github.com/NVIDIA/aistore@v1.3.23-0.20240517131212-7df6609be51d/ais/test/etl_test.go (about)

     1  // Package integration_test.
     2  /*
     3   * Copyright (c) 2018-2024, NVIDIA CORPORATION. All rights reserved.
     4   */
     5  package integration_test
     6  
     7  import (
     8  	"bytes"
     9  	"fmt"
    10  	"io"
    11  	"net/http"
    12  	"net/url"
    13  	"os"
    14  	"os/exec"
    15  	"path/filepath"
    16  	"reflect"
    17  	"regexp"
    18  	"sort"
    19  	"strings"
    20  	"testing"
    21  	"time"
    22  
    23  	"github.com/NVIDIA/aistore/api"
    24  	"github.com/NVIDIA/aistore/api/apc"
    25  	"github.com/NVIDIA/aistore/cmn"
    26  	"github.com/NVIDIA/aistore/cmn/cos"
    27  	"github.com/NVIDIA/aistore/cmn/debug"
    28  	"github.com/NVIDIA/aistore/core/meta"
    29  	"github.com/NVIDIA/aistore/ext/etl"
    30  	"github.com/NVIDIA/aistore/ext/etl/runtime"
    31  	"github.com/NVIDIA/aistore/memsys"
    32  	"github.com/NVIDIA/aistore/tools"
    33  	"github.com/NVIDIA/aistore/tools/cryptorand"
    34  	"github.com/NVIDIA/aistore/tools/readers"
    35  	"github.com/NVIDIA/aistore/tools/tassert"
    36  	"github.com/NVIDIA/aistore/tools/tetl"
    37  	"github.com/NVIDIA/aistore/tools/tlog"
    38  	"github.com/NVIDIA/aistore/tools/trand"
    39  	"github.com/NVIDIA/aistore/xact"
    40  	"github.com/NVIDIA/go-tfdata/tfdata/core"
    41  )
    42  
    43  const (
    44  	tar2tfIn  = "data/small-mnist-3.tar"
    45  	tar2tfOut = "data/small-mnist-3.record"
    46  
    47  	tar2tfFiltersIn  = "data/single-png-cls.tar"
    48  	tar2tfFiltersOut = "data/single-png-cls-transformed.tfrecord"
    49  )
    50  
    51  type (
    52  	transformFunc  func(r io.Reader) io.Reader
    53  	filesEqualFunc func(f1, f2 string) (bool, error)
    54  
    55  	testObjConfig struct {
    56  		transformer string
    57  		comm        string
    58  		inPath      string         // optional
    59  		outPath     string         // optional
    60  		transform   transformFunc  // optional
    61  		filesEqual  filesEqualFunc // optional
    62  		onlyLong    bool           // run only with long tests
    63  	}
    64  
    65  	testCloudObjConfig struct {
    66  		cached   bool
    67  		onlyLong bool
    68  	}
    69  )
    70  
    71  func (tc *testObjConfig) Name() string {
    72  	return fmt.Sprintf("%s/%s", tc.transformer, strings.TrimSuffix(tc.comm, "://"))
    73  }
    74  
    75  // TODO: This should be a part of go-tfdata.
    76  // This function is necessary, as the same TFRecords can be different byte-wise.
    77  // This is caused by the fact that order of TFExamples is can de different,
    78  // as well as ordering of elements of a single TFExample can be different.
    79  func tfDataEqual(n1, n2 string) (bool, error) {
    80  	examples1, err := readExamples(n1)
    81  	if err != nil {
    82  		return false, err
    83  	}
    84  	examples2, err := readExamples(n2)
    85  	if err != nil {
    86  		return false, err
    87  	}
    88  
    89  	if len(examples1) != len(examples2) {
    90  		return false, nil
    91  	}
    92  	return tfRecordsEqual(examples1, examples2)
    93  }
    94  
    95  func tfRecordsEqual(examples1, examples2 []*core.TFExample) (bool, error) {
    96  	sort.SliceStable(examples1, func(i, j int) bool {
    97  		return examples1[i].GetFeature("__key__").String() < examples1[j].GetFeature("__key__").String()
    98  	})
    99  	sort.SliceStable(examples2, func(i, j int) bool {
   100  		return examples2[i].GetFeature("__key__").String() < examples2[j].GetFeature("__key__").String()
   101  	})
   102  
   103  	for i := range len(examples1) {
   104  		if !reflect.DeepEqual(examples1[i].ProtoReflect(), examples2[i].ProtoReflect()) {
   105  			return false, nil
   106  		}
   107  	}
   108  	return true, nil
   109  }
   110  
   111  func readExamples(fileName string) (examples []*core.TFExample, err error) {
   112  	f, err := os.Open(fileName)
   113  	if err != nil {
   114  		return nil, err
   115  	}
   116  	defer f.Close()
   117  	return core.NewTFRecordReader(f).ReadAllExamples()
   118  }
   119  
   120  func testETLObject(t *testing.T, etlName, inPath, outPath string, fTransform transformFunc, fEq filesEqualFunc) {
   121  	var (
   122  		inputFilePath          string
   123  		expectedOutputFilePath string
   124  
   125  		proxyURL   = tools.RandomProxyURL(t)
   126  		baseParams = tools.BaseAPIParams(proxyURL)
   127  
   128  		bck            = cmn.Bck{Provider: apc.AIS, Name: "etl-test"}
   129  		objName        = fmt.Sprintf("%s-%s-object", etlName, trand.String(5))
   130  		outputFileName = filepath.Join(t.TempDir(), objName+".out")
   131  	)
   132  
   133  	buf := make([]byte, 256)
   134  	_, err := cryptorand.Read(buf)
   135  	tassert.CheckFatal(t, err)
   136  	r := bytes.NewReader(buf)
   137  
   138  	if inPath != "" {
   139  		inputFilePath = inPath
   140  	} else {
   141  		inputFilePath = tools.CreateFileFromReader(t, "object.in", r)
   142  	}
   143  	if outPath != "" {
   144  		expectedOutputFilePath = outPath
   145  	} else {
   146  		_, err := r.Seek(0, io.SeekStart)
   147  		tassert.CheckFatal(t, err)
   148  
   149  		r := fTransform(r)
   150  		expectedOutputFilePath = tools.CreateFileFromReader(t, "object.out", r)
   151  	}
   152  
   153  	tools.CreateBucket(t, proxyURL, bck, nil, true /*cleanup*/)
   154  
   155  	tlog.Logln("PUT object")
   156  	reader, err := readers.NewExistingFile(inputFilePath, cos.ChecksumNone)
   157  	tassert.CheckFatal(t, err)
   158  	tools.PutObject(t, bck, objName, reader)
   159  
   160  	fho, err := cos.CreateFile(outputFileName)
   161  	tassert.CheckFatal(t, err)
   162  	defer fho.Close()
   163  
   164  	tlog.Logf("GET %s via etl[%s]\n", bck.Cname(objName), etlName)
   165  	err = api.ETLObject(baseParams, etlName, bck, objName, fho)
   166  	tassert.CheckFatal(t, err)
   167  
   168  	tlog.Logln("Compare output")
   169  	same, err := fEq(outputFileName, expectedOutputFilePath)
   170  	tassert.CheckError(t, err)
   171  	tassert.Errorf(t, same, "file contents after transformation differ")
   172  }
   173  
   174  func testETLObjectCloud(t *testing.T, bck cmn.Bck, etlName string, onlyLong, cached bool) {
   175  	var (
   176  		proxyURL   = tools.RandomProxyURL(t)
   177  		baseParams = tools.BaseAPIParams(proxyURL)
   178  	)
   179  
   180  	tools.CheckSkip(t, &tools.SkipTestArgs{Long: onlyLong})
   181  
   182  	// TODO: PUT and then transform many objects
   183  
   184  	objName := fmt.Sprintf("%s-%s-object", etlName, trand.String(5))
   185  	tlog.Logln("PUT object")
   186  	reader, err := readers.NewRand(cos.KiB, cos.ChecksumNone)
   187  	tassert.CheckFatal(t, err)
   188  
   189  	_, err = api.PutObject(&api.PutArgs{
   190  		BaseParams: baseParams,
   191  		Bck:        bck,
   192  		ObjName:    objName,
   193  		Reader:     reader,
   194  	})
   195  	tassert.CheckFatal(t, err)
   196  
   197  	if !cached {
   198  		tlog.Logf("Evicting object %s\n", bck.Cname(objName))
   199  		err := api.EvictObject(baseParams, bck, objName)
   200  		tassert.CheckFatal(t, err)
   201  	}
   202  
   203  	defer func() {
   204  		// Could bucket is not destroyed, remove created object instead.
   205  		err := api.DeleteObject(baseParams, bck, objName)
   206  		tassert.CheckError(t, err)
   207  	}()
   208  
   209  	bf := bytes.NewBuffer(nil)
   210  	tlog.Logf("Use ETL[%s] to read transformed object\n", etlName)
   211  	err = api.ETLObject(baseParams, etlName, bck, objName, bf)
   212  	tassert.CheckFatal(t, err)
   213  	tassert.Errorf(t, bf.Len() == cos.KiB, "Expected %d bytes, got %d", cos.KiB, bf.Len())
   214  }
   215  
   216  // NOTE: BytesCount references number of bytes *before* the transformation.
   217  func checkETLStats(t *testing.T, xid string, expectedObjCnt int, expectedBytesCnt uint64, skipByteStats bool) {
   218  	snaps, err := api.QueryXactionSnaps(baseParams, &xact.ArgsMsg{ID: xid})
   219  	tassert.CheckFatal(t, err)
   220  
   221  	objs, outObjs, inObjs := snaps.ObjCounts(xid)
   222  
   223  	tassert.Errorf(t, objs == int64(expectedObjCnt), "expected %d objects, got %d (where sent %d, received %d)",
   224  		expectedObjCnt, objs, outObjs, inObjs)
   225  	if outObjs != inObjs {
   226  		tlog.Logf("Warning: (sent objects) %d != %d (received objects)\n", outObjs, inObjs)
   227  	} else {
   228  		tlog.Logf("Num sent/received objects: %d\n", outObjs)
   229  	}
   230  
   231  	if skipByteStats {
   232  		return // don't know the size
   233  	}
   234  	bytes, outBytes, inBytes := snaps.ByteCounts(xid)
   235  
   236  	// TODO -- FIXME: validate transformed bytes as well, make sure `expectedBytesCnt` is correct
   237  
   238  	tlog.Logf("Byte counts: expected %d, got (original %d, sent %d, received %d)\n", expectedBytesCnt,
   239  		bytes, outBytes, inBytes)
   240  }
   241  
   242  func TestETLObject(t *testing.T) {
   243  	tools.CheckSkip(t, &tools.SkipTestArgs{RequiredDeployment: tools.ClusterTypeK8s})
   244  	tetl.CheckNoRunningETLContainers(t, baseParams)
   245  
   246  	noopTransform := func(r io.Reader) io.Reader { return r }
   247  	tests := []testObjConfig{
   248  		{transformer: tetl.Echo, comm: etl.Hpull, transform: noopTransform, filesEqual: tools.FilesEqual, onlyLong: true},
   249  		{transformer: tetl.Echo, comm: etl.Hrev, transform: noopTransform, filesEqual: tools.FilesEqual, onlyLong: true},
   250  		{transformer: tetl.Echo, comm: etl.Hpush, transform: noopTransform, filesEqual: tools.FilesEqual, onlyLong: true},
   251  		{tetl.Tar2TF, etl.Hpull, tar2tfIn, tar2tfOut, nil, tfDataEqual, true},
   252  		{tetl.Tar2TF, etl.Hrev, tar2tfIn, tar2tfOut, nil, tfDataEqual, true},
   253  		{tetl.Tar2TF, etl.Hpush, tar2tfIn, tar2tfOut, nil, tfDataEqual, true},
   254  		{tetl.Tar2tfFilters, etl.Hpull, tar2tfFiltersIn, tar2tfFiltersOut, nil, tfDataEqual, false},
   255  		{tetl.Tar2tfFilters, etl.Hrev, tar2tfFiltersIn, tar2tfFiltersOut, nil, tfDataEqual, false},
   256  		{tetl.Tar2tfFilters, etl.Hpush, tar2tfFiltersIn, tar2tfFiltersOut, nil, tfDataEqual, false},
   257  	}
   258  
   259  	for _, test := range tests {
   260  		t.Run(test.Name(), func(t *testing.T) {
   261  			tools.CheckSkip(t, &tools.SkipTestArgs{Long: test.onlyLong})
   262  
   263  			_ = tetl.InitSpec(t, baseParams, test.transformer, test.comm)
   264  			t.Cleanup(func() { tetl.StopAndDeleteETL(t, baseParams, test.transformer) })
   265  
   266  			testETLObject(t, test.transformer, test.inPath, test.outPath, test.transform, test.filesEqual)
   267  		})
   268  	}
   269  }
   270  
   271  func TestETLObjectCloud(t *testing.T) {
   272  	tools.CheckSkip(t, &tools.SkipTestArgs{Bck: cliBck, RequiredDeployment: tools.ClusterTypeK8s, RemoteBck: true})
   273  	tetl.CheckNoRunningETLContainers(t, baseParams)
   274  
   275  	tcs := map[string][]*testCloudObjConfig{
   276  		etl.Hpull: {
   277  			{cached: true, onlyLong: false},
   278  			{cached: false, onlyLong: false},
   279  		},
   280  		etl.Hrev: {
   281  			{cached: true, onlyLong: false},
   282  			{cached: false, onlyLong: false},
   283  		},
   284  		etl.Hpush: {
   285  			{cached: true, onlyLong: false},
   286  			{cached: false, onlyLong: false},
   287  		},
   288  	}
   289  
   290  	for comm, configs := range tcs {
   291  		t.Run(comm, func(t *testing.T) {
   292  			// TODO: currently, Echo transformation only - add other transforms
   293  			_ = tetl.InitSpec(t, baseParams, tetl.Echo, comm)
   294  			t.Cleanup(func() { tetl.StopAndDeleteETL(t, baseParams, tetl.Echo) })
   295  
   296  			for _, conf := range configs {
   297  				t.Run(fmt.Sprintf("cached=%t", conf.cached), func(t *testing.T) {
   298  					testETLObjectCloud(t, cliBck, tetl.Echo, conf.onlyLong, conf.cached)
   299  				})
   300  			}
   301  		})
   302  	}
   303  }
   304  
   305  // TODO: initial impl - revise and add many more tests
   306  func TestETLInline(t *testing.T) {
   307  	tools.CheckSkip(t, &tools.SkipTestArgs{RequiredDeployment: tools.ClusterTypeK8s})
   308  	tetl.CheckNoRunningETLContainers(t, baseParams)
   309  
   310  	var (
   311  		proxyURL   = tools.RandomProxyURL(t)
   312  		baseParams = tools.BaseAPIParams(proxyURL)
   313  
   314  		bck = cmn.Bck{Provider: apc.AIS, Name: "etl-test"}
   315  
   316  		tests = []testObjConfig{
   317  			{transformer: tetl.MD5, comm: etl.Hpush},
   318  		}
   319  	)
   320  
   321  	for _, test := range tests {
   322  		t.Run(test.Name(), func(t *testing.T) {
   323  			tools.CheckSkip(t, &tools.SkipTestArgs{Long: test.onlyLong})
   324  
   325  			_ = tetl.InitSpec(t, baseParams, test.transformer, test.comm)
   326  			t.Cleanup(func() { tetl.StopAndDeleteETL(t, baseParams, test.transformer) })
   327  
   328  			tools.CreateBucket(t, proxyURL, bck, nil, true /*cleanup*/)
   329  
   330  			tlog.Logln("PUT object")
   331  			objNames, _, err := tools.PutRandObjs(tools.PutObjectsArgs{
   332  				ProxyURL: proxyURL,
   333  				Bck:      bck,
   334  				ObjCnt:   1,
   335  				ObjSize:  cos.MiB,
   336  			})
   337  			tassert.CheckFatal(t, err)
   338  			objName := objNames[0]
   339  
   340  			tlog.Logln("GET transformed object")
   341  			outObject := bytes.NewBuffer(nil)
   342  			_, err = api.GetObject(baseParams, bck, objName, &api.GetArgs{
   343  				Writer: outObject,
   344  				Query:  url.Values{apc.QparamETLName: {test.transformer}},
   345  			})
   346  			tassert.CheckFatal(t, err)
   347  
   348  			matchesMD5 := regexp.MustCompile("^[a-fA-F0-9]{32}$").MatchReader(outObject)
   349  			tassert.Fatalf(t, matchesMD5, "expected transformed object to be md5 checksum")
   350  		})
   351  	}
   352  }
   353  
   354  func TestETLInlineMD5SingleObj(t *testing.T) {
   355  	var (
   356  		proxyURL   = tools.RandomProxyURL(t)
   357  		baseParams = tools.BaseAPIParams(proxyURL)
   358  
   359  		bck         = cmn.Bck{Provider: apc.AIS, Name: "etl-test"}
   360  		transformer = tetl.MD5
   361  		comm        = etl.Hpush
   362  	)
   363  	tools.CheckSkip(t, &tools.SkipTestArgs{RequiredDeployment: tools.ClusterTypeK8s})
   364  	tetl.CheckNoRunningETLContainers(t, baseParams)
   365  
   366  	_ = tetl.InitSpec(t, baseParams, transformer, comm)
   367  	t.Cleanup(func() { tetl.StopAndDeleteETL(t, baseParams, transformer) })
   368  
   369  	tools.CreateBucket(t, proxyURL, bck, nil, true /*cleanup*/)
   370  
   371  	tlog.Logln("PUT object")
   372  	objName := trand.String(10)
   373  	reader, err := readers.NewRand(cos.MiB, cos.ChecksumMD5)
   374  	tassert.CheckFatal(t, err)
   375  
   376  	_, err = api.PutObject(&api.PutArgs{
   377  		BaseParams: baseParams,
   378  		Bck:        bck,
   379  		ObjName:    objName,
   380  		Reader:     reader,
   381  	})
   382  	tassert.CheckFatal(t, err)
   383  
   384  	tlog.Logln("GET transformed object")
   385  	outObject := memsys.PageMM().NewSGL(0)
   386  	defer outObject.Free()
   387  
   388  	_, err = api.GetObject(baseParams, bck, objName, &api.GetArgs{
   389  		Writer: outObject,
   390  		Query:  url.Values{apc.QparamETLName: {transformer}},
   391  	})
   392  	tassert.CheckFatal(t, err)
   393  
   394  	exp, got := reader.Cksum().Val(), string(outObject.Bytes())
   395  	tassert.Errorf(t, exp == got, "expected transformed object to be md5 checksum %s, got %s", exp,
   396  		got[:min(len(got), 16)])
   397  }
   398  
   399  func TestETLAnyToAnyBucket(t *testing.T) {
   400  	tools.CheckSkip(t, &tools.SkipTestArgs{RequiredDeployment: tools.ClusterTypeK8s})
   401  	tetl.CheckNoRunningETLContainers(t, baseParams)
   402  
   403  	var (
   404  		proxyURL   = tools.RandomProxyURL(t)
   405  		baseParams = tools.BaseAPIParams(proxyURL)
   406  		objCnt     = 100
   407  
   408  		bcktests = []struct {
   409  			srcRemote      bool
   410  			evictRemoteSrc bool
   411  			dstRemote      bool
   412  		}{
   413  			{false, false, false},
   414  			{true, false, false},
   415  			{true, true, false},
   416  			{false, false, true},
   417  		}
   418  		tests = []testObjConfig{
   419  			{transformer: tetl.Echo, comm: etl.Hpull, onlyLong: true},
   420  			{transformer: tetl.MD5, comm: etl.Hrev},
   421  			{transformer: tetl.MD5, comm: etl.Hpush, onlyLong: true},
   422  		}
   423  	)
   424  
   425  	for _, bcktest := range bcktests {
   426  		m := ioContext{
   427  			t:         t,
   428  			num:       objCnt,
   429  			fileSize:  512,
   430  			fixedSize: true, // see checkETLStats below
   431  		}
   432  		if bcktest.srcRemote {
   433  			m.bck = cliBck
   434  			m.deleteRemoteBckObjs = true
   435  		} else {
   436  			m.bck = cmn.Bck{Name: "etlsrc_" + cos.GenTie(), Provider: apc.AIS}
   437  			tools.CreateBucket(t, proxyURL, m.bck, nil, true /*cleanup*/)
   438  		}
   439  		m.init(true /*cleanup*/)
   440  
   441  		if bcktest.srcRemote {
   442  			m.remotePuts(false) // (deleteRemoteBckObjs above)
   443  			if bcktest.evictRemoteSrc {
   444  				tlog.Logf("evicting %s\n", m.bck)
   445  				//
   446  				// evict all _cached_ data from the "local" cluster
   447  				// keep the src bucket in the "local" BMD though
   448  				//
   449  				err := api.EvictRemoteBucket(baseParams, m.bck, true /*keep empty src bucket in the BMD*/)
   450  				tassert.CheckFatal(t, err)
   451  			}
   452  		} else {
   453  			m.puts()
   454  		}
   455  
   456  		for _, test := range tests {
   457  			// NOTE: have to use one of the predefined etlName which, by coincidence,
   458  			// corresponds to the test.transformer name and is further used to resolve
   459  			// the corresponding init-spec yaml, e.g.:
   460  			// https://raw.githubusercontent.com/NVIDIA/ais-etl/master/transformers/md5/pod.yaml"
   461  			// See also: tetl.validateETLName
   462  			etlName := test.transformer
   463  
   464  			tname := fmt.Sprintf("%s-%s", test.transformer, strings.TrimSuffix(test.comm, "://"))
   465  			if bcktest.srcRemote {
   466  				if bcktest.evictRemoteSrc {
   467  					tname += "/from-evicted-remote"
   468  				} else {
   469  					tname += "/from-remote"
   470  				}
   471  			} else {
   472  				debug.Assert(!bcktest.evictRemoteSrc)
   473  				tname += "/from-ais"
   474  			}
   475  			if bcktest.dstRemote {
   476  				tname += "/to-remote"
   477  			} else {
   478  				tname += "/to-ais"
   479  			}
   480  			t.Run(tname, func(t *testing.T) {
   481  				tools.CheckSkip(t, &tools.SkipTestArgs{Long: test.onlyLong})
   482  				_ = tetl.InitSpec(t, baseParams, etlName, test.comm)
   483  
   484  				var bckTo cmn.Bck
   485  				if bcktest.dstRemote {
   486  					bckTo = cliBck
   487  					dstm := ioContext{t: t, bck: bckTo}
   488  					dstm.del()
   489  					t.Cleanup(func() { dstm.del() })
   490  				} else {
   491  					bckTo = cmn.Bck{Name: "etldst_" + cos.GenTie(), Provider: apc.AIS}
   492  					// NOTE: ais will create dst bucket on the fly
   493  
   494  					t.Cleanup(func() { tools.DestroyBucket(t, proxyURL, bckTo) })
   495  				}
   496  				testETLBucket(t, baseParams, etlName, &m, bckTo, time.Minute, false, bcktest.evictRemoteSrc)
   497  			})
   498  		}
   499  	}
   500  }
   501  
   502  // also responsible for cleanup: ETL xaction, ETL containers, destination bucket.
   503  func testETLBucket(t *testing.T, bp api.BaseParams, etlName string, m *ioContext, bckTo cmn.Bck, timeout time.Duration,
   504  	skipByteStats, evictRemoteSrc bool) {
   505  	var (
   506  		xid, kind      string
   507  		err            error
   508  		bckFrom        = m.bck
   509  		requestTimeout = 30 * time.Second
   510  
   511  		msg = &apc.TCBMsg{
   512  			Transform: apc.Transform{
   513  				Name:    etlName,
   514  				Timeout: cos.Duration(requestTimeout),
   515  			},
   516  			CopyBckMsg: apc.CopyBckMsg{Force: true},
   517  		}
   518  	)
   519  
   520  	t.Cleanup(func() { tetl.StopAndDeleteETL(t, bp, etlName) })
   521  	tlog.Logf("Start ETL[%s]: %s => %s ...\n", etlName, bckFrom.Cname(""), bckTo.Cname(""))
   522  
   523  	if evictRemoteSrc {
   524  		kind = apc.ActETLObjects // TODO -- FIXME: remove/simplify-out the reliance on x-kind
   525  		xid, err = api.ETLBucket(bp, bckFrom, bckTo, msg, apc.FltExists)
   526  	} else {
   527  		kind = apc.ActETLBck
   528  		xid, err = api.ETLBucket(bp, bckFrom, bckTo, msg)
   529  	}
   530  	tassert.CheckFatal(t, err)
   531  
   532  	t.Cleanup(func() {
   533  		if bckTo.IsRemote() {
   534  			err = api.EvictRemoteBucket(bp, bckTo, false /*keep md*/)
   535  			tassert.CheckFatal(t, err)
   536  			tlog.Logf("[cleanup] %s evicted\n", bckTo)
   537  		} else {
   538  			tools.DestroyBucket(t, bp.URL, bckTo)
   539  		}
   540  	})
   541  
   542  	tlog.Logf("ETL[%s]: running %s => %s x-etl[%s]\n", etlName, bckFrom.Cname(""), bckTo.Cname(""), xid)
   543  
   544  	err = tetl.WaitForFinished(bp, xid, kind, timeout)
   545  	tassert.CheckFatal(t, err)
   546  
   547  	list, err := api.ListObjects(bp, bckTo, nil, api.ListArgs{})
   548  	tassert.CheckFatal(t, err)
   549  	tassert.Errorf(t, len(list.Entries) == m.num, "expected %d objects, got %d", m.num, len(list.Entries))
   550  
   551  	checkETLStats(t, xid, m.num, m.fileSize*uint64(m.num), skipByteStats)
   552  }
   553  
   554  func TestETLInitCode(t *testing.T) {
   555  	tools.CheckSkip(t, &tools.SkipTestArgs{RequiredDeployment: tools.ClusterTypeK8s})
   556  	tetl.CheckNoRunningETLContainers(t, baseParams)
   557  
   558  	const (
   559  		md5 = `
   560  import hashlib
   561  
   562  def transform(input_bytes):
   563      md5 = hashlib.md5()
   564      md5.update(input_bytes)
   565      return md5.hexdigest().encode()
   566  `
   567  		echo = `
   568  def transform(reader, w):
   569      for chunk in reader:
   570          w.write(chunk)
   571  `
   572  
   573  		md5IO = `
   574  import hashlib
   575  import sys
   576  
   577  md5 = hashlib.md5()
   578  chunk = sys.stdin.buffer.read()
   579  md5.update(chunk)
   580  sys.stdout.buffer.write(md5.hexdigest().encode())
   581  `
   582  
   583  		numpy = `
   584  import numpy as np
   585  
   586  def transform(input_bytes: bytes) -> bytes:
   587      x = np.array([[0, 1], [2, 3]], dtype='<u2')
   588      return x.tobytes()
   589  `
   590  		numpyDeps = `numpy==1.19.2`
   591  	)
   592  
   593  	var (
   594  		proxyURL   = tools.RandomProxyURL(t)
   595  		baseParams = tools.BaseAPIParams(proxyURL)
   596  
   597  		m = ioContext{
   598  			t:         t,
   599  			num:       10,
   600  			fileSize:  512,
   601  			fixedSize: true,
   602  			bck:       cmn.Bck{Name: "etl_build", Provider: apc.AIS},
   603  		}
   604  
   605  		tests = []struct {
   606  			etlName   string
   607  			code      string
   608  			deps      string
   609  			runtime   string
   610  			commType  string
   611  			chunkSize int64
   612  			onlyLong  bool
   613  		}{
   614  			{etlName: "simple-py38", code: md5, deps: "", runtime: runtime.Py38, onlyLong: false},
   615  			{etlName: "simple-py38-stream", code: echo, deps: "", runtime: runtime.Py38, onlyLong: false, chunkSize: 64},
   616  			{etlName: "with-deps-py38", code: numpy, deps: numpyDeps, runtime: runtime.Py38, onlyLong: false},
   617  			{etlName: "simple-py310-io", code: md5IO, deps: "", runtime: runtime.Py310, commType: etl.HpushStdin, onlyLong: false},
   618  		}
   619  	)
   620  
   621  	tools.CreateBucket(t, proxyURL, m.bck, nil, true /*cleanup*/)
   622  
   623  	m.init(true /*cleanup*/)
   624  
   625  	m.puts()
   626  
   627  	for _, testType := range []string{"etl_object", "etl_bucket"} {
   628  		for _, test := range tests {
   629  			t.Run(testType+"__"+test.etlName, func(t *testing.T) {
   630  				tools.CheckSkip(t, &tools.SkipTestArgs{Long: test.onlyLong})
   631  
   632  				msg := etl.InitCodeMsg{
   633  					InitMsgBase: etl.InitMsgBase{
   634  						IDX:       test.etlName,
   635  						CommTypeX: test.commType,
   636  						Timeout:   etlBucketTimeout,
   637  					},
   638  					Code:      []byte(test.code),
   639  					Deps:      []byte(test.deps),
   640  					Runtime:   test.runtime,
   641  					ChunkSize: test.chunkSize,
   642  				}
   643  				msg.Funcs.Transform = "transform"
   644  
   645  				_ = tetl.InitCode(t, baseParams, &msg)
   646  
   647  				switch testType {
   648  				case "etl_object":
   649  					t.Cleanup(func() { tetl.StopAndDeleteETL(t, baseParams, test.etlName) })
   650  
   651  					testETLObject(t, test.etlName, "", "", func(r io.Reader) io.Reader {
   652  						return r // TODO: Write function to transform input to md5.
   653  					}, func(_, _ string) (bool, error) {
   654  						return true, nil // TODO: Write function to compare output from md5.
   655  					})
   656  				case "etl_bucket":
   657  					bckTo := cmn.Bck{Name: "etldst_" + cos.GenTie(), Provider: apc.AIS}
   658  					testETLBucket(t, baseParams, test.etlName, &m, bckTo, time.Minute,
   659  						false /*skip checking byte counts*/, false /* remote src evicted */)
   660  				default:
   661  					panic(testType)
   662  				}
   663  			})
   664  		}
   665  	}
   666  }
   667  
   668  func TestETLBucketDryRun(t *testing.T) {
   669  	tools.CheckSkip(t, &tools.SkipTestArgs{RequiredDeployment: tools.ClusterTypeK8s})
   670  	tetl.CheckNoRunningETLContainers(t, baseParams)
   671  
   672  	var (
   673  		proxyURL   = tools.RandomProxyURL(t)
   674  		baseParams = tools.BaseAPIParams(proxyURL)
   675  
   676  		bckFrom = cmn.Bck{Name: "etloffline", Provider: apc.AIS}
   677  		bckTo   = cmn.Bck{Name: "etloffline-out-" + trand.String(5), Provider: apc.AIS}
   678  		objCnt  = 10
   679  
   680  		m = ioContext{
   681  			t:         t,
   682  			num:       objCnt,
   683  			fileSize:  512,
   684  			fixedSize: true,
   685  			bck:       bckFrom,
   686  		}
   687  	)
   688  
   689  	tools.CreateBucket(t, proxyURL, bckFrom, nil, true /*cleanup*/)
   690  	m.init(true /*cleanup*/)
   691  
   692  	m.puts()
   693  
   694  	_ = tetl.InitSpec(t, baseParams, tetl.Echo, etl.Hrev)
   695  	t.Cleanup(func() { tetl.StopAndDeleteETL(t, baseParams, tetl.Echo) })
   696  
   697  	msg := &apc.TCBMsg{
   698  		Transform: apc.Transform{
   699  			Name: tetl.Echo,
   700  		},
   701  		CopyBckMsg: apc.CopyBckMsg{DryRun: true, Force: true},
   702  	}
   703  	xid, err := api.ETLBucket(baseParams, bckFrom, bckTo, msg)
   704  	tassert.CheckFatal(t, err)
   705  
   706  	args := xact.ArgsMsg{ID: xid, Timeout: time.Minute}
   707  	_, err = api.WaitForXactionIC(baseParams, &args)
   708  	tassert.CheckFatal(t, err)
   709  
   710  	exists, err := api.QueryBuckets(baseParams, cmn.QueryBcks(bckTo), apc.FltPresent)
   711  	tassert.CheckFatal(t, err)
   712  	tassert.Errorf(t, exists == false, "[dry-run] expected destination bucket not to be created")
   713  
   714  	checkETLStats(t, xid, m.num, uint64(m.num*int(m.fileSize)), false)
   715  }
   716  
   717  func TestETLStopAndRestartETL(t *testing.T) {
   718  	tools.CheckSkip(t, &tools.SkipTestArgs{RequiredDeployment: tools.ClusterTypeK8s})
   719  	tetl.CheckNoRunningETLContainers(t, baseParams)
   720  
   721  	var (
   722  		proxyURL   = tools.RandomProxyURL(t)
   723  		baseParams = tools.BaseAPIParams(proxyURL)
   724  		etlName    = tetl.Echo // TODO: currently, echo only - add more
   725  	)
   726  
   727  	_ = tetl.InitSpec(t, baseParams, etlName, etl.Hrev)
   728  	t.Cleanup(func() { tetl.StopAndDeleteETL(t, baseParams, etlName) })
   729  
   730  	// 1. Check ETL is in running state
   731  	tetl.ETLShouldBeRunning(t, baseParams, etlName)
   732  
   733  	// 2. Stop ETL and verify it stopped successfully
   734  	tlog.Logf("stopping ETL[%s]\n", etlName)
   735  	err := api.ETLStop(baseParams, etlName)
   736  	tassert.CheckFatal(t, err)
   737  	tetl.ETLShouldNotBeRunning(t, baseParams, etlName)
   738  
   739  	// 3. Start ETL and verify it is in running state
   740  	tlog.Logf("restarting ETL[%s]\n", etlName)
   741  	err = api.ETLStart(baseParams, etlName)
   742  	tassert.CheckFatal(t, err)
   743  	tetl.ETLShouldBeRunning(t, baseParams, etlName)
   744  }
   745  
   746  func TestETLMultipleTransformersAtATime(t *testing.T) {
   747  	tools.CheckSkip(t, &tools.SkipTestArgs{RequiredDeployment: tools.ClusterTypeK8s, Long: true})
   748  	tetl.CheckNoRunningETLContainers(t, baseParams)
   749  
   750  	output, err := exec.Command("bash", "-c", "kubectl get nodes | grep Ready | wc -l").CombinedOutput()
   751  	tassert.CheckFatal(t, err)
   752  	if strings.Trim(string(output), "\n") != "1" {
   753  		t.Skip("Requires a single node kubernetes cluster")
   754  	}
   755  
   756  	if tools.GetClusterMap(t, proxyURL).CountTargets() > 1 {
   757  		t.Skip("Requires a single-node single-target deployment")
   758  	}
   759  
   760  	_ = tetl.InitSpec(t, baseParams, tetl.Echo, etl.Hrev)
   761  	t.Cleanup(func() { tetl.StopAndDeleteETL(t, baseParams, tetl.Echo) })
   762  
   763  	_ = tetl.InitSpec(t, baseParams, tetl.MD5, etl.Hrev)
   764  	t.Cleanup(func() { tetl.StopAndDeleteETL(t, baseParams, tetl.MD5) })
   765  }
   766  
   767  const getMetricsTimeout = 90 * time.Second
   768  
   769  func TestETLHealth(t *testing.T) {
   770  	var (
   771  		proxyURL   = tools.RandomProxyURL(t)
   772  		baseParams = tools.BaseAPIParams(proxyURL)
   773  		etlName    = tetl.Echo // TODO: currently, only echo - add more
   774  	)
   775  
   776  	tools.CheckSkip(t, &tools.SkipTestArgs{RequiredDeployment: tools.ClusterTypeK8s, Long: true})
   777  	tetl.CheckNoRunningETLContainers(t, baseParams)
   778  
   779  	_ = tetl.InitSpec(t, baseParams, etlName, etl.Hpull)
   780  	t.Cleanup(func() { tetl.StopAndDeleteETL(t, baseParams, etlName) })
   781  
   782  	var (
   783  		start    = time.Now()
   784  		deadline = start.Add(getMetricsTimeout) // might take a while for metrics to become available
   785  		healths  etl.HealthByTarget
   786  		err      error
   787  	)
   788  	for {
   789  		now := time.Now()
   790  		if now.After(deadline) {
   791  			t.Fatal("Timeout waiting for successful health response")
   792  		}
   793  
   794  		healths, err = api.ETLHealth(baseParams, etlName)
   795  		if err == nil {
   796  			if len(healths) > 0 {
   797  				tlog.Logf("Successfully received health data after %s\n", now.Sub(start))
   798  				break
   799  			}
   800  			tlog.Logln("Unexpected empty health messages without error, retrying...")
   801  			continue
   802  		}
   803  
   804  		herr, ok := err.(*cmn.ErrHTTP)
   805  		tassert.Errorf(t, ok && herr.Status == http.StatusNotFound, "Unexpected error %v, expected 404", err)
   806  		tlog.Logf("ETL[%s] not found in metrics, retrying...\n", etlName)
   807  		time.Sleep(10 * time.Second)
   808  	}
   809  
   810  	// TODO -- FIXME: see health handlers returning "OK" - revisit
   811  	for _, msg := range healths {
   812  		tassert.Errorf(t, msg.Status == "Running", "Expected pod at %s to be running, got %q",
   813  			meta.Tname(msg.TargetID), msg.Status)
   814  	}
   815  }
   816  
   817  func TestETLMetrics(t *testing.T) {
   818  	var (
   819  		proxyURL   = tools.RandomProxyURL(t)
   820  		baseParams = tools.BaseAPIParams(proxyURL)
   821  		etlName    = tetl.Echo // TODO: currently, only echo - add more
   822  	)
   823  
   824  	tools.CheckSkip(t, &tools.SkipTestArgs{RequiredDeployment: tools.ClusterTypeK8s, Long: true})
   825  	tetl.CheckNoRunningETLContainers(t, baseParams)
   826  
   827  	_ = tetl.InitSpec(t, baseParams, etlName, etl.Hpull)
   828  	t.Cleanup(func() { tetl.StopAndDeleteETL(t, baseParams, etlName) })
   829  
   830  	var (
   831  		start    = time.Now()
   832  		deadline = start.Add(getMetricsTimeout) // might take a while for metrics to become available
   833  		metrics  etl.CPUMemByTarget
   834  		err      error
   835  	)
   836  	for {
   837  		now := time.Now()
   838  		if now.After(deadline) {
   839  			t.Fatal("Timeout waiting for successful metrics response")
   840  		}
   841  
   842  		metrics, err = api.ETLMetrics(baseParams, etlName)
   843  		if err == nil {
   844  			if len(metrics) > 0 {
   845  				tlog.Logf("Successfully received metrics after %s\n", now.Sub(start))
   846  				break
   847  			}
   848  			tlog.Logln("Unexpected empty metrics messages without error, retrying...")
   849  			continue
   850  		}
   851  
   852  		herr, ok := err.(*cmn.ErrHTTP)
   853  		tassert.Errorf(t, ok && herr.Status == http.StatusNotFound, "Unexpected error %v, expected 404", err)
   854  		tlog.Logf("ETL[%s] not found in metrics, retrying...\n", etlName)
   855  		time.Sleep(10 * time.Second)
   856  	}
   857  
   858  	for _, metric := range metrics {
   859  		tassert.Errorf(t, metric.CPU > 0.0 || metric.Mem > 0, "[%s] expected non empty metrics info, got %v",
   860  			metric.TargetID, metric)
   861  	}
   862  }
   863  
   864  func TestETLList(t *testing.T) {
   865  	var (
   866  		proxyURL   = tools.RandomProxyURL(t)
   867  		baseParams = tools.BaseAPIParams(proxyURL)
   868  		etlName    = tetl.Echo // TODO: currently, only echo - add more
   869  	)
   870  	tools.CheckSkip(t, &tools.SkipTestArgs{RequiredDeployment: tools.ClusterTypeK8s})
   871  
   872  	_ = tetl.InitSpec(t, baseParams, etlName, etl.Hrev)
   873  	t.Cleanup(func() { tetl.StopAndDeleteETL(t, baseParams, etlName) })
   874  
   875  	list, err := api.ETLList(baseParams)
   876  	tassert.CheckFatal(t, err)
   877  	tassert.Fatalf(t, len(list) == 1, "expected exactly one ETL to be listed, got %d (%+v)", len(list), list)
   878  	tassert.Fatalf(t, list[0].Name == etlName, "expected ETL[%s], got %q", etlName, list[0].Name)
   879  }