diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ef5319e0..6bd26c86 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -22,3 +22,14 @@ updates: prefix: "dep" labels: - run-ci +- package-ecosystem: gomod + directory: /acceptance-tests + schedule: + interval: weekly + day: "monday" + time: "09:00" + timezone: "Europe/Berlin" + commit-message: + prefix: "dep" + labels: + - run-ci diff --git a/.gitignore b/.gitignore index 74d94387..d2b403b9 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ package.json # Releases/tarballs .final_builds/jobs/**/*.tgz .final_builds/packages/**/*.tgz +ci/scripts/stemcell/*.tgz pcap-server.tgz releases/*.tgz releases/**/*.tgz diff --git a/acceptance-tests/README.md b/acceptance-tests/README.md new file mode 100644 index 00000000..53702ba2 --- /dev/null +++ b/acceptance-tests/README.md @@ -0,0 +1,101 @@ +# Acceptance Tests + +## Requirements + +* Docker installed locally +* A matching Jammy stemcell tgz downloaded to `ci/scripts/stemcell` + * Get it from https://bosh.io/stemcells/bosh-warden-boshlite-ubuntu-jammy-go_agent + +## Running + +```shell +cd acceptance-tests +./run-local.sh +``` + +### Running on Docker for Mac + +The BOSH Docker CPI requires cgroups v1 to be active. Docker for Mac since 4.3.x uses cgroups v2 by default. + +v1 can be restored with the flag `deprecatedCgroupv1` to `true` in `~/Library/Group Containers/group.com.docker/settings.json`. + +A convenience script that does this for you is below. + +**WARNING:** This will restart your Docker Desktop! + +```shell +docker_restart_with_cgroupsv1() { + SETTINGS=~/Library/Group\ Containers/group.com.docker/settings.json + + if ! command -v jq >/dev/null || ! command -v sponge; then + echo "Requires jq and sponge. Consider installing via:" + echo " brew install jq moreutils" + return + fi + + cgroupsV1Enabled=$(jq '.deprecatedCgroupv1' "$SETTINGS") + if [ "$cgroupsV1Enabled" = "true" ]; then + echo "deprecatedCgroupv1 is already set to 'true'. Acceptance tests should work." + else + echo "Stopping Docker to set the config flag deprecatedCgroupv1 = true in $SETTINGS" + + while docker ps -q 2>/dev/null; do + launchctl stop $(launchctl list | grep docker.docker | awk '{print $3}') + osascript -e 'quit app "Docker"' + echo "Waiting for Docker daemon to stop responding." + sleep 1 + done + echo 'Setting "deprecatedCgroupv1" to true.' + + # Add the needed cgroup config to docker settings.json + # sponge is needed because we're updating the same file in place + echo '{"deprecatedCgroupv1": true}' | + jq -s '.[0] * .[1]' "$SETTINGS" - | + sponge "$SETTINGS" + # Restart docker desktop + echo "Restarting Docker" + open --background -a Docker + + while ! docker ps -q 2>/dev/null; do + echo "Waiting for Docker daemon to be back up again. Sleeping 1s." + sleep 1 + done + fi + + docker info | grep "Cgroup" +} + +docker_restart_with_cgroupsv1 +``` + +The output at the end should be: +```plain + Cgroup Driver: cgroupfs + Cgroup Version: 1 +``` + +### Focussed Tests + +If you want to run only a specific part of the suite, you can use [focussed specs](https://onsi.github.io/ginkgo/#focused-specs) + +The easiest way is to just provide the name of the tests you want to run as a command line argument like so: + +```shell +./run-local.sh "description of the test to run" +``` + +The argument is passed as a regular expression that will match all `Describe`, `Context` or `It` closure descriptions in the suite. +So, e.g. if you want to run all tests that use mTLS, you can run: + +```shell +./run-local.sh mTLS +``` + +However, if you want to run exactly one specific test, make sure you pass the exact description of the matching `It` closure: + +```shell +./run-local.sh "Correctly terminates mTLS requests" +``` + +Alternatively, you add an `F` to your `Describe`, `Context` or `It` closures. +Don't forget to do a local commit before running the tests, else BOSH will fail to produce a release. The git repo must be clean before running the tests. \ No newline at end of file diff --git a/acceptance-tests/acceptance_tests_suite_test.go b/acceptance-tests/acceptance_tests_suite_test.go new file mode 100644 index 00000000..8ae92881 --- /dev/null +++ b/acceptance-tests/acceptance_tests_suite_test.go @@ -0,0 +1,50 @@ +package acceptance_tests + +import ( + "encoding/json" + "fmt" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "testing" +) + +func TestAcceptanceTests(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "AcceptanceTests Suite") +} + +var _ = SynchronizedBeforeSuite(func() []byte { + // Load config once, and pass to other + // threads as JSON-encoded byte array + var err error + config, err = loadConfig() + Expect(err).NotTo(HaveOccurred(), "loading common config") + + // Deploy pcap-api deployment + deployPcap( + baseManifestVars{ + deploymentName: deploymentNameForTestNode(), + }, + []string{}, + map[string]interface{}{}, + true, + ) + + configBytes, err := json.Marshal(&config) + Expect(err).NotTo(HaveOccurred()) + + return configBytes +}, func(configBytes []byte) { + // populate thread-local variable `config` in each thread + err := json.Unmarshal(configBytes, &config) + Expect(err).NotTo(HaveOccurred()) +}) + +var _ = SynchronizedAfterSuite(func() { + // Clean up deployments on each thread + deleteDeployment(deploymentNameForTestNode()) +}, func() {}) + +func deploymentNameForTestNode() string { + return fmt.Sprintf("pcap-%d", GinkgoParallelProcess()) +} diff --git a/acceptance-tests/basic_pcap_test.go b/acceptance-tests/basic_pcap_test.go new file mode 100644 index 00000000..7d496014 --- /dev/null +++ b/acceptance-tests/basic_pcap_test.go @@ -0,0 +1,68 @@ +package acceptance_tests + +import ( + "fmt" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/onsi/gomega/gexec" + "net/http" + "os" + "os/exec" + "time" +) + +var _ = Describe("Pcap Deployment", func() { + It("Deploys and Captures Traffic Successfully", func() { + + info, _ := deployPcap( + baseManifestVars{ + deploymentName: deploymentNameForTestNode(), + }, + []string{}, + map[string]interface{}{}, + true, + ) + + By("Logging on to BOSH director to get a refresh token") + login(config.BoshClient, config.BoshClientSecret) + + pcapBoshCliFile, err := os.CreateTemp("", "pcap-bosh-cli-*") + Expect(err).NotTo(HaveOccurred()) + pcapBoshCli := pcapBoshCliFile.Name() + + By("Downloading remote pcap-bosh-cli-linux-amd64 to " + pcapBoshCli) + err = downloadFile(info, "/var/vcap/packages/pcap-api/bin/cli/build/pcap-bosh-cli-linux-amd64", pcapBoshCliFile, 0755) + Expect(err).NotTo(HaveOccurred()) + err = pcapBoshCliFile.Close() + Expect(err).NotTo(HaveOccurred()) + + pcapFile := fmt.Sprintf("%s-capture.pcap", pcapBoshCli) + By("Starting capture of traffic on pcap-agent instance to file " + pcapFile) + cmdPcap := exec.Command( + pcapBoshCli, + "-d", deploymentNameForTestNode(), + "-g", "pcap-agent", + "-o", pcapFile, + "-u", fmt.Sprintf("http://%s:8080/", info.PcapAPIPublicIP), //TODO: make URL configurable in tests + "-v") + sessionPcap, err := gexec.Start(cmdPcap, GinkgoWriter, GinkgoWriter) + Expect(err).NotTo(HaveOccurred()) + + By("Calling apache on pcap-agent instance to produce some traffic") + for request := 1; request <= 10; request++ { + time.Sleep(time.Second) + response, err := http.Get(fmt.Sprintf("http://%s:80/", info.PcapAgentPublicIP)) + Expect(err).NotTo(HaveOccurred()) + Expect(response.StatusCode).To(Equal(200)) + } + + By("Stopping capture after curl has finished") + sessionPcap.Interrupt() + Eventually(sessionPcap, time.Minute, time.Second).Should(gexec.Exit()) + + By("Checking that the capture has produced a valid pcap file") + pcapFileStat, err := os.Stat(pcapFile) + Expect(err).NotTo(HaveOccurred()) + Expect(pcapFileStat.Size()).To(BeNumerically(">", 24)) // 24 bytes == pcap header only + }) +}) diff --git a/acceptance-tests/bosh_helpers.go b/acceptance-tests/bosh_helpers.go new file mode 100644 index 00000000..3e82a719 --- /dev/null +++ b/acceptance-tests/bosh_helpers.go @@ -0,0 +1,369 @@ +package acceptance_tests + +// Helper method for deploying pcap. +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/onsi/gomega/gexec" + "gopkg.in/yaml.v3" +) + +// pcapInfo contains the state of a currently deployed pcap release, as needed for the test suite. +type pcapInfo struct { + SSHPrivateKey string + SSHPublicKey string + SSHPublicKeyFingerprint string + SSHUser string + PcapAPIPublicIP string + PcapAgentPublicIP string +} + +type baseManifestVars struct { + deploymentName string +} + +type varsStoreReader func(interface{}) error + +var opsfileChangeName string = `--- +# change deployment name to allow multiple simultaneous deployments +- type: replace + path: /name + value: ((deployment-name)) +` + +var opsfileChangeVersion string = `--- +# Deploy dev version we just compiled +- type: replace + path: /releases/name=pcap + value: + name: pcap + version: ((release-version)) +` + +var opsfileAddSSHUser string = `--- +# Install OS conf so that we can SSH into VM to inspect configuration +- type: replace + path: /releases/- + value: + name: os-conf + version: latest + +# Add an SSH user +- type: replace + path: /instance_groups/name=pcap-api/jobs/- + value: + name: user_add + release: os-conf + properties: + users: + - name: ((ssh_user)) + public_key: ((ssh_key.public_key)) + sudo: true + +- type: replace + path: /instance_groups/name=pcap-agent/jobs/- + value: + name: user_add + release: os-conf + properties: + users: + - name: ((ssh_user)) + public_key: ((ssh_key.public_key)) + sudo: true + +# Generate an SSH key-pair +- type: replace + path: /variables?/- + value: + name: ssh_key + type: ssh +` + +var opsfileStartApache = ` +# Add apache web server to generate some traffic +- type: replace + path: /instance_groups/name=pcap-agent/jobs/- + value: + name: pre-start-script + release: os-conf + properties: + script: |- + #!/bin/bash + apt-get update && apt-get install apache2 -y && apache2ctl start +` + +// opsfiles that need to be set for all tests +var defaultOpsfiles = []string{opsfileChangeName, opsfileChangeVersion, opsfileAddSSHUser, opsfileStartApache} +var defaultSSHUser string = "ginkgo" + +// buildManifestVars returns a map of variables needed to deploy pcap. +func buildManifestVars(baseManifestVars baseManifestVars, customVars map[string]interface{}) map[string]interface{} { + vars := map[string]interface{}{ + "release-version": config.ReleaseVersion, + "director_ssl_ca": config.BoshDirectorCA, + "bosh_director_api": config.BoshDirectorAPI, + "director_ssl_cert": config.BoshDirectorCert, + "director_ssl_key": config.BoshDirectorKey, + "deployment-name": baseManifestVars.deploymentName, + "ssh_user": defaultSSHUser, + } + for k, v := range customVars { + vars[k] = v + } + + return vars +} + +// buildPcapInfo returns a pcapInfo object which contains the state of a currently deployed pcap release. +func buildPcapInfo(baseManifestVars baseManifestVars, varsStoreReader varsStoreReader) pcapInfo { + var creds struct { + SSHKey struct { + PrivateKey string `yaml:"private_key"` + PublicKey string `yaml:"public_key"` + PublicKeyFingerprint string `yaml:"public_key_fingerprint"` + } `yaml:"ssh_key"` + } + err := varsStoreReader(&creds) + Expect(err).NotTo(HaveOccurred()) + + Expect(creds.SSHKey.PrivateKey).NotTo(BeEmpty()) + Expect(creds.SSHKey.PublicKey).NotTo(BeEmpty()) + + By("Fetching the Pcap public IPs") + instances := boshInstances(baseManifestVars.deploymentName) + + var pcapAPIPublicIP, pcapAgentPublicIP string + for i, vm := range instances { + if strings.Contains(vm.Instance, "pcap-api") { + pcapAPIPublicIP = instances[i].ParseIPs()[0] + } + if strings.Contains(vm.Instance, "pcap-agent") { + pcapAgentPublicIP = instances[i].ParseIPs()[0] + } + + } + + Expect(pcapAPIPublicIP).ToNot(BeEmpty()) + Expect(pcapAgentPublicIP).ToNot(BeEmpty()) + + return pcapInfo{ + PcapAPIPublicIP: pcapAPIPublicIP, + PcapAgentPublicIP: pcapAgentPublicIP, + SSHPrivateKey: creds.SSHKey.PrivateKey, + SSHPublicKey: creds.SSHKey.PublicKey, + SSHPublicKeyFingerprint: creds.SSHKey.PublicKeyFingerprint, + SSHUser: defaultSSHUser, + } +} + +// deployPcap deploys the pcap release using baseManifestVars, customOpsfiles and customVars to customize the deployment. +// If expectSuccess is true, the test will fail if the exit code is non-zero. +func deployPcap(baseManifestVars baseManifestVars, customOpsfiles []string, customVars map[string]interface{}, expectSuccess bool) (pcapInfo, varsStoreReader) { + manifestVars := buildManifestVars(baseManifestVars, customVars) + opsfiles := append(defaultOpsfiles, customOpsfiles...) + cmd, varsStoreReader := deployBaseManifestCmd(baseManifestVars.deploymentName, opsfiles, manifestVars) + + dumpCmd(cmd) + session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter) + Expect(err).NotTo(HaveOccurred()) + + const timeout = 20 + if expectSuccess { + Eventually(session, timeout*time.Minute, time.Second).Should(gexec.Exit(0)) + } else { + Eventually(session, timeout*time.Minute, time.Second).Should(gexec.Exit()) + Expect(session.ExitCode()).NotTo(BeZero()) + } + + pcapInfo := buildPcapInfo(baseManifestVars, varsStoreReader) + + // Dump Pcap config to help debugging + dumpPcapConfig(pcapInfo) + + return pcapInfo, varsStoreReader +} + +// dumpCmd prints the command line of cmd to the ginkgo log. +func dumpCmd(cmd *exec.Cmd) { + writeLog("---------- Command to run ----------") + writeLog(cmd.String()) + writeLog("------------------------------------") +} + +// dumpPcapConfig prints the configuration of pcap-agent and pcap-api to the ginkgo log. +func dumpPcapConfig(pcapInfo pcapInfo) { + By("Checking /var/vcap/jobs/pcap-api/config/pcap-api.yml") + pcapAPIConfig, _, err := runOnRemote(pcapInfo.SSHUser, pcapInfo.PcapAPIPublicIP, pcapInfo.SSHPrivateKey, "cat /var/vcap/jobs/pcap-api/config/pcap-api.yml") + Expect(err).NotTo(HaveOccurred()) + writeLog("---------- PcapAPI Config ----------") + writeLog(pcapAPIConfig) + writeLog("------------------------------------") + + By("Checking /var/vcap/jobs/pcap-agent/config/pcap-agent.yml") + pcapAgentConfig, _, err := runOnRemote(pcapInfo.SSHUser, pcapInfo.PcapAgentPublicIP, pcapInfo.SSHPrivateKey, "cat /var/vcap/jobs/pcap-agent/config/pcap-agent.yml") + Expect(err).NotTo(HaveOccurred()) + writeLog("---------- PcapAPI Config ----------") + writeLog(pcapAgentConfig) + writeLog("------------------------------------") +} + +// deployBaseManifestCmd takes bosh deployment name, ops files and vars. +// Returns a cmd object and a callback to deserialise the bosh-generated vars store after cmd has executed.gofmt -w +func deployBaseManifestCmd(boshDeployment string, opsFilesContents []string, vars map[string]interface{}) (*exec.Cmd, varsStoreReader) { + By(fmt.Sprintf("Deploying pcap (deployment name: %s)", boshDeployment)) + args := []string{"deploy"} + + // ops files + for _, opsFileContents := range opsFilesContents { + opsFile, err := os.CreateTemp("", "pcap-tests-ops-file-*.yml") + Expect(err).NotTo(HaveOccurred()) + + writeLog(fmt.Sprintf("Writing ops file to %s\n", opsFile.Name())) + writeLog("------------------------------------") + writeLog(opsFileContents) + writeLog("------------------------------------") + + _, err = opsFile.WriteString(opsFileContents) + Expect(err).NotTo(HaveOccurred()) + err = opsFile.Close() + Expect(err).NotTo(HaveOccurred()) + + args = append(args, "--ops-file", opsFile.Name()) + } + + // vars file + if vars != nil { + varsFile, err := os.CreateTemp("", "pcap-tests-vars-file-*.json") + Expect(err).NotTo(HaveOccurred()) + + bytes, err := json.Marshal(vars) + Expect(err).NotTo(HaveOccurred()) + + writeLog(fmt.Sprintf("Writing vars file to %s\n", varsFile.Name())) + writeLog("------------------------------------") + writeLog(string(bytes)) + writeLog("------------------------------------") + + _, err = varsFile.Write(bytes) + Expect(err).NotTo(HaveOccurred()) + err = varsFile.Close() + Expect(err).NotTo(HaveOccurred()) + + args = append(args, "--vars-file", varsFile.Name()) + } + + // vars store + varsStore, err := os.CreateTemp("", "pcap-tests-vars-store-*.yml") + Expect(err).NotTo(HaveOccurred()) + + _, err = varsStore.WriteString("{}") + Expect(err).NotTo(HaveOccurred()) + err = varsStore.Close() + Expect(err).NotTo(HaveOccurred()) + + args = append(args, "--vars-store", varsStore.Name()) + args = append(args, config.BaseManifestPath) + + varsStoreReader := func(target interface{}) error { + varsStoreBytes, err := os.ReadFile(varsStore.Name()) + if err != nil { + return err + } + + return yaml.Unmarshal(varsStoreBytes, target) + } + + return config.boshCmd(boshDeployment, args...), varsStoreReader +} + +// boshInstance represents a BOSH instance VM of a BOSH deployment. +type boshInstance struct { + AgentID string `json:"agent_id"` + Az string `json:"az"` + Bootstrap string `json:"bootstrap"` + Deployment string `json:"deployment"` + DiskCids string `json:"disk_cids"` + Ignore string `json:"ignore"` + Index string `json:"index"` + Instance string `json:"instance"` + CommaSeparatedIPs string `json:"ips"` + ProcessState string `json:"process_state"` + State string `json:"state"` + VMCid string `json:"vm_cid"` + VMType string `json:"vm_type"` +} + +// ParseIPs returns all IPs in a comma-separated string. +func (instance boshInstance) ParseIPs() []string { + return strings.Split(instance.CommaSeparatedIPs, ",") +} + +// boshInstances fetches all VMs in a BOSH deployment and returns their details. +func boshInstances(boshDeployment string) []boshInstance { + writeLog("Fetching Bosh instances") + cmd := config.boshCmd(boshDeployment, "--json", "instances", "--details") + session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter) + Expect(err).NotTo(HaveOccurred()) + Eventually(session, time.Minute, time.Second).Should(gexec.Exit(0)) + + output := struct { + Tables []struct { + Rows []boshInstance `json:"Rows"` + } `json:"Tables"` + }{} + + err = json.Unmarshal(session.Out.Contents(), &output) + Expect(err).NotTo(HaveOccurred()) + + return output.Tables[0].Rows +} + +// deleteDeployment deletes a BOSH deployment. +func deleteDeployment(boshDeployment string) { + By(fmt.Sprintf("Deleting pcap deployment (deployment name: %s)", boshDeployment)) + cmd := config.boshCmd(boshDeployment, "delete-deployment") + session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter) + Expect(err).NotTo(HaveOccurred()) + const timeout = 10 + Eventually(session, timeout*time.Minute, time.Second).Should(gexec.Exit(0)) +} + +// login runs an interactive BOSH login using username and password. This will generate access_token and refresh_token +// in the bosh config. +func login(username, password string) { + By(fmt.Sprintf("Calling bosh login (user: %s)", username)) + cmd := config.boshInteractiveCmd("login") + + stdin, err := cmd.StdinPipe() + Expect(err).NotTo(HaveOccurred()) + + stdin.Write([]byte(fmt.Sprintf("%s\n", username))) + stdin.Write([]byte(fmt.Sprintf("%s\n", password))) + stdin.Close() + + session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter) + Expect(err).NotTo(HaveOccurred()) + const timeout = 10 + Eventually(session, timeout*time.Minute, time.Second).Should(gexec.Exit(0)) +} + +// writeLog writes s to the ginkgo log prefixed with the number of the current test process. +func writeLog(s string) { + ginkgoConfig, _ := GinkgoConfiguration() + for _, line := range strings.Split(s, "\n") { + fmt.Printf("node %d/%d: %s\n", ginkgoConfig.ParallelProcess, ginkgoConfig.ParallelTotal, line) + } +} + +// downloadFile retrieves the file from remotePath via SSH and stores it in localFile with permissions. info contains the remote's credentials and IP address. +// See copyFileFromRemote +func downloadFile(info pcapInfo, remotePath string, localFile *os.File, permissions os.FileMode) error { + return copyFileFromRemote(info.SSHUser, info.PcapAPIPublicIP, info.SSHPrivateKey, remotePath, localFile, permissions) +} diff --git a/acceptance-tests/config.go b/acceptance-tests/config.go new file mode 100644 index 00000000..51cd5ea7 --- /dev/null +++ b/acceptance-tests/config.go @@ -0,0 +1,141 @@ +package acceptance_tests + +import ( + "fmt" + "os" + "os/exec" +) + +var config Config + +type Config struct { + ReleaseRepoPath string `json:"releaseRepoPath"` + ReleaseVersion string `json:"releaseVersion"` + BoshDirectorAPI string `json:"boshDirectorAPI"` + BoshDirectorCert string `json:"boshDirectorCert"` + BoshDirectorKey string `json:"boshDirectorKey"` + BoshDirectorCA string `json:"boshDirectorCA"` + BoshClient string `json:"boshClient"` + BoshClientSecret string `json:"boshClientSecret"` + BoshEnvironment string `json:"boshEnvironment"` + BoshPath string `json:"boshPath"` + BaseManifestPath string `json:"baseManifestPath"` + HomePath string `json:"homePath"` +} + +func loadConfig() (Config, error) { + releaseRepoPath, err := getEnvOrFail("REPO_ROOT") + if err != nil { + return Config{}, err + } + + releaseVersion, err := getEnvOrFail("RELEASE_VERSION") + if err != nil { + return Config{}, err + } + + boshDirectorCA, err := getEnvOrFail("BOSH_DIRECTOR_CA") + if err != nil { + return Config{}, err + } + + boshDirectorCert, err := getEnvOrFail("BOSH_DIRECTOR_CERT") + if err != nil { + return Config{}, err + } + + boshDirectorKey, err := getEnvOrFail("BOSH_DIRECTOR_KEY") + if err != nil { + return Config{}, err + } + + boshClient, err := getEnvOrFail("BOSH_CLIENT") + if err != nil { + return Config{}, err + } + + boshClientSecret, err := getEnvOrFail("BOSH_CLIENT_SECRET") + if err != nil { + return Config{}, err + } + + boshDirectorAPI, err := getEnvOrFail("BOSH_DIRECTOR_API") + if err != nil { + return Config{}, err + } + + boshEnvironment, err := getEnvOrFail("BOSH_ENVIRONMENT") + if err != nil { + return Config{}, err + } + + boshPath, err := getEnvOrFail("BOSH_PATH") + if err != nil { + return Config{}, err + } + + baseManifestPath, err := getEnvOrFail("BASE_MANIFEST_PATH") + if err != nil { + return Config{}, err + } + + // BOSH commands require HOME is set + homePath, err := getEnvOrFail("HOME") + if err != nil { + return Config{}, err + } + + return Config{ + ReleaseRepoPath: releaseRepoPath, + ReleaseVersion: releaseVersion, + BoshDirectorAPI: boshDirectorAPI, + BoshDirectorCert: boshDirectorCert, + BoshDirectorKey: boshDirectorKey, + BoshDirectorCA: boshDirectorCA, + BoshClient: boshClient, + BoshClientSecret: boshClientSecret, + BoshEnvironment: boshEnvironment, + BoshPath: boshPath, + BaseManifestPath: baseManifestPath, + HomePath: homePath, + }, nil +} + +func (config *Config) boshCmd(boshDeployment string, args ...string) *exec.Cmd { + cmd := exec.Command(config.BoshPath, append([]string{"--tty", "--no-color"}, args...)...) + cmd.Env = []string{ + fmt.Sprintf("BOSH_DIRECTOR_IP=%s", config.BoshDirectorAPI), + fmt.Sprintf("BOSH_DIRECTOR_CA=%s", config.BoshDirectorCA), + fmt.Sprintf("BOSH_DIRECTOR_CERT=%s", config.BoshDirectorCert), + fmt.Sprintf("BOSH_CLIENT=%s", config.BoshClient), + fmt.Sprintf("BOSH_CLIENT_SECRET=%s", config.BoshClientSecret), + fmt.Sprintf("BOSH_ENVIRONMENT=%s", config.BoshEnvironment), + fmt.Sprintf("HOME=%s", config.HomePath), + fmt.Sprintf("BOSH_DEPLOYMENT=%s", boshDeployment), + "BOSH_NON_INTERACTIVE=true", + } + + return cmd +} + +func (config *Config) boshInteractiveCmd(args ...string) *exec.Cmd { + cmd := exec.Command(config.BoshPath, append([]string{"--tty", "--no-color"}, args...)...) + cmd.Env = []string{ + fmt.Sprintf("BOSH_DIRECTOR_IP=%s", config.BoshDirectorAPI), + fmt.Sprintf("BOSH_DIRECTOR_CA=%s", config.BoshDirectorCA), + fmt.Sprintf("BOSH_DIRECTOR_CERT=%s", config.BoshDirectorCert), + fmt.Sprintf("BOSH_ENVIRONMENT=%s", config.BoshEnvironment), + fmt.Sprintf("HOME=%s", config.HomePath), + } + + return cmd +} + +func getEnvOrFail(key string) (string, error) { + value := os.Getenv(key) + if value == "" { + return "", fmt.Errorf("required env var %s not found", key) + } + + return value, nil +} diff --git a/acceptance-tests/go.mod b/acceptance-tests/go.mod new file mode 100644 index 00000000..ae1f7764 --- /dev/null +++ b/acceptance-tests/go.mod @@ -0,0 +1,22 @@ +module acceptance_tests + +go 1.20 + +require ( + github.com/bramvdbogaerde/go-scp v1.2.1 + github.com/onsi/ginkgo/v2 v2.9.7 + github.com/onsi/gomega v1.27.7 + golang.org/x/crypto v0.10.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/go-logr/logr v1.2.4 // indirect + github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect + github.com/google/go-cmp v0.5.9 // indirect + github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 // indirect + golang.org/x/net v0.10.0 // indirect + golang.org/x/sys v0.9.0 // indirect + golang.org/x/text v0.10.0 // indirect + golang.org/x/tools v0.9.1 // indirect +) diff --git a/acceptance-tests/go.sum b/acceptance-tests/go.sum new file mode 100644 index 00000000..48daf415 --- /dev/null +++ b/acceptance-tests/go.sum @@ -0,0 +1,53 @@ +github.com/bramvdbogaerde/go-scp v1.2.1 h1:BKTqrqXiQYovrDlfuVFaEGz0r4Ou6EED8L7jCXw6Buw= +github.com/bramvdbogaerde/go-scp v1.2.1/go.mod h1:s4ZldBoRAOgUg8IrRP2Urmq5qqd2yPXQTPshACY8vQ0= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= +github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 h1:yAJXTCF9TqKcTiHJAE8dj7HMvPfh66eeA2JYW7eFpSE= +github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/onsi/ginkgo/v2 v2.9.7 h1:06xGQy5www2oN160RtEZoTvnP2sPhEfePYmCDc2szss= +github.com/onsi/ginkgo/v2 v2.9.7/go.mod h1:cxrmXWykAwTwhQsJOPfdIDiJ+l2RYq7U8hFU+M/1uw0= +github.com/onsi/gomega v1.27.7 h1:fVih9JD6ogIiHUN6ePK7HJidyEDpWGVB5mzM7cWNXoU= +github.com/onsi/gomega v1.27.7/go.mod h1:1p8OOlwo2iUUDsHnOrjE5UKYJ+e3W8eQ3qSlRahPmr4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= +golang.org/x/crypto v0.10.0 h1:LKqV2xt9+kDzSTfOhx4FrkEBcMrAgHSYgzywV9zcGmM= +golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I= +golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210525143221-35b2ab0089ea/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s= +golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.9.0 h1:GRRCnKYhdQrD8kfRAdQ6Zcw1P0OcELxGLKJvtjVMZ28= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.10.0 h1:UpjohKhiEgNc0CSauXmwYftY1+LlaC75SJwh0SgCX58= +golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.9.1 h1:8WMNJAz3zrtPmnYC7ISf5dEn3MT0gY7jBJfw27yrrLo= +golang.org/x/tools v0.9.1/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc= +google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/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= diff --git a/acceptance-tests/remote.go b/acceptance-tests/remote.go new file mode 100644 index 00000000..f08f9130 --- /dev/null +++ b/acceptance-tests/remote.go @@ -0,0 +1,243 @@ +package acceptance_tests + +import ( + "bytes" + "context" + "fmt" + "io" + "log" + "net" + "os" + "time" + + "github.com/bramvdbogaerde/go-scp" + "golang.org/x/crypto/ssh" +) + +// runOnRemote runs cmd on a remote machine using SSH. +func runOnRemote(user string, addr string, privateKey string, cmd string) (string, string, error) { + client, err := buildSSHClient(user, addr, privateKey) + if err != nil { + return "", "", err + } + + session, err := client.NewSession() + if err != nil { + return "", "", err + } + defer session.Close() + + var stdOutBuffer bytes.Buffer + var stdErrBuffer bytes.Buffer + session.Stdout = &stdOutBuffer + session.Stderr = &stdErrBuffer + err = session.Run(cmd) + return stdOutBuffer.String(), stdErrBuffer.String(), err +} + +// copyFileToRemote copies a local file from fileReader to remotePath at the host addr with permissions via SSH. +func copyFileToRemote(user string, addr string, privateKey string, remotePath string, fileReader io.Reader, permissions string) error { + clientConfig, err := buildSSHClientConfig(user, privateKey) + if err != nil { + return err + } + + scpClient := scp.NewClient(fmt.Sprintf("%s:22", addr), clientConfig) + if err := scpClient.Connect(); err != nil { + return err + } + + return scpClient.CopyFile(context.Background(), fileReader, remotePath, permissions) +} + +// copyFileFromRemote copies a remote file from remotePath on host addr to localFile with permissions via SSH. localFile +// must exist and be writable. +func copyFileFromRemote(user string, addr string, privateKey string, remotePath string, localFile *os.File, permissions os.FileMode) error { + clientConfig, err := buildSSHClientConfig(user, privateKey) + if err != nil { + return err + } + + scpClient := scp.NewClient(fmt.Sprintf("%s:22", addr), clientConfig) + if err := scpClient.Connect(); err != nil { + return err + } + + if err != nil { + return err + } + + err = localFile.Chmod(permissions) + + if err != nil { + return err + } + + err = scpClient.CopyFromRemote(context.Background(), localFile, remotePath) + + if err != nil { + return err + } + + return localFile.Sync() +} + +// startSSHPortForwarder forwards a TCP connection from a given port on the local machine to a given port on the remote machine +// Starts in background, cancel via context +func startSSHPortForwarder(user string, addr string, privateKey string, localPort, remotePort int, ctx context.Context) error { + remoteConn, err := buildSSHClient(user, addr, privateKey) + if err != nil { + return err + } + + writeLog(fmt.Sprintf("Listening on 127.0.0.1:%d on local machine\n", remotePort)) + localListener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", localPort)) + if err != nil { + return err + } + + go func() { + for { + localClient, err := localListener.Accept() + if err != nil { + if err == io.EOF { + writeLog("Local connection closed") + } else { + writeLog(fmt.Sprintf("Error accepting connection on local listener: %s\n", err.Error())) + } + + return + } + + remoteConn, err := remoteConn.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", remotePort)) + if err != nil { + writeLog(fmt.Sprintf("Error dialing local port %d: %s\n", remotePort, err.Error())) + return + } + + // From https://sosedoff.com/2015/05/25/ssh-port-forwarding-with-go.html + copyConnections(localClient, remoteConn) + } + }() + + go func() { + <-ctx.Done() + writeLog("Closing local listener") + localListener.Close() + }() + + return nil +} + +// startReverseSSHPortForwarder forwards a TCP connection from a given port on the remote machine to a given port on the local machine +// Starts in background, cancel via context +func startReverseSSHPortForwarder(user string, addr string, privateKey string, remotePort, localPort int, ctx context.Context) error { + remoteConn, err := buildSSHClient(user, addr, privateKey) + if err != nil { + return err + } + + writeLog(fmt.Sprintf("Listening on 127.0.0.1:%d on remote machine\n", remotePort)) + remoteListener, err := remoteConn.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", remotePort)) + if err != nil { + return err + } + + go func() { + for { + remoteClient, err := remoteListener.Accept() + if err != nil { + if err == io.EOF { + writeLog("Remote connection closed") + } else { + writeLog(fmt.Sprintf("Error accepting connection on remote listener: %s\n", err.Error())) + } + + return + } + + localConn, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", localPort)) + if err != nil { + writeLog(fmt.Sprintf("Error dialing local port %d: %s\n", localPort, err.Error())) + return + } + + // From https://sosedoff.com/2015/05/25/ssh-port-forwarding-with-go.html + copyConnections(remoteClient, localConn) + } + }() + + go func() { + <-ctx.Done() + writeLog("Closing remote listener") + remoteListener.Close() + }() + + return nil +} + +// copyConnections copies data between two connections. The function blocks until both client and remote are done. +func copyConnections(client net.Conn, remote net.Conn) { + chDone := make(chan bool) + + // Start remote -> local data transfer + go func() { + _, err := io.Copy(client, remote) // blocks until EOF + if err != nil { + log.Println("error while copy remote->local:", err) + } + chDone <- true + }() + + // Start local -> remote data transfer + go func() { + _, err := io.Copy(remote, client) // blocks until EOF + if err != nil { + log.Println("error while copy local->remote:", err) + } + chDone <- true + }() + + <-chDone +} + +// buildSSHClientConfig creates a new SSH config to use with the ssh package. +func buildSSHClientConfig(user string, privateKey string) (*ssh.ClientConfig, error) { + key, err := ssh.ParsePrivateKey([]byte(privateKey)) + if err != nil { + return nil, err + } + + return &ssh.ClientConfig{ + User: user, + Timeout: 10 * time.Second, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Auth: []ssh.AuthMethod{ + ssh.PublicKeys(key), + }, + }, nil +} + +// buildSSHClient creates a new SSH client that is connected to the host addr. +func buildSSHClient(user string, addr string, privateKey string) (*ssh.Client, error) { + config, err := buildSSHClientConfig(user, privateKey) + if err != nil { + return nil, err + } + + writeLog(fmt.Sprintf("Connecting to %s:%d as user %s using private key\n", addr, 22, user)) + return ssh.Dial("tcp", net.JoinHostPort(addr, "22"), config) +} + +// checkListening checks if a tcp port is open at addr. +func checkListening(addr string) error { + conn, err := net.DialTimeout("tcp", addr, time.Second) + if err != nil { + return err + } + if conn != nil { + defer conn.Close() + } + + return nil +} diff --git a/acceptance-tests/run-local.sh b/acceptance-tests/run-local.sh new file mode 100755 index 00000000..477e6330 --- /dev/null +++ b/acceptance-tests/run-local.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")/" && pwd)" +REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +if ! [[ $(git status --porcelain=v1 2>/dev/null | wc -l) -eq 0 ]]; then + echo "You have changes in your Git repository. Commit or clean (e.g. git clean -f) before running." + echo "The build will fail otherwise." + echo "Git Status:" + git status + exit 1 +fi + +FOCUS="$1" + +docker_mac_check_cgroupsv1() { + # Force cgroups v1 on Docker for Mac + # inspired by https://github.com/docker/for-mac/issues/6073#issuecomment-1018793677 + + SETTINGS=~/Library/Group\ Containers/group.com.docker/settings.json + + cgroupsV1Enabled=$(jq '.deprecatedCgroupv1' "$SETTINGS") + if [ "$cgroupsV1Enabled" != "true" ]; then + echo "deprecatedCgroupv1 should be enabled in $SETTINGS. Otherwise the acceptance tests will not run on Docker for Mac." + echo "Check in the README.md for a convenient script to set deprecatedCgroupv1 and restart Docker." + exit 1 + fi +} + +if [ "$(uname)" == "Darwin" ]; then + docker_mac_check_cgroupsv1 +fi + +# Build acceptance test image +pushd "$SCRIPT_DIR/../ci" || exit 1 + docker build -t pcap-release-testflight . +popd || exit 1 + +# Run acceptance tests +if [ -n "$FOCUS" ]; then + docker run --privileged -v "$REPO_DIR":/repo -e REPO_ROOT=/repo -e FOCUS="$FOCUS" pcap-release-testflight bash -c "cd /repo/ci/scripts && ./acceptance-tests ; sleep infinity" +else + docker run --rm --privileged -v "$REPO_DIR":/repo -e REPO_ROOT=/repo pcap-release-testflight bash -c "cd /repo/ci/scripts && ./acceptance-tests" +fi diff --git a/acceptance-tests/run-shell.sh b/acceptance-tests/run-shell.sh new file mode 100755 index 00000000..5936665d --- /dev/null +++ b/acceptance-tests/run-shell.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")/" && pwd)" +REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Build acceptance test image +pushd "$SCRIPT_DIR/../ci" || exit 1 + docker build -t pcap-release-testflight . +popd || exit 1 + +# Run local shell +docker run -it --rm --privileged -v "$REPO_DIR":/repo -e REPO_ROOT=/repo pcap-release-testflight bash -c "cd /repo/ci/scripts && ./shell" diff --git a/ci/Dockerfile b/ci/Dockerfile index e8bf0c95..ac48acf5 100644 --- a/ci/Dockerfile +++ b/ci/Dockerfile @@ -1,5 +1,5 @@ # Dockerfile for image cf-routing.common.repositories.cloud.sap/pcap-release-testflight -FROM bosh/integration:main +FROM bosh/docker-cpi:main ARG GINKGO_VERSION=latest ARG GOLANGCILINT_VERSION=latest @@ -8,6 +8,9 @@ RUN apt-get update && apt-get install -y libpcap-dev python3-pip && rm -rf /var/ RUN curl -fsSL https://deb.nodesource.com/setup_current.x | sudo -E bash - && \ apt-get install -y nodejs && rm -rf /var/lib/apt/lists/* +# Set bosh env at login +RUN echo "source /tmp/local-bosh/director/env" >> /root/.bashrc + RUN npm install -g semantic-release && \ npm install -g @semantic-release/exec @@ -18,4 +21,5 @@ RUN /usr/bin/python3 -m pip install -r /requirements.txt ENV GOPATH=/go PATH=${PATH}:/go/bin RUN go install "github.com/onsi/ginkgo/v2/ginkgo@${GINKGO_VERSION}" \ - && go install "github.com/golangci/golangci-lint/cmd/golangci-lint@${GOLANGCILINT_VERSION}" + && go install "github.com/golangci/golangci-lint/cmd/golangci-lint@${GOLANGCILINT_VERSION}" \ + && go install "github.com/geofffranks/spruce/cmd/spruce@latest" diff --git a/ci/pipeline.yml b/ci/pipeline.yml index cc8a673e..dc33d99e 100644 --- a/ci/pipeline.yml +++ b/ci/pipeline.yml @@ -6,6 +6,8 @@ groups: - unit-tests-pr - shipit - rc + - acceptance-tests + - acceptance-tests-pr - autobump-dependencies jobs: @@ -129,6 +131,85 @@ jobs: status: failure context: unit-tests + - name: acceptance-tests + public: true + serial: true + plan: + - do: + - in_parallel: + - { get: git, trigger: true, passed: [ unit-tests ] } + - { get: stemcell } + - task: acceptance-tests + privileged: true + config: + platform: linux + image_resource: + type: docker-image + source: + repository: cf-routing.common.repositories.cloud.sap/pcap-release-testflight + tag: latest + username: ((docker.username)) + password: ((docker.password)) + inputs: + - { name: git } + - { name: stemcell } + run: + path: ./git/ci/scripts/acceptance-tests + args: [ ] + params: + REPO_ROOT: git + on_failure: + put: notify + params: + channel: "#pcap-release" + username: ci-bot + icon_url: "((slack.icon))" + text: "((slack.fail_url)) pcap-release: acceptance-tests job failed" + + - name: acceptance-tests-pr + public: true + serial: true + plan: + - do: + - { get: git-pull-requests, trigger: true, version: every, passed: [ unit-tests-pr ] } + - { get: stemcell } + - put: git-pull-requests + params: + path: git-pull-requests + status: pending + context: acceptance-tests + - task: acceptance-tests + privileged: true + config: + platform: linux + image_resource: + type: docker-image + source: + repository: cf-routing.common.repositories.cloud.sap/pcap-release-testflight + tag: latest + username: ((docker.username)) + password: ((docker.password)) + inputs: + - { name: git-pull-requests } + - { name: stemcell } + run: + path: ./git-pull-requests/ci/scripts/acceptance-tests + args: [ ] + params: + REPO_ROOT: git-pull-requests + on_success: + put: git-pull-requests + params: + path: git-pull-requests + status: success + context: acceptance-tests + on_failure: + put: git-pull-requests + params: + path: git-pull-requests + status: failure + context: acceptance-tests + - name: shipit public: true serial: true @@ -321,6 +402,11 @@ resources: json_key: ((gcp.service_key)) regexp: pcap-v[0-9a-z\.+-]+.tgz + - name: stemcell + type: bosh-io-stemcell + source: + name: bosh-warden-boshlite-ubuntu-jammy-go_agent + - name: daily type: time source: diff --git a/ci/scripts/acceptance-tests b/ci/scripts/acceptance-tests new file mode 100755 index 00000000..bb20ca30 --- /dev/null +++ b/ci/scripts/acceptance-tests @@ -0,0 +1,63 @@ +#!/bin/bash + +set -e + +stemcell_jammy_path=$PWD/stemcell/*.tgz + +if [ -n "$FOCUS" ]; then + echo "------------------------------------------------------------------" + echo "FOCUS is set. Will only run tests matching '$FOCUS'" + echo "Docker won't be stopped afterwards, so you can debug the test." + echo "------------------------------------------------------------------" + ADDITIONAL_ARGS=("--focus" "$FOCUS") +fi + +cd ${REPO_ROOT:?required} +echo "----- Pulling in any git submodules..." +git submodule update --init --recursive --force + +echo "----- Starting BOSH" + +./ci/scripts/start-bosh.sh + +function stop_docker() { + echo "----- stopping docker" + service docker stop +} + +if [ -z "$FOCUS" ]; then + trap stop_docker EXIT +fi + +source /tmp/local-bosh/director/env + +echo "----- Creating candidate BOSH release..." +bosh -n reset-release # in case dev_releases/ is in repo accidentally + +bosh create-release +bosh upload-release --rebase +release_final_version=$(spruce json dev_releases/*/index.yml | jq -r ".builds[].version" | sed -e "s%+.*%%") +export RELEASE_VERSION="${release_final_version}.latest" +echo "----- Created ${RELEASE_VERSION}" + +echo "----- Uploading Jammy stemcell" +bosh -n upload-stemcell $stemcell_jammy_path + +echo "----- Uploading os-conf (used for tests only)" +bosh -n upload-release --sha1 386293038ae3d00813eaa475b4acf63f8da226ef \ + https://bosh.io/d/github.com/cloudfoundry/os-conf-release?v=22.1.2 + +export BOSH_PATH=$(which bosh) +export BASE_MANIFEST_PATH="$PWD/manifests/pcap-acceptance-tests.yml" + +cd "acceptance-tests" + +echo "----- Installing dependencies" +go mod download + +echo "----- Running tests" + +export PATH=$PATH:$GOPATH/bin + +ginkgo version +ginkgo -v -p -r --trace --show-node-events --randomize-all --flake-attempts 5 "${ADDITIONAL_ARGS[@]}" diff --git a/ci/scripts/lint b/ci/scripts/lint index 2bf61d9c..d6280218 100755 --- a/ci/scripts/lint +++ b/ci/scripts/lint @@ -7,8 +7,14 @@ echo "> Running 'bundle exec rake lint'" bundle install bundle exec rake lint -echo "> Running 'go vet'" +echo "> Running 'go vet' and linter checks for src" pushd src/pcap go vet ./... golangci-lint run popd + +echo "> Running 'go vet' and linter checks for acceptance-tests" +pushd acceptance-tests + go vet ./... + golangci-lint run +popd diff --git a/ci/scripts/shell b/ci/scripts/shell new file mode 100755 index 00000000..0e4d6e79 --- /dev/null +++ b/ci/scripts/shell @@ -0,0 +1,48 @@ +#!/bin/bash + +# This script works like the "acceptance-tests" script, except it does not launch the test suite. +# Instead, it only deploys BOSH and creates a dev release and then leaves an open shell for the developer to manually +# run any test or deployment. +# To be executed by acceptance-tests/run-shell.sh. + +set -eu + +stemcell_path=$PWD/stemcell/*.tgz + +cd ${REPO_ROOT:?required} +echo "----- Pulling in any git submodules..." +git submodule update --init --recursive --force + +echo "----- Starting BOSH" + +./ci/scripts/start-bosh.sh + +function stop_docker() { + echo "----- stopping docker" + service docker stop +} + +trap stop_docker EXIT + +source /tmp/local-bosh/director/env + +echo "----- Creating candidate BOSH release..." +bosh -n reset-release # in case dev_releases/ is in repo accidentally + +bosh create-release +bosh upload-release --rebase +release_final_version=$(spruce json dev_releases/*/index.yml | jq -r ".builds[].version" | sed -e "s%+.*%%") +export RELEASE_VERSION="${release_final_version}.latest" +echo "----- Created ${RELEASE_VERSION}" + +echo "----- Uploading stemcell" +bosh -n upload-stemcell $stemcell_path + +echo "----- Uploading os-conf (used for tests only)" +bosh -n upload-release --sha1 386293038ae3d00813eaa475b4acf63f8da226ef \ + https://bosh.io/d/github.com/cloudfoundry/os-conf-release?v=22.1.2 + +export BOSH_PATH=$(which bosh) +export BASE_MANIFEST_PATH="$PWD/manifests/pcap-acceptance-tests.yml" + +bash diff --git a/ci/scripts/start-bosh.sh b/ci/scripts/start-bosh.sh new file mode 100755 index 00000000..d385b453 --- /dev/null +++ b/ci/scripts/start-bosh.sh @@ -0,0 +1,230 @@ +#!/usr/bin/env bash + +set -eo pipefail + +function generate_certs() { + local certs_dir + certs_dir="${1}" + + pushd "${certs_dir}" + + jq -ner --arg "ip" "${OUTER_CONTAINER_IP}" '{ + "variables": [ + { + "name": "docker_ca", + "type": "certificate", + "options": { + "is_ca": true, + "common_name": "ca" + } + }, + { + "name": "docker_tls", + "type": "certificate", + "options": { + "extended_key_usage": [ + "server_auth" + ], + "common_name": $ip, + "alternative_names": [ $ip ], + "ca": "docker_ca" + } + }, + { + "name": "client_docker_tls", + "type": "certificate", + "options": { + "extended_key_usage": [ + "client_auth" + ], + "common_name": $ip, + "alternative_names": [ $ip ], + "ca": "docker_ca" + } + } + ] + }' > ./bosh-vars.yml + + bosh int ./bosh-vars.yml --vars-store=./certs.yml + bosh int ./certs.yml --path=/docker_ca/ca > ./ca.pem + bosh int ./certs.yml --path=/docker_tls/certificate > ./server-cert.pem + bosh int ./certs.yml --path=/docker_tls/private_key > ./server-key.pem + bosh int ./certs.yml --path=/client_docker_tls/certificate > ./cert.pem + bosh int ./certs.yml --path=/client_docker_tls/private_key > ./key.pem + # generate certs in json format + # + ruby -e 'puts File.read("./ca.pem").split("\n").join("\\n")' > "$certs_dir/ca_json_safe.pem" + ruby -e 'puts File.read("./cert.pem").split("\n").join("\\n")' > "$certs_dir/client_certificate_json_safe.pem" + ruby -e 'puts File.read("./key.pem").split("\n").join("\\n")' > "$certs_dir/client_private_key_json_safe.pem" + popd +} + +function sanitize_cgroups() { + mkdir -p /sys/fs/cgroup + mountpoint -q /sys/fs/cgroup || \ + mount -t tmpfs -o uid=0,gid=0,mode=0755 cgroup /sys/fs/cgroup + + mount -o remount,rw /sys/fs/cgroup + + sed -e 1d /proc/cgroups | while read sys hierarchy num enabled; do + if [ "$enabled" != "1" ]; then + # subsystem disabled; skip + continue + fi + + grouping="$(cat /proc/self/cgroup | cut -d: -f2 | grep "\\<$sys\\>")" + if [ -z "$grouping" ]; then + # subsystem not mounted anywhere; mount it on its own + grouping="$sys" + fi + + mountpoint="/sys/fs/cgroup/$grouping" + + mkdir -p "$mountpoint" + + # clear out existing mount to make sure new one is read-write + if mountpoint -q "$mountpoint"; then + umount "$mountpoint" + fi + + mount -n -t cgroup -o "$grouping" cgroup "$mountpoint" + + if [ "$grouping" != "$sys" ]; then + if [ -L "/sys/fs/cgroup/$sys" ]; then + rm "/sys/fs/cgroup/$sys" + fi + + ln -s "$mountpoint" "/sys/fs/cgroup/$sys" + fi + done +} + +function stop_docker() { + echo "ERROR: stopping docker" + service docker stop +} + +function start_docker() { + generate_certs "$1" + local mtu + mkdir -p /var/log + mkdir -p /var/run + + sanitize_cgroups + + # ensure systemd cgroup is present + mkdir -p /sys/fs/cgroup/systemd + if ! mountpoint -q /sys/fs/cgroup/systemd ; then + mount -t cgroup -o none,name=systemd cgroup /sys/fs/cgroup/systemd + fi + + # check for /proc/sys being mounted readonly, as systemd does + if grep '/proc/sys\s\+\w\+\s\+ro,' /proc/mounts >/dev/null; then + mount -o remount,rw /proc/sys + fi + + mtu=$(cat /sys/class/net/$(ip route get 8.8.8.8|awk '{ print $5 }')/mtu) + + [[ ! -d /etc/docker ]] && mkdir /etc/docker + cat < /etc/docker/daemon.json +{ + "hosts": ["${DOCKER_HOST}","unix:///var/run/docker.sock"], + "tls": true, + "tlscert": "${certs_dir}/server-cert.pem", + "tlskey": "${certs_dir}/server-key.pem", + "tlscacert": "${certs_dir}/ca.pem", + "mtu": ${mtu}, + "data-root": "/scratch/docker", + "tlsverify": true +} +EOF + + trap stop_docker ERR + service docker start + + export DOCKER_TLS_VERIFY=1 + export DOCKER_CERT_PATH=$1 + + rc=1 + for i in $(seq 1 100); do + echo waiting for docker to come up... + sleep 1 + set +e + docker info + rc=$? + set -e + if [ "$rc" -eq "0" ]; then + break + fi + done + + if [ "$rc" -ne "0" ]; then + exit 1 + fi + + echo $certs_dir +} + +function main() { + export OUTER_CONTAINER_IP=$(ruby -rsocket -e 'puts Socket.ip_address_list + .reject { |addr| !addr.ip? || addr.ipv4_loopback? || addr.ipv6? } + .map { |addr| addr.ip_address }.first') + + export DOCKER_HOST="tcp://${OUTER_CONTAINER_IP}:4243" + + local certs_dir + certs_dir=$(mktemp -d) + start_docker "${certs_dir}" + + local local_bosh_dir + local_bosh_dir="/tmp/local-bosh/director" + + if ! docker network ls | grep director_network; then + docker network create -d bridge --subnet=10.245.0.0/16 director_network + fi + + pushd ${BOSH_DEPLOYMENT_PATH:-/usr/local/bosh-deployment} > /dev/null + export BOSH_DIRECTOR_IP="10.245.0.3" + export BOSH_ENVIRONMENT="docker-director" + + mkdir -p ${local_bosh_dir} + + command bosh int bosh.yml \ + -o docker/cpi.yml \ + -o jumpbox-user.yml \ + -o uaa.yml \ + -v director_name=docker \ + -v internal_cidr=10.245.0.0/16 \ + -v internal_gw=10.245.0.1 \ + -v internal_ip="${BOSH_DIRECTOR_IP}" \ + -v docker_host="${DOCKER_HOST}" \ + -v network=director_network \ + -v docker_tls="{\"ca\": \"$(cat ${certs_dir}/ca_json_safe.pem)\",\"certificate\": \"$(cat ${certs_dir}/client_certificate_json_safe.pem)\",\"private_key\": \"$(cat ${certs_dir}/client_private_key_json_safe.pem)\"}" \ + ${@} > "${local_bosh_dir}/bosh-director.yml" + + command bosh create-env "${local_bosh_dir}/bosh-director.yml" \ + --vars-store="${local_bosh_dir}/creds.yml" \ + --state="${local_bosh_dir}/state.json" + + bosh int "${local_bosh_dir}/creds.yml" --path /director_ssl/ca > "${local_bosh_dir}/director_ssl_ca.crt" + bosh -e "${BOSH_DIRECTOR_IP}" --ca-cert "${local_bosh_dir}/director_ssl_ca.crt" alias-env "${BOSH_ENVIRONMENT}" + + cat < "${local_bosh_dir}/env" + export BOSH_ENVIRONMENT="${BOSH_ENVIRONMENT}" + export BOSH_CLIENT=admin + export BOSH_CLIENT_SECRET="$(bosh int ${local_bosh_dir}/creds.yml --path /admin_password)" + export BOSH_DIRECTOR_CA="$(bosh int ${local_bosh_dir}/creds.yml --path /director_ssl/ca)" + export BOSH_DIRECTOR_CERT="$(bosh int ${local_bosh_dir}/creds.yml --path /director_ssl/certificate)" + export BOSH_DIRECTOR_KEY="$(bosh int "${local_bosh_dir}/creds.yml" --path /director_ssl/private_key)" + export BOSH_DIRECTOR_IP="${BOSH_DIRECTOR_IP}" + export BOSH_DIRECTOR_API="https://${BOSH_DIRECTOR_IP}:25555/" + +EOF + source "${local_bosh_dir}/env" + + bosh -n update-cloud-config docker/cloud-config.yml -v network=director_network + + popd > /dev/null +} + +main $@ diff --git a/ci/scripts/stemcell/.gitkeep b/ci/scripts/stemcell/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/manifests/pcap-acceptance-tests.yml b/manifests/pcap-acceptance-tests.yml new file mode 100644 index 00000000..6eca2e5b --- /dev/null +++ b/manifests/pcap-acceptance-tests.yml @@ -0,0 +1,105 @@ +name: pcap +instance_groups: + - name: pcap-api + azs: + - z1 + instances: 1 + vm_type: default + stemcell: default + networks: [{name: default}] + jobs: + - name: pcap-api + release: pcap + properties: + pcap-api: + log_level: debug + buffer: + size: 1000 + upper_limit: 995 + lower_limit: 900 + concurrent_captures: 5 + listen: + port: 8080 + tls: + enabled: false + bosh: + director_url: ((bosh_director_api)) + token_scope: bosh.admin + agent_port: 9494 + mtls: + common_name: bosh.service.cf.internal + skip_verify: false + certificate: ((director_ssl_cert)) + private_key: ((director_ssl_key)) + ca: ((director_ssl_ca)) + agents_mtls: + common_name: pcap-agent.service.cf.internal + skip_verify: false + certificate: ((pcap_api_mtls.certificate))((pcap_api_mtls.ca)) + private_key: ((pcap_api_mtls.private_key)) + ca: ((pcap_api_mtls.ca)) + - name: pcap-agent + azs: + - z1 + instances: 1 + vm_type: default + stemcell: default + networks: [{name: default}] + jobs: + - name: pcap-agent + release: pcap + properties: + pcap-agent: + id: pcap-agent/123 + log_level: info + buffer: + size: 100 + upper_limit: 95 + lower_limit: 90 + listen: + port: 9494 + tls: + certificate: ((pcap_agent_tls.certificate)) + private_key: ((pcap_agent_tls.private_key)) + ca: ((pcap_agent_tls.ca)) + +update: + canaries: 1 + max_in_flight: 1 + canary_watch_time: 1000-30000 + update_watch_time: 1000-30000 + serial: false +stemcells: + - alias: default + os: ubuntu-jammy + version: latest +releases: + - name: pcap + version: "1.0.0" + url: "https://github.com/cloudfoundry/pcap-release/releases/download/v1.0.0/pcap-v1.0.0.tgz" + sha1: "3765c8d7e850d52ba1d6806734369ea0b07d1762" +variables: + - name: pcap_ca + type: certificate + options: + common_name: pcap_ca + is_ca: true + - name: pcap_api_mtls + type: certificate + options: + ca: pcap_ca + common_name: pcap_api_mtls + alternative_names: + - pcap-api.service.cf.internal + extended_key_usage: + - client_auth + - server_auth + - name: pcap_agent_tls + options: + alternative_names: + - pcap-agent.service.cf.internal + ca: pcap_ca + common_name: pcap_agent_tls + extended_key_usage: + - server_auth + type: certificate diff --git a/packages/pcap-agent/packaging b/packages/pcap-agent/packaging index 82862ff7..396e0f8f 100644 --- a/packages/pcap-agent/packaging +++ b/packages/pcap-agent/packaging @@ -20,7 +20,7 @@ export GOCACHE=$BOSH_COMPILE_TARGET/gocache echo "Build libpcap" LIBPCAP_VERSION=1.10.4 # https://www.tcpdump.org/release/libpcap-1.10.4.tar.gz mkdir "${BOSH_COMPILE_TARGET}/libpcap" -tar xzf libpcap-${LIBPCAP_VERSION}.tgz --strip-components 1 -C "${BOSH_COMPILE_TARGET}/libpcap" +tar xzf libpcap-${LIBPCAP_VERSION}.tar.gz --strip-components 1 -C "${BOSH_COMPILE_TARGET}/libpcap" pushd "${BOSH_COMPILE_TARGET}/libpcap" ./configure diff --git a/packages/pcap-agent/spec b/packages/pcap-agent/spec index ccf3bcd8..a10706b5 100644 --- a/packages/pcap-agent/spec +++ b/packages/pcap-agent/spec @@ -6,4 +6,4 @@ dependencies: files: - pcap/**/* -- libpcap-*.tgz +- libpcap-*.tar.gz diff --git a/packages/pcap-api/packaging b/packages/pcap-api/packaging index 7c009c03..62744e47 100644 --- a/packages/pcap-api/packaging +++ b/packages/pcap-api/packaging @@ -20,7 +20,7 @@ export GOCACHE=$BOSH_COMPILE_TARGET/gocache echo "Build libpcap" LIBPCAP_VERSION=1.10.4 # https://www.tcpdump.org/release/libpcap-1.10.4.tar.gz mkdir "${BOSH_COMPILE_TARGET}/libpcap" -tar xzf libpcap-${LIBPCAP_VERSION}.tgz --strip-components 1 -C "${BOSH_COMPILE_TARGET}/libpcap" +tar xzf libpcap-${LIBPCAP_VERSION}.tar.gz --strip-components 1 -C "${BOSH_COMPILE_TARGET}/libpcap" pushd "${BOSH_COMPILE_TARGET}/libpcap" ./configure diff --git a/packages/pcap-api/spec b/packages/pcap-api/spec index c213155d..39fbf9d0 100644 --- a/packages/pcap-api/spec +++ b/packages/pcap-api/spec @@ -6,4 +6,4 @@ dependencies: files: - pcap/**/* -- libpcap-*.tgz +- libpcap-*.tar.gz diff --git a/src/pcap/cmd/pcap-bosh-cli/main.go b/src/pcap/cmd/pcap-bosh-cli/main.go index 09f8f277..eb558df1 100644 --- a/src/pcap/cmd/pcap-bosh-cli/main.go +++ b/src/pcap/cmd/pcap-bosh-cli/main.go @@ -23,6 +23,9 @@ import ( "gopkg.in/yaml.v3" ) +const BoshDefaultPort = 25555 +const BoshAuthTypeUAA = "uaa" + var ( logger *zap.Logger atomicLogLevel zap.AtomicLevel @@ -38,9 +41,8 @@ type options struct { PcapAPIURL string `short:"u" long:"pcap-api-url" description:"The URL of the PCAP API, e.g. pcap.cf.$LANDSCAPE_DOMAIN" env:"PCAP_API" required:"true"` Filter string `short:"f" long:"filter" description:"Allows to provide a filter expression in pcap filter format." required:"false"` Interface string `short:"i" long:"interface" description:"Specifies the network interface to listen on." default:"eth0" required:"false"` - Type string `short:"t" long:"type" description:"Specifies the type of process to capture for the app." default:"web" required:"false"` BoshConfigFilename string `short:"c" long:"bosh-config" description:"Path to the BOSH config file, used for the UAA Token" default:"${HOME}/.bosh/config" required:"false"` - BoshEnvironment string `short:"e" long:"bosh-environment" description:"The BOSH environment to use for retrieving the BOSH UAA token from the BOSH config file" default:"bosh" required:"false"` + BoshEnvironment string `short:"e" long:"bosh-environment" description:"The BOSH environment to use for retrieving the BOSH UAA token from the BOSH config file" env:"BOSH_ENVIRONMENT" required:"false"` Deployment string `short:"d" long:"deployment" description:"The name of the deployment in which you would like to capture." required:"true"` InstanceGroups []string `short:"g" long:"instance-group" description:"The name of an instance group in the deployment in which you would like to capture. Can be defined multiple times." required:"true"` InstanceIds []string `positional-arg-name:"ids" description:"The instance IDs of the deployment to capture." required:"false"` @@ -200,8 +202,11 @@ func checkOutputFile(file string, overwrite bool) error { } // File doesn't exist, check if path is valid fileInfo, err := os.Stat(filepath.Dir(file)) - if err != nil || !fileInfo.IsDir() { - return fmt.Errorf("cannot write file %s. %s does not exist", file, fileInfo.Name()) + if err != nil { + if fileInfo != nil { + return fmt.Errorf("cannot write file %s. %s does not exist", file, fileInfo.Name()) + } + return err } return nil } @@ -246,6 +251,9 @@ func configFromFile(configFilename string) (*Config, error) { if err != nil { return nil, fmt.Errorf("could not open bosh-config: %w", err) } + defer func() { + _ = configReader.Close() + }() var config Config err = yaml.NewDecoder(configReader).Decode(&config) @@ -262,9 +270,9 @@ func configFromFile(configFilename string) (*Config, error) { // Returns url with scheme prefix. func urlWithScheme(url string) string { if !schemaPattern.MatchString(url) { + logger.Debug("URL has no scheme. Defaulting to https.", zap.String("url", url)) return "https://" + url } - logger.Debug("pcap-api URL contains a scheme") return url } @@ -329,7 +337,7 @@ func createCaptureOptions(device string, filter string, snaplen uint32) *pcap.Ca // writeBoshConfig writes the Config to the config-file under configFileName. func writeBoshConfig(config *Config, configFileName string) error { - configWriter, err := os.Create(configFileName) + configWriter, err := os.Create(os.ExpandEnv(configFileName)) if err != nil { return fmt.Errorf("failed to create bosh-config file: %w", err) } @@ -382,8 +390,24 @@ func (e *Environment) UpdateTokens() error { // connect uses the URL from the parsed Bosh environment of a config, sets up the http client for use with either TLS or plain HTTP // and uses this client to establish a connection to the BOSH Director and its UAA. func (e *Environment) connect() error { + err := e.setup() + + if err != nil { + return err + } + + err = e.fetchUAAURL() + if err != nil { + return err + } + + return nil +} + +// setup configures the BOSH http client for a given environment. +func (e *Environment) setup() error { var err error - e.DirectorURL, err = url.Parse(e.URL) + e.DirectorURL, err = url.Parse(urlWithScheme(e.URL)) if err != nil { return fmt.Errorf("error parsing environment url (%v): %w", e.URL, err) } @@ -393,29 +417,30 @@ func (e *Environment) connect() error { e.DirectorURL.Path = "/" } + // If no port was provided, use BOSH default port + if e.DirectorURL.Port() == "" { + e.DirectorURL.Host = fmt.Sprintf("%s:%d", e.DirectorURL.Host, BoshDefaultPort) + } + if e.DirectorURL.Scheme != "https" { logger.Warn("using unencrypted connection to bosh-director", zap.String("bosh-director-url", e.DirectorURL.String())) e.client = http.DefaultClient return nil } - logger.Info("using TLS-encrypted connection to bosh-director", zap.String("bosh-director-url", e.DirectorURL.String())) - boshCA := x509.NewCertPool() - ok := boshCA.AppendCertsFromPEM([]byte(e.CaCert)) - if !ok { - return fmt.Errorf("could not add BOSH Director CA from bosh-config, adding to the cert pool failed") - } - transport := http.DefaultTransport.(*http.Transport).Clone() - transport.TLSClientConfig.RootCAs = boshCA - e.client = &http.Client{ - Transport: transport, + if e.CaCert != "" { + boshCA := x509.NewCertPool() + ok := boshCA.AppendCertsFromPEM([]byte(e.CaCert)) + if !ok { + return fmt.Errorf("could not add BOSH Director CA from bosh-config, adding to the cert pool failed") + } + transport.TLSClientConfig.RootCAs = boshCA } - err = e.fetchUAAURL() - if err != nil { - return err + e.client = &http.Client{ + Transport: transport, } return nil @@ -446,6 +471,10 @@ func (e *Environment) fetchUAAURL() error { return err } + if info.UserAuthentication.Type != BoshAuthTypeUAA { + return fmt.Errorf("unsupported authentication type '%s'", info.UserAuthentication.Type) + } + uaaURL, err := url.Parse(info.UserAuthentication.Options.URL) if err != nil { return err @@ -462,6 +491,9 @@ func (e *Environment) fetchUAAURL() error { // refreshTokens connects to the bosh-uaa API to fetch updated bosh access- & refresh-token. func (e *Environment) refreshTokens() error { + if e.RefreshToken == "" { + return fmt.Errorf("no refresh token found in bosh config. please login first") + } req := http.Request{ Method: http.MethodPost, URL: e.UaaURL.JoinPath("/oauth/token"), diff --git a/src/pcap/cmd/pcap-bosh-cli/main_test.go b/src/pcap/cmd/pcap-bosh-cli/main_test.go index e37bdc62..3ca2480f 100644 --- a/src/pcap/cmd/pcap-bosh-cli/main_test.go +++ b/src/pcap/cmd/pcap-bosh-cli/main_test.go @@ -1,7 +1,13 @@ package main import ( + "encoding/json" + "encoding/pem" + "net/http" + "net/http/httptest" "testing" + + "github.com/cloudfoundry/pcap-release/src/pcap" ) func TestParseAPIURL(t *testing.T) { @@ -54,3 +60,217 @@ func TestParseAPIURL(t *testing.T) { }) } } + +func TestEnvironment_Connect(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/info", func(writer http.ResponseWriter, request *http.Request) { + info := pcap.BoshInfo{} + info.UserAuthentication.Type = BoshAuthTypeUAA + info.UserAuthentication.Options.URL = "https://uaa.fakebosh.com" + + _ = json.NewEncoder(writer).Encode(info) + }) + + fakeBosh := httptest.NewTLSServer(mux) + defer fakeBosh.Close() + + fakeBoshNoTLS := httptest.NewServer(mux) + defer fakeBoshNoTLS.Close() + + tests := []struct { + name string + url string + cacert string + wantErr bool + }{ + { + name: "valid - TLS Full URL with CaCert", + url: fakeBosh.URL, + cacert: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: fakeBosh.Certificate().Raw})), + wantErr: false, + }, + { + name: "valid - TLS IP only with CaCert", + url: fakeBosh.Listener.Addr().String(), + cacert: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: fakeBosh.Certificate().Raw})), + wantErr: false, + }, + { + name: "invalid - TLS Full URL without CaCert", + url: fakeBosh.URL, + cacert: "", + wantErr: true, + }, + { + name: "invalid - TLS IP only without CaCert", + url: fakeBosh.Listener.Addr().String(), + cacert: "", + wantErr: true, + }, + { + name: "valid - No TLS Full URL with CaCert", + url: fakeBoshNoTLS.URL, + cacert: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: fakeBosh.Certificate().Raw})), + wantErr: false, + }, + { + name: "valid - No TLS Full URL without CaCert", + url: fakeBoshNoTLS.URL, + cacert: "", + wantErr: false, + }, + { + name: "invalid - No TLS IP only with CaCert", + url: fakeBoshNoTLS.Listener.Addr().String(), + cacert: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: fakeBosh.Certificate().Raw})), + wantErr: true, + }, + { + name: "invalid - No TLS IP only without CaCert", + url: fakeBoshNoTLS.Listener.Addr().String(), + cacert: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + environment := Environment{ + URL: tt.url, + CaCert: tt.cacert, + } + err := environment.connect() + if (err != nil) != tt.wantErr { + t.Errorf("%s: wantErr = %t, gotError = %v", tt.url, tt.wantErr, err) + } + }) + } +} + +func TestEnvironment_Setup(t *testing.T) { + s := httptest.NewTLSServer(http.NewServeMux()) + bogusCert := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: s.Certificate().Raw})) + + tests := []struct { + name string + url string + wantURL string + cacert string + wantErr bool + }{ + { + name: "valid - Setup TLS with full URL with CaCert", + url: "https://bosh.services.cf.internal:443/", + wantURL: "https://bosh.services.cf.internal:443/", + cacert: bogusCert, + wantErr: false, + }, + { + name: "valid - Setup TLS with URL missing port with CaCert", + url: "https://bosh.services.cf.internal/", + wantURL: "https://bosh.services.cf.internal:25555/", + cacert: bogusCert, + wantErr: false, + }, + { + name: "valid - Setup TLS with IP:port only with CaCert", + url: "1.2.3.4:443", + wantURL: "https://1.2.3.4:443/", + cacert: bogusCert, + wantErr: false, + }, + { + name: "valid - Setup TLS with IP:port only with slash with CaCert", + url: "1.2.3.4:443/", + wantURL: "https://1.2.3.4:443/", + cacert: bogusCert, + wantErr: false, + }, + { + name: "valid - Setup TLS with IP only with CaCert", + url: "1.2.3.4", + wantURL: "https://1.2.3.4:25555/", + cacert: bogusCert, + wantErr: false, + }, + { + name: "valid - Setup TLS with IP and slash only with CaCert", + url: "1.2.3.4/", + wantURL: "https://1.2.3.4:25555/", + cacert: bogusCert, + wantErr: false, + }, + { + name: "valid - Setup TLS with full URL with System Certs only", + url: "https://bosh.services.cf.internal:443", + wantURL: "https://bosh.services.cf.internal:443/", + cacert: "", + wantErr: false, + }, + { + name: "valid - Setup TLS with URL missing port with System Certs only", + url: "https://bosh.services.cf.internal", + wantURL: "https://bosh.services.cf.internal:25555/", + cacert: "", + wantErr: false, + }, + { + name: "valid - Setup non-TLS with full URL with CaCert", + url: "http://bosh.services.cf.internal:80", + wantURL: "http://bosh.services.cf.internal:80/", + cacert: bogusCert, + wantErr: false, + }, + { + name: "valid - Setup non-TLS with URL missing port with CaCert", + url: "http://bosh.services.cf.internal", + wantURL: "http://bosh.services.cf.internal:25555/", + cacert: bogusCert, + wantErr: false, + }, + { + name: "valid - Setup non-TLS with full URL", + url: "http://bosh.services.cf.internal:80", + wantURL: "http://bosh.services.cf.internal:80/", + cacert: "", + wantErr: false, + }, + { + name: "valid - Setup non-TLS with URL missing port", + url: "http://bosh.services.cf.internal", + wantURL: "http://bosh.services.cf.internal:25555/", + cacert: "", + wantErr: false, + }, + { + name: "valid - Setup non-TLS with IP:port", + url: "http://1.2.3.4:80", + wantURL: "http://1.2.3.4:80/", + cacert: "", + wantErr: false, + }, + { + name: "valid - Setup non-TLS with IP only", + url: "http://1.2.3.4", + wantURL: "http://1.2.3.4:25555/", + cacert: "", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + environment := Environment{ + URL: tt.url, + CaCert: tt.cacert, + } + err := environment.setup() + if (err != nil) != tt.wantErr { + t.Errorf("%s: wantErr = %t, gotError = %v", tt.url, tt.wantErr, err) + } + if tt.wantURL != environment.DirectorURL.String() { + t.Errorf("%s: wantURL = %s, gotUrl = %s", tt.url, tt.wantURL, environment.DirectorURL.String()) + } + }) + } +} diff --git a/src/pcap/process.go b/src/pcap/process.go index 87cdf28b..47244fd2 100644 --- a/src/pcap/process.go +++ b/src/pcap/process.go @@ -24,7 +24,7 @@ type WaitingStoppable interface { // StopOnSignal is a reusable function to handle stop signals. // -// The Stoppable interface defines what to do when topping a particular process, and stopSignals defines a list of +// The Stoppable interface defines what to do when stopping a particular process, and stopSignals defines a list of // signals, for which Stop() is called. // // When a server is given, it is shut down gracefully.