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

package allocrunner

import (
	"bytes"
	"context"
	"fmt"
	"net"
	"path/filepath"
	"sync"
	"testing"

	"github.com/hashicorp/nomad/ci"
	"github.com/hashicorp/nomad/client/allocdir"
	"github.com/hashicorp/nomad/client/allocrunner/interfaces"
	"github.com/hashicorp/nomad/helper/testlog"
	"github.com/hashicorp/nomad/nomad/mock"
	"github.com/hashicorp/nomad/nomad/structs/config"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

// TestConsulGRPCSocketHook_PrerunPostrun_Ok asserts that a proxy is started when the
// Consul unix socket hook's Prerun method is called and stopped with the
// Postrun method is called.
func TestConsulGRPCSocketHook_PrerunPostrun_Ok(t *testing.T) {
	ci.Parallel(t)

	// As of Consul 1.6.0 the test server does not support the gRPC
	// endpoint so we have to fake it.
	fakeConsul, err := net.Listen("tcp", "127.0.0.1:0")
	require.NoError(t, err)
	defer fakeConsul.Close()
	consulConfig := &config.ConsulConfig{
		GRPCAddr: fakeConsul.Addr().String(),
	}

	alloc := mock.ConnectAlloc()

	logger := testlog.HCLogger(t)

	allocDir, cleanup := allocdir.TestAllocDir(t, logger, "EnvoyBootstrap", alloc.ID)
	defer cleanup()

	// Start the unix socket proxy
	h := newConsulGRPCSocketHook(logger, alloc, allocDir, consulConfig, map[string]string{})
	require.NoError(t, h.Prerun())

	gRPCSock := filepath.Join(allocDir.AllocDir, allocdir.AllocGRPCSocket)
	envoyConn, err := net.Dial("unix", gRPCSock)
	require.NoError(t, err)

	// Write to Consul to ensure data is proxied out of the netns
	input := bytes.Repeat([]byte{'X'}, 5*1024)
	errCh := make(chan error, 1)
	go func() {
		_, err := envoyConn.Write(input)
		errCh <- err
	}()

	// Accept the connection from the netns
	consulConn, err := fakeConsul.Accept()
	require.NoError(t, err)
	defer consulConn.Close()

	output := make([]byte, len(input))
	_, err = consulConn.Read(output)
	require.NoError(t, err)
	require.NoError(t, <-errCh)
	require.Equal(t, input, output)

	// Read from Consul to ensure data is proxied into the netns
	input = bytes.Repeat([]byte{'Y'}, 5*1024)
	go func() {
		_, err := consulConn.Write(input)
		errCh <- err
	}()

	_, err = envoyConn.Read(output)
	require.NoError(t, err)
	require.NoError(t, <-errCh)
	require.Equal(t, input, output)

	// Stop the unix socket proxy
	require.NoError(t, h.Postrun())

	// Consul reads should error
	n, err := consulConn.Read(output)
	require.Error(t, err)
	require.Zero(t, n)

	// Envoy reads and writes should error
	n, err = envoyConn.Write(input)
	require.Error(t, err)
	require.Zero(t, n)
	n, err = envoyConn.Read(output)
	require.Error(t, err)
	require.Zero(t, n)
}

// TestConsulGRPCSocketHook_Prerun_Error asserts that invalid Consul addresses cause
// Prerun to return an error if the alloc requires a grpc proxy.
func TestConsulGRPCSocketHook_Prerun_Error(t *testing.T) {
	ci.Parallel(t)

	logger := testlog.HCLogger(t)

	// A config without an Addr or GRPCAddr is invalid.
	consulConfig := &config.ConsulConfig{}

	alloc := mock.Alloc()
	connectAlloc := mock.ConnectAlloc()

	allocDir, cleanup := allocdir.TestAllocDir(t, logger, "EnvoyBootstrap", alloc.ID)
	defer cleanup()

	{
		// An alloc without a Connect proxy sidecar should not return
		// an error.
		h := newConsulGRPCSocketHook(logger, alloc, allocDir, consulConfig, map[string]string{})
		require.NoError(t, h.Prerun())

		// Postrun should be a noop
		require.NoError(t, h.Postrun())
	}

	{
		// An alloc *with* a Connect proxy sidecar *should* return an error
		// when Consul is not configured.
		h := newConsulGRPCSocketHook(logger, connectAlloc, allocDir, consulConfig, map[string]string{})
		require.EqualError(t, h.Prerun(), "consul address must be set on nomad client")

		// Postrun should be a noop
		require.NoError(t, h.Postrun())
	}

	{
		// Updating an alloc without a sidecar to have a sidecar should
		// error when the sidecar is added.
		h := newConsulGRPCSocketHook(logger, alloc, allocDir, consulConfig, map[string]string{})
		require.NoError(t, h.Prerun())

		req := &interfaces.RunnerUpdateRequest{
			Alloc: connectAlloc,
		}
		require.EqualError(t, h.Update(req), "consul address must be set on nomad client")

		// Postrun should be a noop
		require.NoError(t, h.Postrun())
	}
}

// TestConsulGRPCSocketHook_proxy_Unix asserts that the destination can be a unix
// socket path.
func TestConsulGRPCSocketHook_proxy_Unix(t *testing.T) {
	ci.Parallel(t)

	dir := t.TempDir()

	// Setup fake listener that would be inside the netns (normally a unix
	// socket, but it doesn't matter for this test).
	src, err := net.Listen("tcp", "127.0.0.1:0")
	require.NoError(t, err)
	defer src.Close()

	// Setup fake listener that would be Consul outside the netns. Use a
	// socket as Consul may be configured to listen on a unix socket.
	destFn := filepath.Join(dir, "fakeconsul.sock")
	dest, err := net.Listen("unix", destFn)
	require.NoError(t, err)
	defer dest.Close()

	// Collect errors (must have len > goroutines)
	errCh := make(chan error, 10)

	// Block until completion
	wg := sync.WaitGroup{}

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	wg.Add(1)
	go func() {
		defer wg.Done()
		proxy(ctx, testlog.HCLogger(t), "unix://"+destFn, src)
	}()

	// Fake Envoy
	// Connect and write to the src (netns) side of the proxy; then read
	// and exit.
	wg.Add(1)
	go func() {
		defer func() {
			// Cancel after final read has completed (or an error
			// has occurred)
			cancel()

			wg.Done()
		}()

		addr := src.Addr()
		conn, err := net.Dial(addr.Network(), addr.String())
		if err != nil {
			errCh <- err
			return
		}

		defer conn.Close()

		if _, err := conn.Write([]byte{'X'}); err != nil {
			errCh <- err
			return
		}

		recv := make([]byte, 1)
		if _, err := conn.Read(recv); err != nil {
			errCh <- err
			return
		}

		if expected := byte('Y'); recv[0] != expected {
			errCh <- fmt.Errorf("expected %q but received: %q", expected, recv[0])
			return
		}
	}()

	// Fake Consul on a unix socket
	// Listen, receive 1 byte, write a response, and exit
	wg.Add(1)
	go func() {
		defer wg.Done()

		conn, err := dest.Accept()
		if err != nil {
			errCh <- err
			return
		}

		// Close listener now. No more connections expected.
		if err := dest.Close(); err != nil {
			errCh <- err
			return
		}

		defer conn.Close()

		recv := make([]byte, 1)
		if _, err := conn.Read(recv); err != nil {
			errCh <- err
			return
		}

		if expected := byte('X'); recv[0] != expected {
			errCh <- fmt.Errorf("expected %q but received: %q", expected, recv[0])
			return
		}

		if _, err := conn.Write([]byte{'Y'}); err != nil {
			errCh <- err
			return
		}
	}()

	// Wait for goroutines to complete
	wg.Wait()

	// Make sure no errors occurred
	for len(errCh) > 0 {
		assert.NoError(t, <-errCh)
	}
}
