// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0

package csi

import (
	"fmt"
	"time"

	"github.com/hashicorp/nomad/e2e/e2eutil"
	e2e "github.com/hashicorp/nomad/e2e/e2eutil"
	"github.com/hashicorp/nomad/e2e/framework"
	"github.com/hashicorp/nomad/helper/uuid"
	"github.com/hashicorp/nomad/testutil"
)

// CSIControllerPluginEBSTest exercises the AWS EBS plugin, which is an
// example of a plugin that supports most of the CSI Controller RPCs.
type CSIControllerPluginEBSTest struct {
	framework.TC
	uuid         string
	testJobIDs   []string
	volumeIDs    []string
	pluginJobIDs []string
	nodeIDs      []string
}

const ebsPluginID = "aws-ebs0"

// BeforeAll waits for the cluster to be ready, deploys the CSI plugins, and
// creates two EBS volumes for use in the test.
func (tc *CSIControllerPluginEBSTest) BeforeAll(f *framework.F) {
	e2eutil.WaitForLeader(f.T(), tc.Nomad())
	e2eutil.WaitForNodesReady(f.T(), tc.Nomad(), 2)

	tc.uuid = uuid.Generate()[0:8]

	// deploy the controller plugin job
	controllerJobID := "aws-ebs-plugin-controller-" + tc.uuid
	f.NoError(e2eutil.Register(controllerJobID, "csi/input/plugin-aws-ebs-controller.nomad"))
	tc.pluginJobIDs = append(tc.pluginJobIDs, controllerJobID)

	f.NoError(e2e.WaitForAllocStatusComparison(
		func() ([]string, error) { return e2e.AllocStatuses(controllerJobID, ns) },
		func(got []string) bool {
			if len(got) != 2 {
				return false
			}
			for _, status := range got {
				if status != "running" {
					return false
				}
			}
			return true
		}, pluginAllocWait,
	), "plugin job should be running")

	// deploy the node plugins job
	nodesJobID := "aws-ebs-plugin-nodes-" + tc.uuid
	f.NoError(e2eutil.Register(nodesJobID, "csi/input/plugin-aws-ebs-nodes.nomad"))
	tc.pluginJobIDs = append(tc.pluginJobIDs, nodesJobID)

	f.NoError(e2eutil.WaitForAllocStatusComparison(
		func() ([]string, error) { return e2eutil.AllocStatuses(nodesJobID, ns) },
		func(got []string) bool {
			for _, status := range got {
				if status != "running" {
					return false
				}
			}
			return true
		}, nil,
	))

	f.NoError(waitForPluginStatusControllerCount(ebsPluginID, 2, pluginWait),
		"aws-ebs0 controller plugins did not become healthy")
	f.NoError(waitForPluginStatusMinNodeCount(ebsPluginID, 2, pluginWait),
		"aws-ebs0 node plugins did not become healthy")

	// ideally we'd wait until after we check `nomad volume status -verbose`
	// to verify these volumes are ready, but the plugin doesn't support the
	// CSI ListVolumes RPC
	volID := "ebs-vol[0]"
	err := volumeRegister(volID, "csi/input/ebs-volume0.hcl", "create")
	requireNoErrorElseDump(f, err, "could not create volume", tc.pluginJobIDs)
	tc.volumeIDs = append(tc.volumeIDs, volID)

	volID = "ebs-vol[1]"
	err = volumeRegister(volID, "csi/input/ebs-volume1.hcl", "create")
	requireNoErrorElseDump(f, err, "could not create volume", tc.pluginJobIDs)
	tc.volumeIDs = append(tc.volumeIDs, volID)
}

func (tc *CSIControllerPluginEBSTest) AfterEach(f *framework.F) {

	// Ensure nodes are all restored
	for _, id := range tc.nodeIDs {
		_, err := e2eutil.Command("nomad", "node", "drain", "-disable", "-yes", id)
		f.Assert().NoError(err)
		_, err = e2eutil.Command("nomad", "node", "eligibility", "-enable", id)
		f.Assert().NoError(err)
	}
	tc.nodeIDs = []string{}

	// Stop all jobs in test
	for _, id := range tc.testJobIDs {
		err := e2eutil.StopJob(id, "-purge")
		f.Assert().NoError(err)
	}
	tc.testJobIDs = []string{}

	// Garbage collect
	out, err := e2eutil.Command("nomad", "system", "gc")
	f.Assert().NoError(err, out)
}

// AfterAll cleans up the volumes and plugin jobs created by the test.
func (tc *CSIControllerPluginEBSTest) AfterAll(f *framework.F) {

	for _, volID := range tc.volumeIDs {
		err := waitForVolumeClaimRelease(volID, reapWait)
		f.Assert().NoError(err, "volume claims were not released")

		out, err := e2eutil.Command("nomad", "volume", "delete", volID)
		assertNoErrorElseDump(f, err,
			fmt.Sprintf("could not delete volume:\n%v", out), tc.pluginJobIDs)
	}

	// Deregister all plugin jobs in test
	for _, id := range tc.pluginJobIDs {
		err := e2eutil.StopJob(id, "-purge")
		f.Assert().NoError(err)
	}
	tc.pluginJobIDs = []string{}

	// Garbage collect
	out, err := e2eutil.Command("nomad", "system", "gc")
	f.Assert().NoError(err, out)

}

// TestVolumeClaim exercises the volume publish/unpublish workflows for the
// EBS plugin.
func (tc *CSIControllerPluginEBSTest) TestVolumeClaim(f *framework.F) {
	nomadClient := tc.Nomad()

	// deploy a job that writes to the volume
	writeJobID := "write-ebs-" + tc.uuid
	f.NoError(e2eutil.Register(writeJobID, "csi/input/use-ebs-volume.nomad"))
	f.NoError(
		e2eutil.WaitForAllocStatusExpected(writeJobID, ns, []string{"running"}),
		"job should be running")

	allocs, err := e2eutil.AllocsForJob(writeJobID, ns)
	f.NoError(err, "could not get allocs for write job")
	f.Len(allocs, 1, "could not get allocs for write job")
	writeAllocID := allocs[0]["ID"]

	// read data from volume and assert the writer wrote a file to it
	expectedPath := "/task/test/" + writeAllocID
	_, err = readFile(nomadClient, writeAllocID, expectedPath)
	f.NoError(err)

	// Shutdown (and purge) the writer so we can run a reader.
	// we could mount the EBS volume with multi-attach, but we
	// want this test to exercise the unpublish workflow.
	err = e2eutil.StopJob(writeJobID, "-purge")
	f.NoError(err)

	// wait for the volume unpublish workflow to complete
	for _, volID := range tc.volumeIDs {
		err := waitForVolumeClaimRelease(volID, reapWait)
		f.NoError(err, "volume claims were not released")
	}

	// deploy a job so we can read from the volume
	readJobID := "read-ebs-" + tc.uuid
	tc.testJobIDs = append(tc.testJobIDs, readJobID) // ensure failed tests clean up
	f.NoError(e2eutil.Register(readJobID, "csi/input/use-ebs-volume.nomad"))
	f.NoError(
		e2eutil.WaitForAllocStatusExpected(readJobID, ns, []string{"running"}),
		"job should be running")

	allocs, err = e2eutil.AllocsForJob(readJobID, ns)
	f.NoError(err, "could not get allocs for read job")
	f.Len(allocs, 1, "could not get allocs for read job")
	readAllocID := allocs[0]["ID"]

	// read data from volume and assert we can read the file the writer wrote
	expectedPath = "/task/test/" + readAllocID
	_, err = readFile(nomadClient, readAllocID, expectedPath)
	f.NoError(err)
}

// TestSnapshot exercises the snapshot commands.
func (tc *CSIControllerPluginEBSTest) TestSnapshot(f *framework.F) {

	out, err := e2eutil.Command("nomad", "volume", "snapshot", "create",
		tc.volumeIDs[0], "snap-"+tc.uuid)
	requireNoErrorElseDump(f, err, "could not create volume snapshot", tc.pluginJobIDs)

	snaps, err := e2eutil.ParseColumns(out)

	defer func() {
		_, err := e2eutil.Command("nomad", "volume", "snapshot", "delete",
			ebsPluginID, snaps[0]["Snapshot ID"])
		requireNoErrorElseDump(f, err, "could not delete volume snapshot", tc.pluginJobIDs)
	}()

	f.NoError(err, fmt.Sprintf("could not parse output:\n%v", out))
	f.Len(snaps, 1, fmt.Sprintf("could not parse output:\n%v", out))

	// the snapshot we're looking for should be the first one because
	// we just created it, but give us some breathing room to allow
	// for concurrent test runs
	out, err = e2eutil.Command("nomad", "volume", "snapshot", "list",
		"-plugin", ebsPluginID, "-per-page", "10")
	requireNoErrorElseDump(f, err, "could not list volume snapshots", tc.pluginJobIDs)
	f.Contains(out, snaps[0]["ID"],
		fmt.Sprintf("volume snapshot list did not include expected snapshot:\n%v", out))
}

// TestNodeDrain exercises the remounting behavior in the face of a node drain
func (tc *CSIControllerPluginEBSTest) TestNodeDrain(f *framework.F) {

	nomadClient := tc.Nomad()

	nodesJobID := "aws-ebs-plugin-nodes-" + tc.uuid
	pluginAllocs, err := e2eutil.AllocsForJob(nodesJobID, ns)
	f.NoError(err)
	expectedHealthyNodePlugins := len(pluginAllocs)

	// deploy a job that writes to the volume
	writeJobID := "write-ebs-for-drain" + tc.uuid
	f.NoError(e2eutil.Register(writeJobID, "csi/input/use-ebs-volume.nomad"))
	f.NoError(
		e2eutil.WaitForAllocStatusExpected(writeJobID, ns, []string{"running"}),
		"job should be running")
	tc.testJobIDs = append(tc.testJobIDs, writeJobID) // ensure failed tests clean up

	allocs, err := e2eutil.AllocsForJob(writeJobID, ns)
	f.NoError(err, "could not get allocs for write job")
	f.Len(allocs, 1, "could not get allocs for write job")
	writeAllocID := allocs[0]["ID"]

	// read data from volume and assert the writer wrote a file to it
	expectedPath := "/task/test/" + writeAllocID
	_, err = readFile(nomadClient, writeAllocID, expectedPath)
	f.NoError(err)

	// intentionally set a long deadline so we can check the plugins
	// haven't been moved
	nodeID := allocs[0]["Node ID"]
	out, err := e2eutil.Command("nomad", "node",
		"drain", "-enable",
		"-deadline", "10m",
		"-yes", "-detach", nodeID)
	f.NoError(err, fmt.Sprintf("'nomad node drain' failed: %v\n%v", err, out))
	tc.nodeIDs = append(tc.nodeIDs, nodeID)

	wc := &e2eutil.WaitConfig{}
	interval, retries := wc.OrDefault()
	testutil.WaitForResultRetries(retries, func() (bool, error) {
		time.Sleep(interval)
		allocs, err := e2eutil.AllocsForJob(writeJobID, ns)
		if err != nil {
			return false, err
		}
		for _, alloc := range allocs {
			if alloc["ID"] != writeAllocID {
				if alloc["Status"] == "running" {
					return true, nil
				}
				if alloc["Status"] == "failed" {
					// no point in waiting anymore if we hit this case
					f.T().Fatal("expected replacement alloc not to fail")
				}
			}
		}
		return false, fmt.Errorf("expected replacement alloc to be running")
	}, func(e error) {
		err = e
	})

	pluginAllocs, err = e2eutil.AllocsForJob(nodesJobID, ns)
	f.Lenf(pluginAllocs, expectedHealthyNodePlugins,
		"expected node plugins to be unchanged, got: %v", pluginAllocs)
}
