diff --git a/ci/bats/bats_suite_test.go b/ci/bats/bats_suite_test.go new file mode 100644 index 000000000..00a022ed4 --- /dev/null +++ b/ci/bats/bats_suite_test.go @@ -0,0 +1,44 @@ +package bats_test + +import ( + "os" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "bosh-openstack-cpi-release/ci/bats/helpers" +) + +func TestBATs(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "BOSH Acceptance Tests (OpenStack)") +} + +var cfg *helpers.Config +var bosh *helpers.Runner + +var _ = BeforeSuite(func() { + // Skip the full suite setup when running in dry-run mode (e.g. local syntax check). + if os.Getenv("BAT_DIRECTOR") == "dry-run" { + Skip("BAT_DIRECTOR=dry-run: skipping real director tests") + } + + var err error + cfg, err = helpers.NewConfig() + Expect(err).NotTo(HaveOccurred(), "loading BAT config") + + bosh = helpers.NewRunner(cfg) + + By("uploading stemcell") + Expect(bosh.UploadStemcell(cfg.StemcellPath)).To(Succeed()) + + By("updating cloud config") + f, err := os.CreateTemp("", "bats-cloud-config-*.yml") + Expect(err).NotTo(HaveOccurred()) + defer os.Remove(f.Name()) + _, err = f.WriteString(helpers.CloudConfig(cfg)) + Expect(err).NotTo(HaveOccurred()) + Expect(f.Close()).To(Succeed()) + Expect(bosh.UpdateCloudConfig(f.Name())).To(Succeed()) +}) diff --git a/ci/bats/go.mod b/ci/bats/go.mod new file mode 100644 index 000000000..2afd16773 --- /dev/null +++ b/ci/bats/go.mod @@ -0,0 +1,24 @@ +module bosh-openstack-cpi-release/ci/bats + +go 1.25.0 + +require ( + github.com/onsi/ginkgo/v2 v2.32.1 + github.com/onsi/gomega v1.40.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect + golang.org/x/tools v0.44.0 // indirect +) diff --git a/ci/bats/go.sum b/ci/bats/go.sum new file mode 100644 index 000000000..f71d68677 --- /dev/null +++ b/ci/bats/go.sum @@ -0,0 +1,69 @@ +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/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/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= +github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= +github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= +github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= +github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= +github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg= +github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= +github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= +github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= +github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= +github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= +github.com/onsi/ginkgo/v2 v2.32.1 h1:6tlvcDm/3sE8lGJbZ4+d4mO3RLy24/tQWOFzVSQNIfw= +github.com/onsi/ginkgo/v2 v2.32.1/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= +github.com/onsi/gomega v1.40.0 h1:Vtol0e1MghCD2ZVIilPDIg44XSL9l2QAn8ZNaljWcJc= +github.com/onsi/gomega v1.40.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A= +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/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= +google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +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/ci/bats/helpers/bosh.go b/ci/bats/helpers/bosh.go new file mode 100644 index 000000000..57a71eb13 --- /dev/null +++ b/ci/bats/helpers/bosh.go @@ -0,0 +1,208 @@ +package helpers + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" + "time" +) + +const defaultCommandTimeout = 30 * time.Minute + +// Runner is a thin wrapper around the bosh-go CLI binary. +type Runner struct { + env []string +} + +// NewRunner creates a Runner pre-loaded with director credentials from cfg. +func NewRunner(cfg *Config) *Runner { + env := append(os.Environ(), + "BOSH_ENVIRONMENT="+cfg.Director, + "BOSH_CLIENT="+cfg.Client, + "BOSH_CLIENT_SECRET="+cfg.ClientSecret, + "BOSH_CA_CERT="+cfg.CACert, + ) + if cfg.AllProxy != "" { + env = append(env, "BOSH_ALL_PROXY="+cfg.AllProxy) + } + return &Runner{env: env} +} + +// Run executes bosh-go with the given args, returning combined stdout+stderr. +func (r *Runner) Run(args ...string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), defaultCommandTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, "bosh-go", append([]string{"-n"}, args...)...) + cmd.Env = r.env + out, err := cmd.CombinedOutput() + if err != nil { + return string(out), fmt.Errorf("bosh-go %s: %w\noutput: %s", strings.Join(args, " "), err, out) + } + return string(out), nil +} + +// UploadStemcell uploads a stemcell tarball, skipping if already present. +func (r *Runner) UploadStemcell(path string) error { + _, err := r.Run("upload-stemcell", path, "--fix") + return err +} + +// UpdateCloudConfig applies the given cloud config YAML file to the director. +func (r *Runner) UpdateCloudConfig(path string) error { + _, err := r.Run("update-cloud-config", path) + return err +} + +// Deploy deploys using the manifest at path. Returns combined output. +func (r *Runner) Deploy(manifestPath string) (string, error) { + return r.Run("deploy", "-d", deploymentName(manifestPath), manifestPath) +} + +// DeleteDeployment deletes the named deployment. +func (r *Runner) DeleteDeployment(name string) error { + _, err := r.Run("delete-deployment", "-d", name) + return err +} + +// Recreate triggers a full recreate of all instances in the named deployment. +func (r *Runner) Recreate(deployment string) error { + _, err := r.Run("recreate", "-d", deployment) + return err +} + +// SSH runs cmd on instance slug (e.g. "group/0") inside deployment, returning stdout. +func (r *Runner) SSH(deployment, instanceSlug, cmd string) (string, error) { + return r.Run("ssh", "-d", deployment, instanceSlug, "-c", cmd) +} + +// Instance holds the relevant fields from `bosh instances --json`. +type Instance struct { + Name string `json:"instance"` + State string `json:"process_state"` + IPs string `json:"ips"` +} + +// Instances returns a list of instances in the named deployment. +func (r *Runner) Instances(deployment string) ([]Instance, error) { + out, err := r.Run("instances", "-d", deployment, "--json") + if err != nil { + return nil, err + } + return parseInstancesJSON(out) +} + +// InstanceDetail extends Instance with the disk CID of the attached persistent disk. +type InstanceDetail struct { + Name string `json:"instance"` + State string `json:"process_state"` + IPs string `json:"ips"` + DiskCID string `json:"disk_cids"` +} + +// InstancesDetails returns instances with disk attachment information. +func (r *Runner) InstancesDetails(deployment string) ([]InstanceDetail, error) { + out, err := r.Run("instances", "-d", deployment, "--details", "--json") + if err != nil { + return nil, err + } + return parseInstanceDetailsJSON(out) +} + +// DiskIDs returns the disk CIDs attached to instances of a deployment. +func (r *Runner) DiskIDs(deployment string) ([]string, error) { + out, err := r.Run("disks", "--orphaned", "--json") + if err != nil { + return nil, err + } + return parseDiskIDsJSON(out, deployment) +} + +type instancesResponse struct { + Tables []struct { + Rows []Instance `json:"Rows"` + } `json:"Tables"` +} + +func parseInstancesJSON(raw string) ([]Instance, error) { + // Trim non-JSON prefix (bosh sometimes emits a "Using environment..." line) + idx := strings.Index(raw, "{") + if idx < 0 { + return nil, fmt.Errorf("no JSON in bosh instances output: %s", raw) + } + var resp instancesResponse + if err := json.Unmarshal([]byte(raw[idx:]), &resp); err != nil { + return nil, fmt.Errorf("parsing instances JSON: %w\nraw: %s", err, raw) + } + if len(resp.Tables) == 0 { + return nil, nil + } + return resp.Tables[0].Rows, nil +} + +type instanceDetailsResponse struct { + Tables []struct { + Rows []InstanceDetail `json:"Rows"` + } `json:"Tables"` +} + +func parseInstanceDetailsJSON(raw string) ([]InstanceDetail, error) { + idx := strings.Index(raw, "{") + if idx < 0 { + return nil, fmt.Errorf("no JSON in bosh instances --details output: %s", raw) + } + var resp instanceDetailsResponse + if err := json.Unmarshal([]byte(raw[idx:]), &resp); err != nil { + return nil, fmt.Errorf("parsing instances details JSON: %w\nraw: %s", err, raw) + } + if len(resp.Tables) == 0 { + return nil, nil + } + return resp.Tables[0].Rows, nil +} + +type disksResponse struct { + Tables []struct { + Rows []struct { + DiskCID string `json:"disk_cid"` + Deployment string `json:"deployment"` + } `json:"Rows"` + } `json:"Tables"` +} + +func parseDiskIDsJSON(raw, deployment string) ([]string, error) { + idx := strings.Index(raw, "{") + if idx < 0 { + return nil, nil + } + var resp disksResponse + if err := json.Unmarshal([]byte(raw[idx:]), &resp); err != nil { + return nil, err + } + var ids []string + for _, t := range resp.Tables { + for _, row := range t.Rows { + if row.Deployment == deployment { + ids = append(ids, row.DiskCID) + } + } + } + return ids, nil +} + +// deploymentName extracts the deployment name from a manifest file: expects a +// "name:" key on the first line that starts with it, falling back to the filename. +func deploymentName(manifestPath string) string { + data, err := os.ReadFile(manifestPath) + if err != nil { + return manifestPath + } + for _, line := range strings.Split(string(data), "\n") { + if strings.HasPrefix(line, "name:") { + return strings.TrimSpace(strings.TrimPrefix(line, "name:")) + } + } + return manifestPath +} diff --git a/ci/bats/helpers/config.go b/ci/bats/helpers/config.go new file mode 100644 index 000000000..1db2422f3 --- /dev/null +++ b/ci/bats/helpers/config.go @@ -0,0 +1,150 @@ +package helpers + +import ( + "fmt" + "os" + + yaml "gopkg.in/yaml.v3" +) + +// Config holds everything the BATs need at runtime. +type Config struct { + // BOSH director connection + Director string + Client string + ClientSecret string + CACert string + AllProxy string + + // Stemcell tarball path on disk (Concourse input) + StemcellPath string + + // Parsed from BAT_DEPLOYMENT_SPEC + BATs BATProperties +} + +// BATProperties mirrors the relevant subset of the bats-config.yml schema +// written by run-bats.sh. +type BATProperties struct { + InstanceType string `yaml:"instance_type"` + FlavorWithNoEphemeralDisk string `yaml:"flavor_with_no_ephemeral_disk"` + VIP string `yaml:"vip"` + SecondStaticIP string `yaml:"second_static_ip"` + AvailabilityZone string `yaml:"availability_zone"` + Stemcell StemcellRef `yaml:"stemcell"` + SSHGateway SSHGateway `yaml:"ssh_gateway"` + SSHKeyPair SSHKeyPair `yaml:"ssh_key_pair"` + Networks []Network `yaml:"networks"` +} + +// StemcellRef is the stemcell stanza in bats-config.yml. +type StemcellRef struct { + Name string `yaml:"name"` + Version string `yaml:"version"` +} + +// SSHGateway describes the jumpbox used for BOSH SSH tunnels. +type SSHGateway struct { + Host string `yaml:"host"` + Username string `yaml:"username"` +} + +// SSHKeyPair holds the jumpbox key pair for SSH tunnel establishment. +type SSHKeyPair struct { + PublicKey string `yaml:"public_key"` + PrivateKey string `yaml:"private_key"` +} + +// Network is one entry in the bats-config.yml networks list. +type Network struct { + Name string `yaml:"name"` + StaticIP string `yaml:"static_ip"` + Type string `yaml:"type"` + CloudProps NetworkCloudProps `yaml:"cloud_properties"` + CIDR string `yaml:"cidr"` + Reserved []string `yaml:"reserved"` + Static []string `yaml:"static"` + Gateway string `yaml:"gateway"` +} + +// NetworkCloudProps mirrors the OpenStack cloud_properties for a network. +type NetworkCloudProps struct { + NetID string `yaml:"net_id"` + SecurityGroups []string `yaml:"security_groups"` +} + +type batsConfigFile struct { + CPI string `yaml:"cpi"` + Properties BATProperties `yaml:"properties"` +} + +// NewConfig reads all required env vars and the BAT_DEPLOYMENT_SPEC file. +func NewConfig() (*Config, error) { + c := &Config{} + + required := map[string]*string{ + "BAT_DIRECTOR": &c.Director, + "BOSH_CLIENT": &c.Client, + "BOSH_CLIENT_SECRET": &c.ClientSecret, + "BOSH_CA_CERT": &c.CACert, + "BAT_STEMCELL": &c.StemcellPath, + "BAT_DEPLOYMENT_SPEC": nil, // handled separately below + } + for k, v := range required { + if v == nil { + continue + } + *v = os.Getenv(k) + if *v == "" { + return nil, fmt.Errorf("required env var %s is not set", k) + } + } + + c.AllProxy = os.Getenv("BOSH_ALL_PROXY") + + specPath := os.Getenv("BAT_DEPLOYMENT_SPEC") + if specPath == "" { + return nil, fmt.Errorf("required env var BAT_DEPLOYMENT_SPEC is not set") + } + if err := c.loadSpec(specPath); err != nil { + return nil, fmt.Errorf("loading BAT_DEPLOYMENT_SPEC %s: %w", specPath, err) + } + + return c, nil +} + +func (c *Config) loadSpec(path string) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + var f batsConfigFile + if err := yaml.Unmarshal(data, &f); err != nil { + return err + } + if len(f.Properties.Networks) == 0 { + return fmt.Errorf("properties.networks is missing or empty in %s", path) + } + c.BATs = f.Properties + return nil +} + +// PrimaryNetwork returns the first network entry (the "default" manual network). +func (c *Config) PrimaryNetwork() Network { + for _, n := range c.BATs.Networks { + if n.Name == "default" { + return n + } + } + return c.BATs.Networks[0] +} + +// SecondaryNetwork returns the second network entry if present. +func (c *Config) SecondaryNetwork() (Network, bool) { + for _, n := range c.BATs.Networks { + if n.Name == "second" { + return n, true + } + } + return Network{}, false +} diff --git a/ci/bats/helpers/manifest.go b/ci/bats/helpers/manifest.go new file mode 100644 index 000000000..eccbdda07 --- /dev/null +++ b/ci/bats/helpers/manifest.go @@ -0,0 +1,193 @@ +package helpers + +import ( + "fmt" + "strings" +) + +// CloudConfig generates a BOSH cloud config from the BATs configuration. +// It creates two manual networks (primary + secondary), two VM types +// (default / no-ephemeral-disk), one disk type, and a compilation block. +func CloudConfig(cfg *Config) string { + primary := cfg.PrimaryNetwork() + az := cfg.BATs.AvailabilityZone + if az == "" { + az = "nova" + } + + reserved := yamlSeq(primary.Reserved, " ") + static := yamlSeq(primary.Static, " ") + + var sb strings.Builder + fmt.Fprintf(&sb, `azs: +- name: z1 + cloud_properties: + availability_zone: %s + +vm_types: +- name: default + cloud_properties: + instance_type: %s +- name: no-ephemeral-disk + cloud_properties: + instance_type: %s + +disk_types: +- name: default + disk_size: 2048 + cloud_properties: {} + +networks: +- name: default + type: manual + subnets: + - range: %s + gateway: %s + az: z1 + dns: [8.8.8.8] + reserved: +%s + static: +%s + cloud_properties: + net_id: %s + security_groups: [%s] +`, + az, + cfg.BATs.InstanceType, + cfg.BATs.FlavorWithNoEphemeralDisk, + primary.CIDR, + primary.Gateway, + reserved, + static, + primary.CloudProps.NetID, + strings.Join(primary.CloudProps.SecurityGroups, ", "), + ) + + if secondary, ok := cfg.SecondaryNetwork(); ok { + secReserved := yamlSeq(secondary.Reserved, " ") + secStatic := yamlSeq(secondary.Static, " ") + fmt.Fprintf(&sb, `- name: second + type: manual + subnets: + - range: %s + gateway: %s + az: z1 + dns: [8.8.8.8] + reserved: +%s + static: +%s + cloud_properties: + net_id: %s + security_groups: [%s] +`, + secondary.CIDR, + secondary.Gateway, + secReserved, + secStatic, + secondary.CloudProps.NetID, + strings.Join(secondary.CloudProps.SecurityGroups, ", "), + ) + } + + fmt.Fprintf(&sb, ` +compilation: + workers: 2 + reuse_compilation_vms: true + az: z1 + vm_type: default + network: default +`) + return sb.String() +} + +// VMLifecycleManifest returns a minimal deployment manifest that deploys one +// agent-only VM on the primary manual network at staticIP. +func VMLifecycleManifest(cfg *Config, name, staticIP string) string { + return vmManifest(cfg, name, staticIP, "default", false) +} + +// PersistentDiskManifest is like VMLifecycleManifest but attaches a 2 GiB +// persistent disk so BOSH will manage disk attach/detach across recreates. +func PersistentDiskManifest(cfg *Config, name, staticIP string) string { + return vmManifest(cfg, name, staticIP, "default", true) +} + +// MultiNetworkManifest deploys two VMs on the same deployment, one per manual +// network, using the supplied static IPs. +func MultiNetworkManifest(_ *Config, name, primaryIP, secondaryIP string) string { + return fmt.Sprintf(`name: %s +stemcells: +- alias: default + os: ubuntu-noble + version: latest +instance_groups: +- name: primary-vm + instances: 1 + vm_type: default + stemcell: default + azs: [z1] + networks: + - name: default + static_ips: [%s] + default: [dns, gateway] + jobs: [] +- name: secondary-vm + instances: 1 + vm_type: default + stemcell: default + azs: [z1] + networks: + - name: second + static_ips: [%s] + jobs: [] +update: + canaries: 1 + max_in_flight: 2 + canary_watch_time: 30000-600000 + update_watch_time: 30000-600000 +`, name, primaryIP, secondaryIP) +} + +func vmManifest(cfg *Config, name, staticIP, vmType string, persistentDisk bool) string { + diskLine := "" + if persistentDisk { + diskLine = "\n persistent_disk_type: default" + } + return fmt.Sprintf(`name: %s +stemcells: +- alias: default + os: ubuntu-noble + version: latest +instance_groups: +- name: bats-vm + instances: 1 + vm_type: %s + stemcell: default + azs: [z1]%s + networks: + - name: default + static_ips: [%s] + default: [dns, gateway] + jobs: [] +update: + canaries: 1 + max_in_flight: 1 + canary_watch_time: 30000-600000 + update_watch_time: 30000-600000 +`, name, vmType, diskLine, staticIP) +} + +// yamlSeq renders a []string as an indented YAML sequence suitable for +// embedding inside a larger YAML document at the given indent level. +func yamlSeq(items []string, indent string) string { + if len(items) == 0 { + return indent + "[]" + } + var lines []string + for _, item := range items { + lines = append(lines, indent+"- "+item) + } + return strings.Join(lines, "\n") +} diff --git a/ci/bats/networking_test.go b/ci/bats/networking_test.go new file mode 100644 index 000000000..9c4572085 --- /dev/null +++ b/ci/bats/networking_test.go @@ -0,0 +1,92 @@ +package bats_test + +import ( + "os" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "bosh-openstack-cpi-release/ci/bats/helpers" +) + +var _ = Describe("Manual networking", func() { + const deployment = "bats-networking" + + var deployed bool + + BeforeEach(func() { + deployed = false + }) + + AfterEach(func() { + if deployed { + Expect(bosh.DeleteDeployment(deployment)).To(Succeed()) + } else { + _ = bosh.DeleteDeployment(deployment) + } + }) + + It("assigns the expected static IP on the primary manual network", func() { + primary := cfg.PrimaryNetwork() + manifest := helpers.VMLifecycleManifest(cfg, deployment, primary.StaticIP) + + f, err := os.CreateTemp("", "bats-net-manifest-*.yml") + Expect(err).NotTo(HaveOccurred()) + defer os.Remove(f.Name()) + _, err = f.WriteString(manifest) + Expect(err).NotTo(HaveOccurred()) + Expect(f.Close()).To(Succeed()) + + By("deploying") + _, err = bosh.Deploy(f.Name()) + Expect(err).NotTo(HaveOccurred()) + deployed = true + + By("verifying the instance IP matches the requested static IP") + instances, err := bosh.Instances(deployment) + Expect(err).NotTo(HaveOccurred()) + Expect(instances).To(HaveLen(1)) + Expect(instances[0].IPs).To(ContainSubstring(primary.StaticIP), + "instance IP should contain the requested static IP %s", primary.StaticIP) + }) + + It("deploys two VMs on separate manual networks", func() { + secondary, ok := cfg.SecondaryNetwork() + if !ok { + Skip("no secondary network configured in BAT_DEPLOYMENT_SPEC") + } + + primary := cfg.PrimaryNetwork() + manifest := helpers.MultiNetworkManifest(cfg, deployment, + primary.StaticIP, secondary.StaticIP) + + f, err := os.CreateTemp("", "bats-multinetwork-*.yml") + Expect(err).NotTo(HaveOccurred()) + defer os.Remove(f.Name()) + _, err = f.WriteString(manifest) + Expect(err).NotTo(HaveOccurred()) + Expect(f.Close()).To(Succeed()) + + By("deploying two instance groups across two networks") + _, err = bosh.Deploy(f.Name()) + Expect(err).NotTo(HaveOccurred()) + deployed = true + + By("verifying both instances are running with the expected IPs") + instances, err := bosh.Instances(deployment) + Expect(err).NotTo(HaveOccurred()) + Expect(instances).To(HaveLen(2)) + for _, inst := range instances { + Expect(inst.State).To(Equal("running")) + switch { + case strings.HasPrefix(inst.Name, "primary-vm"): + Expect(inst.IPs).To(ContainSubstring(primary.StaticIP), + "primary-vm should have IP %s", primary.StaticIP) + case strings.HasPrefix(inst.Name, "secondary-vm"): + Expect(inst.IPs).To(ContainSubstring(secondary.StaticIP), + "secondary-vm should have IP %s", secondary.StaticIP) + } + } + }) +}) diff --git a/ci/bats/persistent_disk_test.go b/ci/bats/persistent_disk_test.go new file mode 100644 index 000000000..e3573f9ed --- /dev/null +++ b/ci/bats/persistent_disk_test.go @@ -0,0 +1,64 @@ +package bats_test + +import ( + "os" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "bosh-openstack-cpi-release/ci/bats/helpers" +) + +var _ = Describe("Persistent disk", func() { + const deployment = "bats-persistent-disk" + + var deployed bool + + BeforeEach(func() { + deployed = false + }) + + AfterEach(func() { + if deployed { + Expect(bosh.DeleteDeployment(deployment)).To(Succeed()) + } else { + _ = bosh.DeleteDeployment(deployment) + } + }) + + It("preserves the disk across a VM recreate", func() { + primary := cfg.PrimaryNetwork() + manifest := helpers.PersistentDiskManifest(cfg, deployment, primary.StaticIP) + + f, err := os.CreateTemp("", "bats-disk-manifest-*.yml") + Expect(err).NotTo(HaveOccurred()) + defer os.Remove(f.Name()) + _, err = f.WriteString(manifest) + Expect(err).NotTo(HaveOccurred()) + Expect(f.Close()).To(Succeed()) + + By("deploying with persistent disk") + _, err = bosh.Deploy(f.Name()) + Expect(err).NotTo(HaveOccurred()) + deployed = true + + By("recording disk CID before recreate") + detailsBefore, err := bosh.InstancesDetails(deployment) + Expect(err).NotTo(HaveOccurred()) + Expect(detailsBefore).To(HaveLen(1)) + Expect(detailsBefore[0].State).To(Equal("running")) + diskCIDBefore := detailsBefore[0].DiskCID + Expect(diskCIDBefore).NotTo(BeEmpty(), "instance should have a persistent disk attached") + + By("recreating the VM") + Expect(bosh.Recreate(deployment)).To(Succeed()) + + By("confirming disk CID is preserved after recreate") + detailsAfter, err := bosh.InstancesDetails(deployment) + Expect(err).NotTo(HaveOccurred()) + Expect(detailsAfter).To(HaveLen(1)) + Expect(detailsAfter[0].State).To(Equal("running")) + Expect(detailsAfter[0].DiskCID).To(Equal(diskCIDBefore), + "disk CID should be identical before and after recreate") + }) +}) diff --git a/ci/bats/recreation_test.go b/ci/bats/recreation_test.go new file mode 100644 index 000000000..74b2adde0 --- /dev/null +++ b/ci/bats/recreation_test.go @@ -0,0 +1,61 @@ +package bats_test + +import ( + "os" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "bosh-openstack-cpi-release/ci/bats/helpers" +) + +var _ = Describe("VM recreation", func() { + const deployment = "bats-recreation" + + var deployed bool + + BeforeEach(func() { + deployed = false + }) + + AfterEach(func() { + if deployed { + Expect(bosh.DeleteDeployment(deployment)).To(Succeed()) + } else { + _ = bosh.DeleteDeployment(deployment) + } + }) + + It("recreates the VM and brings it back with the same static IP", func() { + primary := cfg.PrimaryNetwork() + manifest := helpers.VMLifecycleManifest(cfg, deployment, primary.StaticIP) + + f, err := os.CreateTemp("", "bats-recreate-manifest-*.yml") + Expect(err).NotTo(HaveOccurred()) + defer os.Remove(f.Name()) + _, err = f.WriteString(manifest) + Expect(err).NotTo(HaveOccurred()) + Expect(f.Close()).To(Succeed()) + + By("deploying") + _, err = bosh.Deploy(f.Name()) + Expect(err).NotTo(HaveOccurred()) + deployed = true + + By("recording instance IP before recreate") + instancesBefore, err := bosh.Instances(deployment) + Expect(err).NotTo(HaveOccurred()) + Expect(instancesBefore).To(HaveLen(1)) + ipBefore := instancesBefore[0].IPs + + By("recreating") + Expect(bosh.Recreate(deployment)).To(Succeed()) + + By("confirming instance is running with same IP after recreate") + instancesAfter, err := bosh.Instances(deployment) + Expect(err).NotTo(HaveOccurred()) + Expect(instancesAfter).To(HaveLen(1)) + Expect(instancesAfter[0].State).To(Equal("running")) + Expect(instancesAfter[0].IPs).To(Equal(ipBefore), "IP should not change across recreate") + }) +}) diff --git a/ci/bats/ssh_test.go b/ci/bats/ssh_test.go new file mode 100644 index 000000000..5530dc12e --- /dev/null +++ b/ci/bats/ssh_test.go @@ -0,0 +1,39 @@ +package bats_test + +import ( + "os" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "bosh-openstack-cpi-release/ci/bats/helpers" +) + +var _ = Describe("SSH", func() { + const deployment = "bats-ssh" + + AfterEach(func() { + _ = bosh.DeleteDeployment(deployment) + }) + + It("can SSH through the jumpbox to a deployed VM", func() { + primary := cfg.PrimaryNetwork() + manifest := helpers.VMLifecycleManifest(cfg, deployment, primary.StaticIP) + + f, err := os.CreateTemp("", "bats-ssh-manifest-*.yml") + Expect(err).NotTo(HaveOccurred()) + defer os.Remove(f.Name()) + _, err = f.WriteString(manifest) + Expect(err).NotTo(HaveOccurred()) + Expect(f.Close()).To(Succeed()) + + By("deploying") + _, err = bosh.Deploy(f.Name()) + Expect(err).NotTo(HaveOccurred()) + + By("SSHing to the instance and running a command") + out, err := bosh.SSH(deployment, "bats-vm/0", "echo bats-ssh-ok") + Expect(err).NotTo(HaveOccurred(), "bosh ssh failed: %s", out) + Expect(out).To(ContainSubstring("bats-ssh-ok")) + }) +}) diff --git a/ci/bats/vm_lifecycle_test.go b/ci/bats/vm_lifecycle_test.go new file mode 100644 index 000000000..535683def --- /dev/null +++ b/ci/bats/vm_lifecycle_test.go @@ -0,0 +1,44 @@ +package bats_test + +import ( + "os" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "bosh-openstack-cpi-release/ci/bats/helpers" +) + +var _ = Describe("VM lifecycle", func() { + const deployment = "bats-vm-lifecycle" + + AfterEach(func() { + // Best-effort cleanup so a failed test does not leave orphaned VMs. + _ = bosh.DeleteDeployment(deployment) + }) + + It("deploys a VM and deletes the deployment", func() { + primary := cfg.PrimaryNetwork() + manifest := helpers.VMLifecycleManifest(cfg, deployment, primary.StaticIP) + + f, err := os.CreateTemp("", "bats-manifest-*.yml") + Expect(err).NotTo(HaveOccurred()) + defer os.Remove(f.Name()) + _, err = f.WriteString(manifest) + Expect(err).NotTo(HaveOccurred()) + Expect(f.Close()).To(Succeed()) + + By("deploying") + _, err = bosh.Deploy(f.Name()) + Expect(err).NotTo(HaveOccurred()) + + By("confirming instance is running") + instances, err := bosh.Instances(deployment) + Expect(err).NotTo(HaveOccurred()) + Expect(instances).To(HaveLen(1)) + Expect(instances[0].State).To(Equal("running")) + + By("deleting deployment") + Expect(bosh.DeleteDeployment(deployment)).To(Succeed()) + }) +}) diff --git a/ci/docker/openstack-cpi-release/Dockerfile b/ci/docker/openstack-cpi-release/Dockerfile index bee5dbfd8..267ca362b 100644 --- a/ci/docker/openstack-cpi-release/Dockerfile +++ b/ci/docker/openstack-cpi-release/Dockerfile @@ -1,3 +1,5 @@ +FROM ghcr.io/cloudfoundry/bosh/golang-release AS golang + FROM ubuntu:noble ENV DEBIAN_FRONTEND=noninteractive @@ -48,3 +50,6 @@ RUN curl -fsSL "https://github.com/cloudfoundry/bosh-cli/releases/download/v${BO && mv /tmp/bosh /usr/bin/bosh \ && chmod +x /usr/bin/bosh \ && cp /usr/bin/bosh /usr/local/bin/bosh-go + +COPY --from=golang /var/vcap/packages/golang-1-linux /usr/local/go +ENV PATH="/usr/local/go/bin:${PATH}" diff --git a/ci/pipeline.yml b/ci/pipeline.yml index abbb968be..baa327607 100644 --- a/ci/pipeline.yml +++ b/ci/pipeline.yml @@ -60,8 +60,6 @@ jobs: - get: stemcell resource: openstack-ubuntu-noble-stemcell timeout: *timeouts-long - - get: bats - timeout: *timeouts-long - get: bosh-deployment timeout: *timeouts-long @@ -161,18 +159,32 @@ jobs: - task: run-bats timeout: *timeouts-long image: openstack-cpi-release-docker-image - file: bosh-openstack-cpi-release/ci/tasks/run-manual-networking-bats.yml + file: bosh-openstack-cpi-release/ci/tasks/run-bats.yml params: stemcell_name: "bosh-openstack-kvm-ubuntu-noble" openstack_flavor_with_ephemeral_disk: with-ephemeral-disk openstack_flavor_with_no_ephemeral_disk: without-ephemeral-disk availability_zone: nova DEBUG_BATS: *debug_bats - bats_rspec_tags: "" ensure: task: bats-print-errors timeout: *timeouts-long - file: bosh-openstack-cpi-release/ci/tasks/print_task_errors.yml + image: openstack-cpi-release-docker-image + config: + platform: linux + inputs: + - name: bosh-director-deployment + - name: terraform-cpi + run: + path: bash + args: + - -c + - | + export BOSH_ENVIRONMENT=$(jq -r '.director_public_ip' terraform-cpi/metadata) + export BOSH_CLIENT=admin + export BOSH_CLIENT_SECRET=$(bosh-go int bosh-director-deployment/credentials.yml --path /admin_password) + export BOSH_CA_CERT="$(bosh-go int bosh-director-deployment/credentials.yml --path /director_ssl/ca)" + bosh-go -n tasks --all --recent=50 ensure: put: destroy-devstack @@ -446,12 +458,6 @@ resources: username: bosh-admin-bot password: ((github_public_repo_token)) -- name: bats - type: git - source: - uri: https://github.com/cloudfoundry/bosh-acceptance-tests.git - branch: master - - name: openstack-ubuntu-noble-stemcell type: bosh-io-stemcell source: diff --git a/ci/tasks/print_task_errors.sh b/ci/tasks/print_task_errors.sh deleted file mode 100755 index d21241cf4..000000000 --- a/ci/tasks/print_task_errors.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash - -set -e - -source bosh-openstack-cpi-release/ci/tasks/utils.sh - -export_terraform_variable terraform-cpi/metadata "director_public_ip" -export BOSH_ENVIRONMENT=${director_public_ip} -export BOSH_CLIENT=admin -export BOSH_CLIENT_SECRET=$(bosh-go int bosh-director-deployment/credentials.yml --path /admin_password) -export BOSH_CA_CERT="$(bosh-go int bosh-director-deployment/credentials.yml --path /director_ssl/ca)" - -bosh-go -n tasks --all --recent=100 - -echo 'Printing debug output of tasks in state error, latest errors first' - -bosh-go -n tasks --all --recent=100 --json | ./bosh-openstack-cpi-release/ci/ruby_scripts/print_task_debug_output.sh diff --git a/ci/tasks/print_task_errors.yml b/ci/tasks/print_task_errors.yml deleted file mode 100644 index 867f277b3..000000000 --- a/ci/tasks/print_task_errors.yml +++ /dev/null @@ -1,14 +0,0 @@ ---- -platform: linux -image_resource: - type: registry-image - source: - repository: ghcr.io/cloudfoundry/bosh-openstack-cpi-release/openstack-cpi-release - username: ((github_read_write_packages.username)) - password: ((github_read_write_packages.password)) -inputs: - - name: bosh-openstack-cpi-release - - name: terraform-cpi - - name: bosh-director-deployment -run: - path: bosh-openstack-cpi-release/ci/tasks/print_task_errors.sh diff --git a/ci/tasks/run-manual-networking-bats.sh b/ci/tasks/run-bats.sh similarity index 83% rename from ci/tasks/run-manual-networking-bats.sh rename to ci/tasks/run-bats.sh index 60537f8f2..bf2c56894 100755 --- a/ci/tasks/run-manual-networking-bats.sh +++ b/ci/tasks/run-bats.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash -# Network and credential variables are exported at runtime by the utils.sh -# helpers (export_terraform_variable/optional_value), which shellcheck can't follow. # shellcheck disable=SC1091,SC2154 +# SC1091: utils.sh exports are not statically traceable +# SC2154: terraform variables are exported dynamically by export_terraform_variable set -eo pipefail @@ -13,7 +13,6 @@ source bosh-openstack-cpi-release/ci/tasks/utils.sh : "${openstack_flavor_with_no_ephemeral_disk:?}" optional_value availability_zone -optional_value bats_rspec_tags export_terraform_variable terraform-cpi/metadata "director_public_ip" export_terraform_variable terraform-cpi/metadata "director_private_ip" @@ -34,11 +33,10 @@ export_terraform_variable terraform-cpi/metadata "secondary_net_dhcp_pool" export_terraform_variable terraform-cpi/metadata "security_group" working_dir=$PWD -# checked by BATs environment helper (bosh-acceptance-tests.git/lib/bat/env.rb) + export BAT_STEMCELL="${working_dir}/stemcell/stemcell.tgz" export BAT_DIRECTOR="${director_public_ip}" export BAT_INFRASTRUCTURE='openstack' -export BAT_BOSH_CLI='bosh-go' export BOSH_ENVIRONMENT="${director_public_ip}" export BOSH_CLIENT="admin" @@ -47,7 +45,7 @@ export BOSH_CLIENT_SECRET BOSH_CA_CERT="$(creds_path /director_ssl/ca)" export BOSH_CA_CERT -ssh_private_key=$( creds_path /jumpbox_ssh/private_key | sed 's/$/\\n/' | tr -d '\n' ) +ssh_private_key="$(creds_path /jumpbox_ssh/private_key | sed 's/$/\\n/' | tr -d '\n')" private_key_path=$(mktemp) echo -e "${ssh_private_key}" > "${private_key_path}" export BOSH_ALL_PROXY="ssh+socks5://jumpbox@${director_public_ip}:22?private-key=${private_key_path}" @@ -99,8 +97,5 @@ properties: gateway: ${secondary_net_gateway} EOF -cd bats -bundle install -j4 -# bats_rspec_tags may hold multiple space-separated flags; split into an array. -read -ra rspec_tags <<< "${bats_rspec_tags:-}" -bundle exec rspec --tag ~raw_ephemeral_storage --tag ~ipv6 --tag ~nic_groups --tag ~multiple_manual_networks "${rspec_tags[@]}" spec +cd bosh-openstack-cpi-release/ci/bats +go test ./... -v -timeout 90m diff --git a/ci/tasks/run-manual-networking-bats.yml b/ci/tasks/run-bats.yml similarity index 81% rename from ci/tasks/run-manual-networking-bats.yml rename to ci/tasks/run-bats.yml index 206622671..8e43d15d3 100644 --- a/ci/tasks/run-manual-networking-bats.yml +++ b/ci/tasks/run-bats.yml @@ -1,22 +1,24 @@ --- platform: linux + image_resource: type: registry-image source: repository: ghcr.io/cloudfoundry/bosh-openstack-cpi-release/openstack-cpi-release username: ((github_read_write_packages.username)) password: ((github_read_write_packages.password)) + inputs: - name: bosh-openstack-cpi-release - name: stemcell - - name: bats - name: terraform-cpi - name: bosh-director-deployment + run: - path: bosh-openstack-cpi-release/ci/tasks/run-manual-networking-bats.sh + path: bosh-openstack-cpi-release/ci/tasks/run-bats.sh + params: stemcell_name: replace-me openstack_flavor_with_ephemeral_disk: replace-me openstack_flavor_with_no_ephemeral_disk: replace-me availability_zone: replace-me - bats_rspec_tags: ""