// Copyright (c) HashiCorp, Inc. // SPDX-License-Identifier: BUSL-1.1 package database_test import ( "bytes" "context" "database/sql" "encoding/json" "fmt" "net/http" "os" "path/filepath" "testing" "time" "github.com/hashicorp/boundary/api/workers" "github.com/hashicorp/boundary/internal/target" "github.com/hashicorp/boundary/testing/internal/e2e" "github.com/hashicorp/boundary/testing/internal/e2e/boundary" "github.com/hashicorp/boundary/testing/internal/e2e/infra" "github.com/hashicorp/boundary/testing/internal/e2e/vault" _ "github.com/jackc/pgx/v5/stdlib" "github.com/ory/dockertest/v3" "github.com/ory/dockertest/v3/docker" "github.com/stretchr/testify/require" ) type TestEnvironment struct { Pool *dockertest.Pool Network *dockertest.Network Boundary *infra.Container Database *infra.Container Target *infra.Container Vault *infra.Container DbInitInfo boundary.DbInitInfo } // TestDatabaseMigration tests migrating the Boundary database from the latest released version to // the version under test. It creates a database, populates the database with a number of resources, // and uses the Boundary version under test to migrate the database. func TestDatabaseMigration(t *testing.T) { e2e.MaybeSkipTest(t) c, err := loadTestConfig() require.NoError(t, err) ctx := context.Background() boundaryRepo := "hashicorp/boundary" boundaryTag := "latest" te := setupEnvironment(t, c, boundaryRepo, boundaryTag) populateBoundaryDatabase(t, ctx, c, te, boundaryRepo, boundaryTag) // Migrate database t.Log("Stopping boundary before migrating...") err = te.Pool.Client.StopContainer(te.Boundary.Resource.Container.ID, 10) require.NoError(t, err) output := e2e.RunCommand(ctx, "boundary", e2e.WithArgs("version")) require.NoError(t, err) t.Logf("Upgrading to version: %s", output.Stdout) bConfigFilePath, err := filepath.Abs("testdata/boundary-config.hcl") require.NoError(t, err) t.Log("Starting database migration...") output = e2e.RunCommand(ctx, "boundary", e2e.WithArgs("database", "migrate", "-config", bConfigFilePath), e2e.WithEnv("BOUNDARY_POSTGRES_URL", te.Database.UriLocalhost), ) require.NoError(t, output.Err, string(output.Stderr)) t.Logf("%s", output.Stdout) t.Logf("Migration Output: %s", output.Stderr) } func setupEnvironment(t testing.TB, c *config, boundaryRepo, boundaryTag string) TestEnvironment { pool, err := dockertest.NewPool("") require.NoError(t, err) err = pool.Client.Ping() require.NoError(t, err) pool.MaxWait = 10 * time.Second // Set up docker network network, err := pool.CreateNetwork(t.Name()) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, pool.RemoveNetwork(network)) }) // Start Vault v, vaultToken := infra.StartVault(t, pool, network, "hashicorp/vault", "latest") t.Cleanup(func() { if err := pool.Purge(v.Resource); err != nil { t.Logf("error purging pool: %v", err) } }) os.Setenv("VAULT_ADDR", v.UriLocalhost) os.Setenv("VAULT_TOKEN", vaultToken) t.Log("Waiting for Vault to finish loading...") err = pool.Retry(func() error { response, err := http.Get(v.UriLocalhost) if err != nil { t.Logf("Could not access Vault URL: %s. Retrying...", err.Error()) return err } if response.StatusCode != http.StatusOK { return fmt.Errorf("Could not connect to %s. Status Code: %d", v.UriLocalhost, response.StatusCode) } return nil }) require.NoError(t, err) // Start a Boundary database and wait until it's ready db := infra.StartBoundaryDatabase(t, pool, network, "library/postgres", "latest") t.Cleanup(func() { if err := pool.Purge(db.Resource); err != nil { t.Logf("error purging pool: %v", err) } }) t.Log("Waiting for database to load...") err = pool.Retry(func() error { db, err := sql.Open("pgx", db.UriLocalhost) if err != nil { return err } return db.Ping() }) require.NoError(t, err) // Create a target target := infra.StartOpenSshServer( t, pool, network, "linuxserver/openssh-server", "latest", c.TargetSshUser, c.TargetSshKeyPath, ) t.Cleanup(func() { if err := pool.Purge(target.Resource); err != nil { t.Logf("error purging pool: %v", err) } }) // Initialize the database and extract resulting information dbInit := infra.InitBoundaryDatabase( t, pool, network, boundaryRepo, boundaryTag, db.UriNetwork, ) t.Cleanup(func() { if err := pool.Purge(dbInit.Resource); err != nil { t.Logf("error purging pool: %v", err) } }) dbInitInfo := infra.GetDbInitInfoFromContainer(t, pool, dbInit) // Start a Boundary server and wait until Boundary has finished loading b := infra.StartBoundary(t, pool, network, boundaryRepo, boundaryTag, db.UriNetwork) t.Cleanup(func() { pool.Purge(b.Resource) }) os.Setenv("BOUNDARY_ADDR", b.UriLocalhost) buf := bytes.NewBuffer(nil) ebuf := bytes.NewBuffer(nil) _, err = b.Resource.Exec([]string{"boundary", "version"}, dockertest.ExecOptions{ StdOut: buf, StdErr: ebuf, }) require.NoError(t, err) require.Empty(t, ebuf) t.Logf("Using Boundary Version: %s", buf.String()) t.Log("Waiting for Boundary to finish loading...") err = pool.Retry(func() error { response, err := http.Get(fmt.Sprintf("%s/health", b.UriLocalhost)) if err != nil { t.Logf("Could not access health endpoint: %s. Retrying...", err.Error()) return err } if response.StatusCode != http.StatusOK { return fmt.Errorf("Health check returned an error. Status Code: %d", response.StatusCode) } response.Body.Close() return nil }) require.NoError(t, err) return TestEnvironment{ Pool: pool, Network: network, Boundary: b, Database: db, Target: target, Vault: v, DbInitInfo: dbInitInfo, } } func populateBoundaryDatabase(t testing.TB, ctx context.Context, c *config, te TestEnvironment, boundaryRepo, boundaryTag string) { // Create resources for target. Uses the local CLI so that these methods can be reused. // While the CLI version used won't necessarily match the controller version, it should be (and is // supposed to be) backwards compatible boundary.AuthenticateCli(t, ctx, te.DbInitInfo.AuthMethod.AuthMethodId, te.DbInitInfo.AuthMethod.LoginName, te.DbInitInfo.AuthMethod.Password) orgId, err := boundary.CreateOrgCli(t, ctx) require.NoError(t, err) projectId, err := boundary.CreateProjectCli(t, ctx, orgId) require.NoError(t, err) hostCatalogId, err := boundary.CreateHostCatalogCli(t, ctx, projectId) require.NoError(t, err) hostSetId, err := boundary.CreateHostSetCli(t, ctx, hostCatalogId) require.NoError(t, err) hostId, err := boundary.CreateHostCli(t, ctx, hostCatalogId, te.Target.UriNetwork) require.NoError(t, err) err = boundary.AddHostToHostSetCli(t, ctx, hostSetId, hostId) require.NoError(t, err) targetId, err := boundary.CreateTargetCli(t, ctx, projectId, "2222") // openssh-server uses port 2222 require.NoError(t, err) err = boundary.AddHostSourceToTargetCli(t, ctx, targetId, hostSetId) require.NoError(t, err) // Create a target with an address attached _, err = boundary.CreateTargetCli( t, ctx, projectId, "2222", target.WithName("e2e target with address"), target.WithAddress(te.Target.UriNetwork), ) require.NoError(t, err) // Create AWS dynamic host catalog awsHostCatalogId, err := boundary.CreateAwsHostCatalogCli(t, ctx, projectId, c.AwsAccessKeyId, c.AwsSecretAccessKey, c.AwsRegion, false) require.NoError(t, err) awsHostSetId, err := boundary.CreatePluginHostSetCli(t, ctx, awsHostCatalogId, c.AwsHostSetFilter, "4") require.NoError(t, err) boundary.WaitForHostsInHostSetCli(t, ctx, awsHostSetId) // Create a user/group and add role to group accountId, _, err := boundary.CreateAccountCli(t, ctx, te.DbInitInfo.AuthMethod.AuthMethodId, "test-account") require.NoError(t, err) userId, err := boundary.CreateUserCli(t, ctx, "global") require.NoError(t, err) err = boundary.SetAccountToUserCli(t, ctx, userId, accountId) require.NoError(t, err) groupId, err := boundary.CreateGroupCli(t, ctx, "global") require.NoError(t, err) err = boundary.AddUserToGroup(t, ctx, userId, groupId) require.NoError(t, err) roleId, err := boundary.CreateRoleCli(t, ctx, projectId) require.NoError(t, err) err = boundary.AddGrantToRoleCli(t, ctx, roleId, "ids=*;type=target;actions=authorize-session") require.NoError(t, err) err = boundary.AddPrincipalToRoleCli(t, ctx, roleId, groupId) require.NoError(t, err) // Create static credentials storeId, err := boundary.CreateCredentialStoreStaticCli(t, ctx, projectId) require.NoError(t, err) _, err = boundary.CreateStaticCredentialPasswordCli(t, ctx, storeId, c.TargetSshUser, "password") require.NoError(t, err) _, err = boundary.CreateStaticCredentialJsonCli(t, ctx, storeId, "testdata/credential.json") require.NoError(t, err) credentialsId, err := boundary.CreateStaticCredentialPrivateKeyCli(t, ctx, storeId, c.TargetSshUser, c.TargetSshKeyPath) require.NoError(t, err) err = boundary.AddBrokeredCredentialSourceToTargetCli(t, ctx, targetId, credentialsId) require.NoError(t, err) // Create vault credentials boundaryPolicyName, kvPolicyFilePath := vault.Setup(t, "testdata/boundary-controller-policy.hcl") output := e2e.RunCommand(ctx, "vault", e2e.WithArgs("secrets", "enable", "-path="+c.VaultSecretPath, "kv-v2"), ) require.NoError(t, output.Err, string(output.Stderr)) privateKeySecretName := vault.CreateKvPrivateKeyCredential(t, c.VaultSecretPath, c.TargetSshUser, c.TargetSshKeyPath, kvPolicyFilePath) passwordSecretName, _ := vault.CreateKvPasswordCredential(t, c.VaultSecretPath, c.TargetSshUser, kvPolicyFilePath) kvPolicyName := vault.WritePolicy(t, ctx, kvPolicyFilePath) t.Log("Created Vault Credential") output = e2e.RunCommand(ctx, "vault", e2e.WithArgs( "token", "create", "-no-default-policy=true", "-policy="+boundaryPolicyName, "-policy="+kvPolicyName, "-orphan=true", "-period=20m", "-renewable=true", "-format=json", ), ) require.NoError(t, output.Err, string(output.Stderr)) var tokenCreateResult vault.CreateTokenResponse err = json.Unmarshal(output.Stdout, &tokenCreateResult) require.NoError(t, err) credStoreToken := tokenCreateResult.Auth.Client_Token t.Log("Created Vault Cred Store Token") // Create a credential store for vault vaultStoreId, err := boundary.CreateCredentialStoreVaultCli(t, ctx, projectId, te.Vault.UriNetwork, credStoreToken) require.NoError(t, err) // Create a credential library for the private key in vault _, err = boundary.CreateVaultGenericCredentialLibraryCli( t, ctx, vaultStoreId, fmt.Sprintf("%s/data/%s", c.VaultSecretPath, privateKeySecretName), "ssh_private_key", ) require.NoError(t, err) // Create a credential library for the password in vault _, err = boundary.CreateVaultGenericCredentialLibraryCli( t, ctx, vaultStoreId, fmt.Sprintf("%s/data/%s", c.VaultSecretPath, passwordSecretName), "username_password", ) require.NoError(t, err) // Create a worker output = e2e.RunCommand(ctx, "boundary", e2e.WithArgs( "workers", "create", "controller-led", "-name", "e2e worker", "-description", "e2e", "-format", "json", ), ) require.NoError(t, output.Err, string(output.Stderr)) var newWorkerResult workers.WorkerCreateResult err = json.Unmarshal(output.Stdout, &newWorkerResult) require.NoError(t, err) newWorkerId := newWorkerResult.Item.Id t.Logf("Created Worker: %s", newWorkerId) // Create a session. Uses Boundary in a docker container to do the connect in order to avoid // modifying the runner's /etc/hosts file. Otherwise, you would need to add a `127.0.0.1 // localhost boundary` entry into /etc/hosts. buf := bytes.NewBuffer(nil) ebuf := bytes.NewBuffer(nil) _, err = te.Boundary.Resource.Exec( []string{ "boundary", "authenticate", "password", "-addr", te.Boundary.UriNetwork, "-auth-method-id", te.DbInitInfo.AuthMethod.AuthMethodId, "-login-name", te.DbInitInfo.AuthMethod.LoginName, "-password", "env://E2E_TEST_BOUNDARY_PASSWORD", "-keyring-type", "none", "-format", "json", }, dockertest.ExecOptions{ StdOut: buf, StdErr: ebuf, Env: []string{"E2E_TEST_BOUNDARY_PASSWORD=" + te.DbInitInfo.AuthMethod.Password}, }, ) require.NoError(t, err) require.Empty(t, ebuf) var authenticationResult boundary.AuthenticateCliOutput err = json.Unmarshal(buf.Bytes(), &authenticationResult) require.NoError(t, err) auth_token, ok := authenticationResult.Item.Attributes["token"].(string) require.True(t, ok) connectTarget := infra.ConnectToTarget( t, te.Pool, te.Network, boundaryRepo, boundaryTag, te.Boundary.UriNetwork, auth_token, targetId, ) t.Cleanup(func() { if err := te.Pool.Purge(connectTarget.Resource); err != nil { t.Logf("error purging pool: %v", err) } }) _, err = te.Pool.Client.WaitContainer(connectTarget.Resource.Container.ID) require.NoError(t, err) buf = bytes.NewBuffer(nil) ebuf = bytes.NewBuffer(nil) err = te.Pool.Client.Logs(docker.LogsOptions{ Container: connectTarget.Resource.Container.ID, OutputStream: buf, ErrorStream: ebuf, Follow: true, Stdout: true, Stderr: true, }) require.NoError(t, err) require.Empty(t, ebuf) t.Log("Created session") }