diff --git a/.github/workflows/enos-run.yml b/.github/workflows/enos-run.yml index 1487b5d51d..08a416f416 100644 --- a/.github/workflows/enos-run.yml +++ b/.github/workflows/enos-run.yml @@ -80,6 +80,7 @@ jobs: - filter: 'e2e_database' - filter: 'e2e_docker_base builder:crt' - filter: 'e2e_docker_base_plus builder:crt' + - filter: 'e2e_docker_base_with_gcp builder:crt' - filter: 'e2e_docker_base_with_vault builder:crt' - filter: 'e2e_docker_base_with_worker builder:crt' - filter: 'e2e_docker_worker_registration_controller_led builder:crt' @@ -101,6 +102,10 @@ jobs: ENOS_VAR_boundary_docker_image_name: ${{ inputs.docker-image-name }} ENOS_VAR_boundary_docker_image_file: ./support/boundary_docker_image.tar ENOS_VAR_go_version: ${{ inputs.go-version }} + ENOS_VAR_gcp_project_id: ${{ secrets.GCP_PROJECT_ID_CI }} + ENOS_VAR_gcp_client_email: ${{ secrets.GCP_CLIENT_EMAIL_CI }} + ENOS_VAR_gcp_private_key_id: ${{ secrets.GCP_PRIVATE_KEY_ID_CI }} + ENOS_VAR_gcp_private_key: ${{ secrets.GCP_PRIVATE_KEY_CI }} steps: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -147,6 +152,17 @@ jobs: role-to-assume: ${{ secrets.AWS_ROLE_ARN_CI }} role-skip-session-tagging: true role-duration-seconds: 3600 + - name: Configure GCP credentials + if: contains(matrix.filter, 'gcp') + id: gcp_auth + uses: google-github-actions/auth@55bd3a7c6e2ae7cf1877fd1ccb9d54c0503c457c # v2.1.2 + with: + credentials_json: ${{ secrets.GCP_CREDENTIALS }} + access_token_lifetime: '3600s' + project_id: ${{ secrets.GCP_PROJECT_ID_CI }} + - name: 'Set up GCP Cloud SDK' + if: contains(matrix.filter, 'gcp') + uses: google-github-actions/setup-gcloud@98ddc00a17442e89a24bbf282954a3b65ce6d200 # v2.1.0 - name: Set up Enos uses: hashicorp/action-setup-enos@v1 # TSCCR: loading action configs: failed to query HEAD reference: failed to get advertised references: authorization failed with: diff --git a/enos/enos-modules.hcl b/enos/enos-modules.hcl index d2b53ba110..0c7c7facc1 100644 --- a/enos/enos-modules.hcl +++ b/enos/enos-modules.hcl @@ -178,3 +178,15 @@ module "docker_ldap" { module "docker_minio" { source = "./modules/docker_minio" } + +module "gcp_iam_setup" { + source = "./modules/gcp_iam_setup" + gcp_project_id = var.gcp_project_id +} + +module "gcp_target" { + source = "./modules/gcp_target" + target_count = var.target_count + environment = var.environment + enos_user = var.enos_user +} diff --git a/enos/enos-scenario-e2e-docker-base-with-gcp.hcl b/enos/enos-scenario-e2e-docker-base-with-gcp.hcl new file mode 100644 index 0000000000..d70f476487 --- /dev/null +++ b/enos/enos-scenario-e2e-docker-base-with-gcp.hcl @@ -0,0 +1,143 @@ +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: BUSL-1.1 + +# For this scenario to work, add the following line to /etc/hosts +# 127.0.0.1 localhost boundary + +scenario "e2e_docker_base_with_gcp" { + terraform_cli = terraform_cli.default + terraform = terraform.default + providers = [ + provider.enos.default, + provider.google.default + ] + + matrix { + builder = ["local", "crt"] + } + + locals { + local_boundary_dir = var.local_boundary_dir != null ? abspath(var.local_boundary_dir) : null + local_boundary_src_dir = var.local_boundary_src_dir != null ? abspath(var.local_boundary_src_dir) : null + boundary_docker_image_file = abspath(var.boundary_docker_image_file) + license_path = abspath(var.boundary_license_path != null ? var.boundary_license_path : joinpath(path.root, "./support/boundary.hclic")) + gcp_private_key = var.gcp_private_key_path != null ? file(var.gcp_private_key_path) : var.gcp_private_key + + network_cluster = "e2e_gcp" + + build_path = { + "local" = "/tmp", + "crt" = var.crt_bundle_path == null ? null : abspath(var.crt_bundle_path) + } + tags = merge({ + "Project Name" : var.project_name + "Project" : "Enos", + "Environment" : "ci" + }, var.tags) + } + + step "build_boundary_docker_image" { + module = matrix.builder == "crt" ? module.build_boundary_docker_crt : module.build_boundary_docker_local + + variables { + path = matrix.builder == "crt" ? local.boundary_docker_image_file : "" + cli_build_path = local.build_path[matrix.builder] + edition = var.boundary_edition + } + } + + step "create_docker_network" { + module = module.docker_network + variables { + network_name = local.network_cluster + } + } + + step "create_boundary_database" { + depends_on = [ + step.create_docker_network + ] + variables { + image_name = "${var.docker_mirror}/library/postgres:latest" + network_name = [local.network_cluster] + } + module = module.docker_postgres + } + + step "read_license" { + skip_step = var.boundary_edition == "oss" + module = module.read_license + + variables { + file_name = local.license_path + } + } + + step "create_boundary" { + module = module.docker_boundary + depends_on = [ + step.create_docker_network, + step.create_boundary_database, + step.build_boundary_docker_image + ] + variables { + image_name = matrix.builder == "crt" ? var.boundary_docker_image_name : step.build_boundary_docker_image.image_name + network_name = [local.network_cluster] + database_network = local.network_cluster + postgres_address = step.create_boundary_database.address + boundary_license = var.boundary_edition != "oss" ? step.read_license.license : "" + } + } + + step "create_test_id" { + module = module.random_stringifier + variables { + length = 5 + } + } + + step "create_gcp_target" { + module = module.gcp_target + + variables { + enos_user = var.enos_user + instance_type = var.gcp_target_instance_type + gcp_zone = var.gcp_zone + target_count = 1 + } + } + + step "run_e2e_test" { + module = module.test_e2e_docker + depends_on = [ + step.create_boundary, + step.create_gcp_target + ] + variables { + test_package = "github.com/hashicorp/boundary/testing/internal/e2e/tests/gcp" + docker_mirror = var.docker_mirror + network_name = step.create_docker_network.network_name + go_version = var.go_version + debug_no_run = var.e2e_debug_no_run + alb_boundary_api_addr = step.create_boundary.address + auth_method_id = step.create_boundary.auth_method_id + auth_login_name = step.create_boundary.login_name + auth_password = step.create_boundary.password + local_boundary_dir = step.build_boundary_docker_image.cli_zip_path + local_boundary_src_dir = local.local_boundary_src_dir + gcp_host_set_filter1 = step.create_gcp_target.filter_label1 + gcp_host_set_filter2 = step.create_gcp_target.filter_label2 + gcp_private_key_id = var.gcp_private_key_id + gcp_private_key = local.gcp_private_key + gcp_zone = var.gcp_zone + gcp_project_id = var.gcp_project_id + gcp_client_email = var.gcp_client_email + gcp_target_ssh_key = step.create_gcp_target.target_ssh_key + gcp_host_set_ips = step.create_gcp_target.target_ips + target_address = step.create_gcp_target.target_public_ips[0] + target_port = "22" + target_user = "ubuntu" + max_page_size = step.create_boundary.max_page_size + } + } +} diff --git a/enos/enos-variables.hcl b/enos/enos-variables.hcl index aa41eaec93..19a5bf867c 100644 --- a/enos/enos-variables.hcl +++ b/enos/enos-variables.hcl @@ -200,3 +200,56 @@ variable "hcp_boundary_cluster_id" { // If using HCP int, ensure that the cluster id starts with "int-" // Example: "int-19283a-123123-..." } + +variable "gcp_target_instance_type" { + description = "Instance type for test target nodes" + type = string + default = "e2-micro" +} + +variable "gcp_region" { + description = "GCP region where the resources will be created" + type = string + default = "us-central1" +} + +variable "gcp_zone" { + description = "GCP zone where the resources will be created" + type = string + default = "us-central1-a" +} + +variable "gcp_project_id" { + description = "GCP project where the resources will be created" + type = string + sensitive = true + default = "" +} + +variable "gcp_private_key_path" { + description = "Path to the GCP private key" + type = string + sensitive = true + default = null +} + +variable "gcp_private_key" { + description = "GCP private key" + type = string + sensitive = true + default = null +} + +variable "gcp_private_key_id" { + description = "GCP private key ID" + type = string + sensitive = true + default = null +} + +variable "gcp_client_email" { + description = "GCP client email" + type = string + sensitive = true + default = null +} \ No newline at end of file diff --git a/enos/enos.hcl b/enos/enos.hcl index d71352c566..85c68c6bbd 100644 --- a/enos/enos.hcl +++ b/enos/enos.hcl @@ -17,6 +17,11 @@ terraform "default" { source = "hashicorp/aws" version = "5.72.1" } + + google = { + source = "hashicorp/google" + version = "5.22.0" + } } } @@ -32,3 +37,8 @@ provider "enos" "default" { } } } + +provider "google" "default" { + region = var.gcp_region + project = var.gcp_project_id +} diff --git a/enos/enos.vars.hcl b/enos/enos.vars.hcl index 0609134642..c558af8cd1 100644 --- a/enos/enos.vars.hcl +++ b/enos/enos.vars.hcl @@ -60,6 +60,23 @@ // Number of target instances to create. Applies to AWS scenarios only. // target_count = 1 +// The GCP project ID to use for the tests. Only needed if running GCP scenarios. +// gcp_project_id = "my-gcp-project-id" + +// The GCP private_key_path. This is used to authenticate with GCP. Only needed +// if running GCP scenarios. This should not be used in combination with gcp_private_key. +// gcp_private_key_path = "" + +// The GCP private_key. This is used to authenticate with GCP. Only needed +// if running GCP scenarios. This should not be used in combination with gcp_private_key_path. +// gcp_private_key = "" + +// The GCP private_key_id. Only needed if running GCP scenarios. +// gcp_private_key_id = "" + +// The GCP client_email used to authenticate with GCP +// gcp_client_email = "my-gcp-client-email" + // The directory that contains the copy of the boundary cli that the e2e tests // will use in CI. Only needed if e2e_debug_no_run = false. // local_boundary_dir = "/Users//.go/bin" diff --git a/enos/modules/gcp_target/main.tf b/enos/modules/gcp_target/main.tf new file mode 100644 index 0000000000..a983daa06d --- /dev/null +++ b/enos/modules/gcp_target/main.tf @@ -0,0 +1,160 @@ +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: BUSL-1.1 + +terraform { + required_providers { + enos = { + source = "registry.terraform.io/hashicorp-forge/enos" + } + } +} + +variable "target_count" {} +variable "enos_user" {} +variable "additional_labels" { + default = {} +} +variable "instance_type" { + description = "The type of instance to create." + type = string + default = "e2-micro" +} +variable "environment" { + description = "Name of the environment." + type = string + default = "enos-environment" +} +variable "private_cidr_block" { + type = list(string) + default = ["10.0.0.0/8"] +} +variable "gcp_zone" { + description = "The zone to deploy the resources." + type = string + default = "us-central1-a" +} + +data "enos_environment" "current" {} + +resource "random_string" "test_string" { + length = 5 + lower = true + upper = false + numeric = false + special = false +} + +resource "google_compute_network" "boundary_compute_network" { + name = "boundary-enos-network-${random_string.test_string.result}" +} + +resource "random_id" "filter_label1" { + prefix = "enos_boundary" + byte_length = 4 +} + +resource "random_id" "filter_label2" { + prefix = "enos_boundary" + byte_length = 4 +} + +resource "tls_private_key" "ssh" { + algorithm = "RSA" + rsa_bits = 4096 +} + +resource "google_compute_address" "boundary_external_ip" { + count = var.target_count + name = "boundary-external-ip-${random_string.test_string.result}-${count.index}" + address_type = "EXTERNAL" +} + +resource "google_compute_firewall" "boundary_private_ssh" { + name = "boundary-private-ssh-${random_string.test_string.result}" + network = google_compute_network.boundary_compute_network.name + source_ranges = var.private_cidr_block + target_tags = ["boundary-target-${random_string.test_string.result}"] + + allow { + protocol = "tcp" + ports = ["22"] + } +} + +resource "google_compute_firewall" "boundary_enos_ssh" { + name = "boundary-enos-ssh-${random_string.test_string.result}" + network = google_compute_network.boundary_compute_network.name + source_ranges = flatten([formatlist("%s/32", data.enos_environment.current.public_ipv4_addresses)]) + target_tags = ["boundary-target-${random_string.test_string.result}"] + + allow { + protocol = "tcp" + ports = ["22"] + } +} + +resource "google_compute_instance" "boundary_target" { + count = var.target_count + name = "boundary-target-${random_string.test_string.result}-${count.index}" + machine_type = var.instance_type + zone = var.gcp_zone + + boot_disk { + initialize_params { + image = "ubuntu-os-cloud/ubuntu-2204-lts" + } + } + + network_interface { + network = google_compute_network.boundary_compute_network.id + + access_config { + nat_ip = google_compute_address.boundary_external_ip[count.index].address + } + } + + tags = ["boundary-target-${random_string.test_string.result}"] + + metadata = { + ssh-keys = "ubuntu:${tls_private_key.ssh.public_key_openssh}" + } + + labels = merge(var.additional_labels, { + "name" : "boundary-target-${random_string.test_string.result}-${count.index}", + "type" : "target", + "project" : "enos", + "project_name" : "qti-enos-boundary", + "environment" : var.environment, + "enos_user" : var.enos_user, + "filter_label_1" : random_id.filter_label1.hex + "filter_label_2" : random_id.filter_label2.hex + }) +} + +output "target_private_ips" { + value = [for instance in google_compute_instance.boundary_target : instance.network_interface[0].network_ip] +} + +output "target_public_ips" { + value = [for instance in google_compute_instance.boundary_target : instance.network_interface[0].access_config[0].nat_ip] +} + +output "target_ips" { + value = flatten([ + [for instance in google_compute_instance.boundary_target : instance.network_interface[0].network_ip], + [for instance in google_compute_instance.boundary_target : instance.network_interface[0].access_config[0].nat_ip] + ]) +} + +output "target_ssh_key" { + value = tls_private_key.ssh.private_key_pem + sensitive = true +} + +output "filter_label1" { + value = "labels.filter_label_1=${random_id.filter_label1.hex}" +} + +output "filter_label2" { + value = "labels.filter_label_2=${random_id.filter_label2.hex}" +} \ No newline at end of file diff --git a/enos/modules/test_e2e_docker/main.tf b/enos/modules/test_e2e_docker/main.tf index 8bd7b0f0d9..839acfefec 100644 --- a/enos/modules/test_e2e_docker/main.tf +++ b/enos/modules/test_e2e_docker/main.tf @@ -222,6 +222,63 @@ variable "test_timeout" { type = string default = "25m" } +variable "gcp_private_key_id" { + description = "ID of the private key used to authenticate with GCP" + type = string + sensitive = true + default = "" +} + +variable "gcp_private_key" { + description = "Private key used to authenticate with GCP" + type = string + sensitive = true + default = "" +} + +variable "gcp_project_id" { + description = "GCP project where the resources will be created" + type = string + default = "" +} + +variable "gcp_zone" { + description = "GCP zone where the resources will be created" + type = string + default = "" +} + +variable "gcp_target_ssh_key" { + description = "SSH key used to authenticate with GCP target" + type = string + sensitive = true + default = "" +} + +variable "gcp_client_email" { + description = "GCP client email associated with the private key" + type = string + sensitive = true + default = "" +} + +variable "gcp_host_set_filter1" { + description = "value for the first filter in the host set" + type = string + default = "" +} + +variable "gcp_host_set_filter2" { + description = "value for the second filter in the host set" + type = string + default = "" +} + +variable "gcp_host_set_ips" { + description = "List of IP addresses" + type = list(string) + default = [""] +} resource "enos_local_exec" "get_go_version" { count = var.go_version == "" ? 1 : 0 @@ -284,6 +341,15 @@ resource "enos_local_exec" "run_e2e_test" { E2E_LDAP_USER_NAME = var.ldap_user_name E2E_LDAP_USER_PASSWORD = var.ldap_user_password E2E_LDAP_GROUP_NAME = var.ldap_group_name + E2E_GCP_PRIVATE_KEY_ID = var.gcp_private_key_id + E2E_GCP_PRIVATE_KEY = var.gcp_private_key + E2E_GCP_PROJECT_ID = var.gcp_project_id + E2E_GCP_CLIENT_EMAIL = var.gcp_client_email + E2E_GCP_ZONE = var.gcp_zone + E2E_GCP_TARGET_SSH_KEY = var.gcp_target_ssh_key + E2E_GCP_HOST_SET_FILTER1 = var.gcp_host_set_filter1 + E2E_GCP_HOST_SET_FILTER2 = var.gcp_host_set_filter2 + E2E_GCP_HOST_SET_IPS = jsonencode(var.gcp_host_set_ips) E2E_MAX_PAGE_SIZE = var.max_page_size E2E_CONTROLLER_CONTAINER_NAME = var.controller_container_name BOUNDARY_DIR = abspath(var.local_boundary_src_dir) diff --git a/enos/modules/test_e2e_docker/test_runner.sh b/enos/modules/test_e2e_docker/test_runner.sh index e4c1390c73..24e1e99d5b 100644 --- a/enos/modules/test_e2e_docker/test_runner.sh +++ b/enos/modules/test_e2e_docker/test_runner.sh @@ -45,6 +45,15 @@ docker run \ -e "E2E_LDAP_USER_NAME=$E2E_LDAP_USER_NAME" \ -e "E2E_LDAP_USER_PASSWORD=$E2E_LDAP_USER_PASSWORD" \ -e "E2E_LDAP_GROUP_NAME=$E2E_LDAP_GROUP_NAME" \ + -e "E2E_GCP_PRIVATE_KEY_ID=$E2E_GCP_PRIVATE_KEY_ID" \ + -e "E2E_GCP_PRIVATE_KEY=$E2E_GCP_PRIVATE_KEY" \ + -e "E2E_GCP_CLIENT_EMAIL=$E2E_GCP_CLIENT_EMAIL" \ + -e "E2E_GCP_PROJECT_ID=$E2E_GCP_PROJECT_ID" \ + -e "E2E_GCP_ZONE=$E2E_GCP_ZONE" \ + -e "E2E_GCP_TARGET_SSH_KEY=$E2E_GCP_TARGET_SSH_KEY" \ + -e "E2E_GCP_HOST_SET_FILTER1=$E2E_GCP_HOST_SET_FILTER1" \ + -e "E2E_GCP_HOST_SET_FILTER2=$E2E_GCP_HOST_SET_FILTER2" \ + -e "E2E_GCP_HOST_SET_IPS=$E2E_GCP_HOST_SET_IPS" \ -e "E2E_MAX_PAGE_SIZE=$E2E_MAX_PAGE_SIZE" \ -e "E2E_CONTROLLER_CONTAINER_NAME=$E2E_CONTROLLER_CONTAINER_NAME" \ --mount type=bind,src=$BOUNDARY_DIR,dst=/src/boundary/ \ diff --git a/testing/internal/e2e/boundary/host.go b/testing/internal/e2e/boundary/host.go index a98277874d..f85df4eeab 100644 --- a/testing/internal/e2e/boundary/host.go +++ b/testing/internal/e2e/boundary/host.go @@ -240,9 +240,9 @@ func CreateAwsHostCatalogCli(t testing.TB, ctx context.Context, projectId, acces return hostCatalogId, nil } -// CreateAwsHostSetCli uses the cli to create a new host set from an AWS dynamic host catalog. +// CreatePluginHostSetCli uses the cli to create a new host set from a dynamic host catalog. // Returns the id of the new host set. -func CreateAwsHostSetCli(t testing.TB, ctx context.Context, hostCatalogId string, filter string) (string, error) { +func CreatePluginHostSetCli(t testing.TB, ctx context.Context, hostCatalogId string, filter string) (string, error) { name, err := base62.Random(16) if err != nil { return "", err @@ -359,3 +359,53 @@ func WaitForNumberOfHostsInHostSetCli(t testing.TB, ctx context.Context, hostSet ) require.NoError(t, err) } + +// CreateGcpHostCatalogCli uses the cli to create a new GCP dynamic host catalog. +// Returns the id of the new host catalog. +func CreateGcpHostCatalogCli( + t testing.TB, + ctx context.Context, + projectId string, + gcpProjectId string, + clientEmail string, + privateKeyId string, + privateKey string, + zone string, +) (string, error) { + name, err := base62.Random(16) + if err != nil { + return "", err + } + + output := e2e.RunCommand(ctx, "boundary", + e2e.WithArgs( + "host-catalogs", "create", "plugin", + "-scope-id", projectId, + "-plugin-name", "gcp", + "-attr", "disable_credential_rotation=true", + "-attr", fmt.Sprintf("project_id=%s", gcpProjectId), + "-attr", fmt.Sprintf("client_email=%s", clientEmail), + "-attr", fmt.Sprintf("zone=%s", zone), + "-secret", "private_key_id=env://E2E_GCP_PRIVATE_KEY_ID", + "-secret", "private_key=env://E2E_GCP_PRIVATE_KEY", + "-name", fmt.Sprintf("e2e Host Catalog %s", name), + "-description", "e2e", + "-format", "json", + ), + e2e.WithEnv("E2E_GCP_PRIVATE_KEY_ID", privateKeyId), + e2e.WithEnv("E2E_GCP_PRIVATE_KEY", privateKey), + ) + if output.Err != nil { + return "", fmt.Errorf("%w: %s", output.Err, string(output.Stderr)) + } + + var createHostCatalogResult hostcatalogs.HostCatalogCreateResult + err = json.Unmarshal(output.Stdout, &createHostCatalogResult) + if err != nil { + return "", err + } + + hostCatalogId := createHostCatalogResult.Item.Id + t.Logf("Created Host Catalog: %s", hostCatalogId) + return hostCatalogId, nil +} diff --git a/testing/internal/e2e/tests/aws/dynamichostcatalog_host_set_empty_test.go b/testing/internal/e2e/tests/aws/dynamichostcatalog_host_set_empty_test.go index 11a5f4dfe9..a37f517d49 100644 --- a/testing/internal/e2e/tests/aws/dynamichostcatalog_host_set_empty_test.go +++ b/testing/internal/e2e/tests/aws/dynamichostcatalog_host_set_empty_test.go @@ -42,7 +42,7 @@ func TestCliCreateAwsDynamicHostCatalogWithEmptyHostSet(t *testing.T) { require.NoError(t, err) // Set up a host set - hostSetId, err := boundary.CreateAwsHostSetCli(t, ctx, hostCatalogId, "tag:empty_test=true") + hostSetId, err := boundary.CreatePluginHostSetCli(t, ctx, hostCatalogId, "tag:empty_test=true") require.NoError(t, err) // Check that there are no hosts in the host set diff --git a/testing/internal/e2e/tests/aws/dynamichostcatalog_host_set_test.go b/testing/internal/e2e/tests/aws/dynamichostcatalog_host_set_test.go index 8d413b1c6f..517fec504d 100644 --- a/testing/internal/e2e/tests/aws/dynamichostcatalog_host_set_test.go +++ b/testing/internal/e2e/tests/aws/dynamichostcatalog_host_set_test.go @@ -47,7 +47,7 @@ func TestCliCreateAwsDynamicHostCatalogWithHostSet(t *testing.T) { require.NoError(t, err) // Set up a host set - hostSetId1, err := boundary.CreateAwsHostSetCli(t, ctx, hostCatalogId, c.AwsHostSetFilter1) + hostSetId1, err := boundary.CreatePluginHostSetCli(t, ctx, hostCatalogId, c.AwsHostSetFilter1) require.NoError(t, err) var targetIps1 []string err = json.Unmarshal([]byte(c.AwsHostSetIps1), &targetIps1) @@ -56,7 +56,7 @@ func TestCliCreateAwsDynamicHostCatalogWithHostSet(t *testing.T) { boundary.WaitForNumberOfHostsInHostSetCli(t, ctx, hostSetId1, expectedHostSetCount1) // Set up another host set - hostSetId2, err := boundary.CreateAwsHostSetCli(t, ctx, hostCatalogId, c.AwsHostSetFilter2) + hostSetId2, err := boundary.CreatePluginHostSetCli(t, ctx, hostCatalogId, c.AwsHostSetFilter2) require.NoError(t, err) var targetIps2 []string err = json.Unmarshal([]byte(c.AwsHostSetIps2), &targetIps2) diff --git a/testing/internal/e2e/tests/database/migration_test.go b/testing/internal/e2e/tests/database/migration_test.go index fd6207743c..7e6c89558b 100644 --- a/testing/internal/e2e/tests/database/migration_test.go +++ b/testing/internal/e2e/tests/database/migration_test.go @@ -243,7 +243,7 @@ func populateBoundaryDatabase(t testing.TB, ctx context.Context, c *config, te T // Create AWS dynamic host catalog awsHostCatalogId, err := boundary.CreateAwsHostCatalogCli(t, ctx, projectId, c.AwsAccessKeyId, c.AwsSecretAccessKey, c.AwsRegion) require.NoError(t, err) - awsHostSetId, err := boundary.CreateAwsHostSetCli(t, ctx, awsHostCatalogId, c.AwsHostSetFilter) + awsHostSetId, err := boundary.CreatePluginHostSetCli(t, ctx, awsHostCatalogId, c.AwsHostSetFilter) require.NoError(t, err) boundary.WaitForHostsInHostSetCli(t, ctx, awsHostSetId) diff --git a/testing/internal/e2e/tests/gcp/dynamichostcatalog_host_set_empty_test.go b/testing/internal/e2e/tests/gcp/dynamichostcatalog_host_set_empty_test.go new file mode 100644 index 0000000000..6b96a1c14b --- /dev/null +++ b/testing/internal/e2e/tests/gcp/dynamichostcatalog_host_set_empty_test.go @@ -0,0 +1,138 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package gcp_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "testing" + "time" + + "github.com/hashicorp/boundary/api/hostcatalogs" + "github.com/hashicorp/boundary/api/hostsets" + "github.com/hashicorp/boundary/testing/internal/e2e" + "github.com/hashicorp/boundary/testing/internal/e2e/boundary" + "github.com/stretchr/testify/require" +) + +// TestCliCreateGcpDynamicHostCatalogWithEmptyHostSet uses the boundary cli to create a host catalog with the GCP +// plugin. The test sets up an GCP dynamic host catalog, creates some host sets, sets up a target to +// one of the host sets, and attempts to connect to the target. +func TestCliCreateGcpDynamicHostCatalogWithEmptyHostSet(t *testing.T) { + e2e.MaybeSkipTest(t) + c, err := loadTestConfig() + require.NoError(t, err) + + ctx := context.Background() + boundary.AuthenticateAdminCli(t, ctx) + orgId, err := boundary.CreateOrgCli(t, ctx) + require.NoError(t, err) + t.Cleanup(func() { + ctx := context.Background() + boundary.AuthenticateAdminCli(t, ctx) + output := e2e.RunCommand(ctx, "boundary", e2e.WithArgs("scopes", "delete", "-id", orgId)) + require.NoError(t, output.Err, string(output.Stderr)) + }) + projectId, err := boundary.CreateProjectCli(t, ctx, orgId) + require.NoError(t, err) + hostCatalogId, err := boundary.CreateGcpHostCatalogCli(t, ctx, projectId, c.GcpProjectId, c.GcpClientEmail, c.GcpPrivateKeyId, c.GcpPrivateKey, c.GcpZone) + require.NoError(t, err) + + // Set up a host set + hostSetId, err := boundary.CreatePluginHostSetCli(t, ctx, hostCatalogId, "labels.empty_test=true") + require.NoError(t, err) + + // Check that there are no hosts in the host set + t.Logf("Looking for items in the host set...") + var actualHostSetCount int + for i := 0; i < 3; i++ { + if i != 0 { + time.Sleep(3 * time.Second) + } + + output := e2e.RunCommand(ctx, "boundary", + e2e.WithArgs( + "host-sets", "read", + "-id", hostSetId, + "-format", "json", + ), + ) + require.NoError(t, output.Err, string(output.Stderr)) + var hostSetsReadResult hostsets.HostSetReadResult + err := json.Unmarshal(output.Stdout, &hostSetsReadResult) + require.NoError(t, err) + + actualHostSetCount = len(hostSetsReadResult.Item.HostIds) + require.Equal(t, 0, actualHostSetCount, + fmt.Sprintf("Detected incorrect number of hosts. Expected: 0, Actual: %d", actualHostSetCount), + ) + } + t.Log("Successfully detected zero hosts in the host set") + + // Check that there are no hosts in the host catalog + t.Logf("Looking for items in the host catalog...") + var actualHostCatalogCount int + for i := 0; i < 3; i++ { + if i != 0 { + time.Sleep(3 * time.Second) + } + + output := e2e.RunCommand(ctx, "boundary", + e2e.WithArgs("hosts", "list", "-host-catalog-id", hostCatalogId, "-format", "json"), + ) + require.NoError(t, output.Err, string(output.Stderr)) + var hostCatalogListResult hostcatalogs.HostCatalogListResult + err := json.Unmarshal(output.Stdout, &hostCatalogListResult) + require.NoError(t, err) + + actualHostCatalogCount = len(hostCatalogListResult.Items) + require.Equal(t, 0, actualHostCatalogCount, + fmt.Sprintf("Detected incorrect number of hosts. Expected: 0, Actual: %d", actualHostCatalogCount), + ) + } + t.Log("Successfully detected zero hosts in the host catalog") + + // Create target + targetId, err := boundary.CreateTargetCli(t, ctx, projectId, c.GcpTargetPort) + require.NoError(t, err) + err = boundary.AddHostSourceToTargetCli(t, ctx, targetId, hostSetId) + require.NoError(t, err) + + // Create a temporary file to store the SSH key string + tempFile, err := os.CreateTemp("./", "ssh-key.pem") + require.NoError(t, err) + defer os.Remove(tempFile.Name()) + + // Write the SSH key string to the temporary file + _, err = tempFile.WriteString(c.GcpTargetSshKey) + require.NoError(t, err) + err = tempFile.Close() + require.NoError(t, err) + + // Attempt to connect to target + output := e2e.RunCommand(ctx, "boundary", + e2e.WithArgs( + "connect", + "-target-id", targetId, + "-format", "json", + "-exec", "/usr/bin/ssh", "--", + "-l", c.GcpTargetSshUser, + "-i", tempFile.Name(), + "-o", "UserKnownHostsFile=/dev/null", + "-o", "StrictHostKeyChecking=no", + "-o", "IdentitiesOnly=yes", // forces the use of the provided key + "-p", "{{boundary.port}}", // this is provided by boundary + "{{boundary.ip}}", + "hostname", "-i", + ), + ) + var response boundary.CliError + err = json.Unmarshal(output.Stderr, &response) + require.NoError(t, err) + require.Equal(t, http.StatusNotFound, response.Status, "Expected to error when connecting to a target with zero hosts") + t.Log("Successfully failed to connect to target") +} diff --git a/testing/internal/e2e/tests/gcp/dynamichostcatalog_host_set_test.go b/testing/internal/e2e/tests/gcp/dynamichostcatalog_host_set_test.go new file mode 100644 index 0000000000..0ac7274059 --- /dev/null +++ b/testing/internal/e2e/tests/gcp/dynamichostcatalog_host_set_test.go @@ -0,0 +1,281 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package gcp_test + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "strings" + "testing" + "time" + + "github.com/cenkalti/backoff/v4" + "github.com/hashicorp/boundary/api/hostcatalogs" + "github.com/hashicorp/boundary/api/hosts" + "github.com/hashicorp/boundary/api/hostsets" + "github.com/hashicorp/boundary/api/scopes" + "github.com/hashicorp/boundary/testing/internal/e2e" + "github.com/hashicorp/boundary/testing/internal/e2e/boundary" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestCliCreateGcpDynamicHostCatalogWithHostSet uses the boundary cli to create a host catalog with the GCP +// plugin. The test sets up an GCP dynamic host catalog, creates some host sets, sets up a target to +// one of the host sets, and attempts to connect to the target. +func TestCliCreateGcpDynamicHostCatalogWithHostSet(t *testing.T) { + e2e.MaybeSkipTest(t) + c, err := loadTestConfig() + require.NoError(t, err) + + ctx := context.Background() + boundary.AuthenticateAdminCli(t, ctx) + orgId, err := boundary.CreateOrgCli(t, ctx) + require.NoError(t, err) + t.Cleanup(func() { + ctx := context.Background() + boundary.AuthenticateAdminCli(t, ctx) + output := e2e.RunCommand(ctx, "boundary", e2e.WithArgs("scopes", "delete", "-id", orgId)) + require.NoError(t, output.Err, string(output.Stderr)) + }) + projectId, err := boundary.CreateProjectCli(t, ctx, orgId) + require.NoError(t, err) + hostCatalogId, err := boundary.CreateGcpHostCatalogCli(t, ctx, projectId, c.GcpProjectId, c.GcpClientEmail, c.GcpPrivateKeyId, c.GcpPrivateKey, c.GcpZone) + require.NoError(t, err) + + // Set up a host set + hostSetId1, err := boundary.CreatePluginHostSetCli(t, ctx, hostCatalogId, c.GcpHostSetFilter1) + require.NoError(t, err) + boundary.WaitForNumberOfHostsInHostSetCli(t, ctx, hostSetId1, 1) + + // Set up another host set + hostSetId2, err := boundary.CreatePluginHostSetCli(t, ctx, hostCatalogId, c.GcpHostSetFilter2) + require.NoError(t, err) + boundary.WaitForNumberOfHostsInHostSetCli(t, ctx, hostSetId2, 1) + + // Update host set with a different filter + t.Log("Updating host set 2 with host set 1's filter...") + output := e2e.RunCommand(ctx, "boundary", + e2e.WithArgs( + "host-sets", "update", "plugin", + "-id", hostSetId2, + "-attr", fmt.Sprintf("filters=%s", c.GcpHostSetFilter1), + ), + ) + require.NoError(t, output.Err, string(output.Stderr)) + boundary.WaitForNumberOfHostsInHostSetCli(t, ctx, hostSetId2, 1) + + // update host set to use preferred endpoints + t.Log("Updating host set 1 to use preferred endpoint...") + output = e2e.RunCommand(ctx, "boundary", + e2e.WithArgs( + "host-sets", "update", "plugin", + "-id", hostSetId1, + "-preferred-endpoint", fmt.Sprintf("cidr:%s/32", c.GcpTargetAddress), + ), + ) + require.NoError(t, output.Err, string(output.Stderr)) + + // Get list of all hosts from host catalog + t.Logf("Looking for items in the host catalog...") + var actualHostCatalogCount int + err = backoff.RetryNotify( + func() error { + output := e2e.RunCommand(ctx, "boundary", + e2e.WithArgs("hosts", "list", "-host-catalog-id", hostCatalogId, "-format", "json"), + ) + if output.Err != nil { + return backoff.Permanent(errors.New(string(output.Stderr))) + } + + var hostCatalogListResult hostcatalogs.HostCatalogListResult + err := json.Unmarshal(output.Stdout, &hostCatalogListResult) + if err != nil { + return backoff.Permanent(err) + } + + t.Logf("Found %v host(s)", len(hostCatalogListResult.GetItems())) + + actualHostCatalogCount = len(hostCatalogListResult.Items) + if actualHostCatalogCount == 0 { + return errors.New("No items are appearing in the host catalog") + } + + t.Logf("Found %d host(s)", actualHostCatalogCount) + return nil + }, + backoff.WithMaxRetries(backoff.NewConstantBackOff(3*time.Second), 5), + func(err error, td time.Duration) { + t.Logf("%s. Retrying...", err.Error()) + }, + ) + require.NoError(t, err) + assert.Equal(t, 1, actualHostCatalogCount, "Numbers of hosts in host catalog did not match expected amount") + + // Create target + targetId, err := boundary.CreateTargetCli(t, ctx, projectId, c.GcpTargetPort) + require.NoError(t, err) + err = boundary.AddHostSourceToTargetCli(t, ctx, targetId, hostSetId1) + require.NoError(t, err) + + // Create a temporary file to store the SSH key string + tempFile, err := os.CreateTemp("./", "ssh-key.pem") + require.NoError(t, err) + defer os.Remove(tempFile.Name()) + + // Write the SSH key string to the temporary file + _, err = tempFile.WriteString(c.GcpTargetSshKey) + require.NoError(t, err) + err = tempFile.Close() + require.NoError(t, err) + + // Connect to target + output = e2e.RunCommand(ctx, "boundary", + e2e.WithArgs( + "connect", + "-target-id", targetId, + "-exec", "/usr/bin/ssh", "--", + "-l", c.GcpTargetSshUser, + "-i", tempFile.Name(), + "-o", "UserKnownHostsFile=/dev/null", + "-o", "StrictHostKeyChecking=no", + "-o", "IdentitiesOnly=yes", // forces the use of the provided key + "-p", "{{boundary.port}}", // this is provided by boundary + "{{boundary.ip}}", + "hostname", "-i", + ), + ) + require.NoError(t, output.Err, string(output.Stderr)) + + parts := strings.Fields(string(output.Stdout)) + hostIp := parts[len(parts)-1] + t.Log("Successfully connected to the target") + + // Check if connected host exists in the host set + var targetIps []string + err = json.Unmarshal([]byte(c.GcpHostSetIps), &targetIps) + require.NoError(t, err) + hostIpInList := false + for _, v := range targetIps { + if v == hostIp { + hostIpInList = true + } + } + require.True(t, hostIpInList, fmt.Sprintf("Connected host (%s) is not in expected list (%s)", hostIp, targetIps)) +} + +// TestApiCreateGcpDynamicHostCatalog uses the Go api to create a host catalog with the GCP plugin. +// The test sets up an GCP dynamic host catalog, creates a host set, and sets up a target to the +// host set. +func TestApiCreateGCPDynamicHostCatalog(t *testing.T) { + e2e.MaybeSkipTest(t) + c, err := loadTestConfig() + require.NoError(t, err) + + client, err := boundary.NewApiClient() + require.NoError(t, err) + ctx := context.Background() + + orgId, err := boundary.CreateOrgApi(t, ctx, client) + require.NoError(t, err) + t.Cleanup(func() { + scopeClient := scopes.NewClient(client) + _, err := scopeClient.Delete(ctx, orgId) + require.NoError(t, err) + }) + projectId, err := boundary.CreateProjectApi(t, ctx, client, orgId) + require.NoError(t, err) + + // Create a dynamic host catalog + hcClient := hostcatalogs.NewClient(client) + newHostCatalogResult, err := hcClient.Create(ctx, "plugin", projectId, + hostcatalogs.WithName("e2e Automated Test Host Catalog"), + hostcatalogs.WithPluginName("gcp"), + hostcatalogs.WithAttributes(map[string]any{ + "disable_credential_rotation": true, + "project_id": c.GcpProjectId, + "client_email": c.GcpClientEmail, + "zone": c.GcpZone, + }), + hostcatalogs.WithSecrets(map[string]any{ + "private_key_id": c.GcpPrivateKeyId, + "private_key": c.GcpPrivateKey, + }), + ) + require.NoError(t, err) + newHostCatalogId := newHostCatalogResult.Item.Id + t.Logf("Created Host Catalog: %s", newHostCatalogId) + + // Create a host set and add to catalog + hsClient := hostsets.NewClient(client) + newHostSetResult, err := hsClient.Create(ctx, newHostCatalogId, + hostsets.WithAttributes(map[string]any{ + "filters": c.GcpHostSetFilter1, + }), + hostsets.WithName("e2e Automated Test Host Set"), + ) + require.NoError(t, err) + newHostSetId := newHostSetResult.Item.Id + t.Logf("Created Host Set: %s", newHostSetId) + + // Get list of hosts in host set + // Retry is needed here since it can take a few tries before hosts start appearing + t.Logf("Looking for items in the host set...") + var actualHostSetCount int + err = backoff.RetryNotify( + func() error { + hostSetReadResult, err := hsClient.Read(ctx, newHostSetId) + if err != nil { + return backoff.Permanent(err) + } + + actualHostSetCount = len(hostSetReadResult.Item.HostIds) + if actualHostSetCount == 0 { + return errors.New("No items are appearing in the host set") + } + + t.Logf("Found %d hosts", actualHostSetCount) + return nil + }, + backoff.WithMaxRetries(backoff.NewConstantBackOff(3*time.Second), 5), + func(err error, td time.Duration) { + t.Logf("%s. Retrying...", err.Error()) + }, + ) + require.NoError(t, err) + t.Log("Successfully found items in the host set") + assert.Equal(t, 1, actualHostSetCount, "Numbers of hosts in host set did not match expected amount") + + // Get list of all hosts from host catalog + // Retry is needed here since it can take a few tries before hosts start appearing + t.Logf("Looking for items in the host catalog...") + var actualHostCatalogCount int + hClient := hosts.NewClient(client) + err = backoff.RetryNotify( + func() error { + hostListResult, err := hClient.List(ctx, newHostCatalogId) + if err != nil { + return backoff.Permanent(err) + } + + actualHostCatalogCount = len(hostListResult.Items) + if actualHostCatalogCount == 0 { + return errors.New("No items are appearing in the host catalog") + } + + t.Logf("Found %d hosts", actualHostCatalogCount) + return nil + }, + backoff.WithMaxRetries(backoff.NewConstantBackOff(3*time.Second), 5), + func(err error, td time.Duration) { + t.Logf("%s. Retrying...", err.Error()) + }, + ) + require.NoError(t, err) + t.Log("Successfully found items in the host catalog") + assert.Equal(t, 1, actualHostCatalogCount, "Numbers of hosts in host catalog did not match expected amount") +} diff --git a/testing/internal/e2e/tests/gcp/env_test.go b/testing/internal/e2e/tests/gcp/env_test.go new file mode 100644 index 0000000000..4df85fa915 --- /dev/null +++ b/testing/internal/e2e/tests/gcp/env_test.go @@ -0,0 +1,31 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package gcp_test + +import "github.com/kelseyhightower/envconfig" + +type config struct { + GcpPrivateKeyId string `envconfig:"E2E_GCP_PRIVATE_KEY_ID" required:"true"` + GcpPrivateKey string `envconfig:"E2E_GCP_PRIVATE_KEY" required:"true"` + GcpZone string `envconfig:"E2E_GCP_ZONE" required:"true"` // e.g. "us-central1-a" + GcpProjectId string `envconfig:"E2E_GCP_PROJECT_ID" required:"true"` // e.g. "my-project" + GcpClientEmail string `envconfig:"E2E_GCP_CLIENT_EMAIL" required:"true"` + GcpHostSetFilter1 string `envconfig:"E2E_GCP_HOST_SET_FILTER1" required:"true"` + GcpHostSetFilter2 string `envconfig:"E2E_GCP_HOST_SET_FILTER2" required:"true"` + GcpHostSetIps string `envconfig:"E2E_GCP_HOST_SET_IPS" required:"true"` + GcpTargetSshKey string `envconfig:"E2E_GCP_TARGET_SSH_KEY" required:"true"` + GcpTargetAddress string `envconfig:"E2E_TARGET_ADDRESS" required:"true"` // e.g. "192.168.0.1" + GcpTargetSshUser string `envconfig:"E2E_SSH_USER" required:"true"` // e.g. "ubuntu" + GcpTargetPort string `envconfig:"E2E_TARGET_PORT" required:"true"` // e.g. "22" +} + +func loadTestConfig() (*config, error) { + var c config + err := envconfig.Process("", &c) + if err != nil { + return nil, err + } + + return &c, nil +}