e2etest/declarativeHelpers.go (388 lines of code) (raw):
// Copyright © Microsoft <wastore@microsoft.com>
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
package e2etest
import (
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/blob"
"reflect"
"strings"
"testing"
"github.com/Azure/azure-storage-azcopy/v10/common"
"github.com/JeffreyRichter/enum/enum"
)
// /////////
var sanitizer = common.NewAzCopyLogSanitizer() // while this is "only tests", we may as well follow good SAS redaction practices
// //////
type comparison struct {
equals bool
}
func (c comparison) String() string {
if c.equals {
return "equals"
} else {
return "notEquals"
}
}
func equals() comparison {
return comparison{true}
}
func notEquals() comparison {
return comparison{false}
}
// /////
// simplified assertion interface. Allows us to abstract away the specific test harness we are using
// (in case we change it... again)
type asserter interface {
Assert(obtained interface{}, comp comparison, expected interface{}, comment ...string)
AssertNoErr(err error, comment ...string)
Error(reason string)
Skip(reason string)
Failed() bool
// ScenarioName piggy-backs on this interface, in a context-value-like way (ugly, but it works)
CompactScenarioName() string
}
type testingAsserter struct {
t *testing.T
compactScenarioName string
fullScenarioName string
}
func (a *testingAsserter) formatComments(comment []string) string {
expandedComment := strings.Join(comment, ", ")
if expandedComment != "" {
expandedComment = "\n " + expandedComment
}
return expandedComment
}
// Assert compares its arguments and marks the current test (or subtest) as failed. Unlike gocheck's Assert method,
// in this implementation execution of the test continues (and so subsequent asserts may give additional information)
func (a *testingAsserter) Assert(obtained interface{}, comp comparison, expected interface{}, comment ...string) {
// do the comparison (our comparison options are deliberately simple)
// TODO: if obtained or expected is a pointer, do we want to dereference it before comparing? Do we even need that in our codebase?
ok := false
if comp.equals {
ok = reflect.DeepEqual(obtained, expected)
} else {
ok = obtained != expected
}
if ok {
return
}
// record the failure
a.t.Helper() // exclude this method from the logged callstack
expandedComment := a.formatComments(comment)
a.t.Logf("Assertion failed in %s\n Attempted to assert that: (actual) %v %s (expected) %v%s", a.fullScenarioName, obtained, comp, expected, expandedComment)
a.t.Fail()
}
// AssertNoErr asserts that err is nil, and calls FailNow for immediate termination of the test if err is not nil.
// This does immediate termination, rather than letting the test continue running like Assert() does, because
// with immediate termination it can be used as a guard clause, before things which might otherwise panic due to invalid inputs.
func (a *testingAsserter) AssertNoErr(err error, comment ...string) {
if err != nil {
a.t.Helper() // exclude this method from the logged callstack
redactedErr := sanitizer.SanitizeLogMessage(err.Error())
a.t.Logf("Error %s%s", redactedErr, a.formatComments(comment))
a.t.Fail()
}
}
func (a *testingAsserter) Error(reason string) {
a.t.Helper()
a.t.Error(reason)
}
func (a *testingAsserter) Skip(reason string) {
a.t.Skip(reason)
}
func (a *testingAsserter) Failed() bool {
return a.t.Failed()
}
func (a *testingAsserter) CompactScenarioName() string {
return a.compactScenarioName
}
// //
type params struct {
recursive bool
invertedAsSubdir bool // this flag is INVERTED, because it is TRUE by default. todo: use pointers instead?
includePath string
includePattern string
includeAfter string
includeAttributes string
excludePath string
excludePattern string
excludeAttributes string
forceIfReadOnly bool
capMbps float32
blockSizeMB float32
putBlobSizeMB float32
deleteDestination common.DeleteDestination // Manual validation is needed.
s2sSourceChangeValidation bool
metadata string
cancelFromStdin bool
backupMode bool
preserveSMBPermissions bool
preserveSMBInfo *bool
preservePOSIXProperties bool
relativeSourcePath string
blobTags string
blobType string
stripTopDir bool
s2sPreserveBlobTags bool
cpkByName string
cpkByValue bool
isObjectDir bool
debugSkipFiles []string // a list of localized filepaths to skip over on the first run in the STE.
s2sPreserveAccessTier bool
accessTier *blob.AccessTier
checkMd5 common.HashValidationOption
compareHash common.SyncHashType
hashStorageMode common.HashStorageMode
hashStorageDir string
symlinkHandling common.SymlinkHandlingType
destNull bool
disableParallelTesting bool
deleteDestinationFile bool
trailingDot common.TrailingDotOption
decompress bool
// looks like this for a folder transfer:
/*
INFO: source: /New folder/New Text Document.txt dest: /Test/New folder/New Text Document.txt
INFO: source: /New Text Document.txt dest: /Test/New Text Document.txt
*/
// and this for a single file transfer to a folder:
/*
INFO: source: dest: /New Text Document.txt
*/
// OAuth params, "SPN" (default), "AZCLI", and "PSCRED" are currently supported
AutoLoginType string
// cancel params
ignoreErrorIfCompleted bool
// benchmark params
mode string
fileCount int
sizePerFile string
}
// we expect folder transfers to be allowed (between folder-aware resources) if there are no filters that act at file level
// TODO : Make this *actually* check with azcopy code instead of assuming azcopy's black magic.
func (p params) allowsFolderTransfers() bool {
return !p.destNull && p.includePattern+p.includeAttributes+p.excludePattern+p.excludeAttributes == ""
}
// ////////////
var eOperation = Operation(0)
type Operation uint8
func (Operation) Copy() Operation { return Operation(1) }
func (Operation) Sync() Operation { return Operation(1 << 1) }
func (Operation) CopyAndSync() Operation { return eOperation.Copy() | eOperation.Sync() }
func (Operation) Remove() Operation { return Operation(1 << 2) }
func (Operation) List() Operation { return Operation(1 << 3) }
func (Operation) Resume() Operation { return Operation(1 << 7) } // Resume should only ever be combined with Copy or Sync, and is a mid-job cancel/resume.
func (Operation) Cancel() Operation { return Operation(1 << 3) }
func (Operation) Benchmark() Operation { return Operation(1 << 4) }
func (o Operation) String() string {
return enum.StringInt(o, reflect.TypeOf(o))
}
func (o Operation) NeedsDst() bool {
return !(o == eOperation.Remove() || o == eOperation.List() || o == eOperation.Resume() || o == eOperation.Benchmark())
}
// getValues chops up composite values into their parts
func (o Operation) getValues() []Operation {
out := make([]Operation, 0)
// separate out the bitflags
for idx := 0; idx < 8; idx++ {
opMatching := Operation(1 << idx)
if opMatching&o != 0 {
out = append(out, opMatching)
}
}
return out
}
func (o Operation) includes(item Operation) bool {
for _, v := range o.getValues() {
if v == item {
return true
}
}
return false
}
// ///////////
var eTestFromTo = TestFromTo{}
// TestFromTo is similar to common/FromTo, except that it can have cases where one value represents many possibilities
type TestFromTo struct {
desc string
useAllTos bool
suppressAutoFileToFile bool // TODO: invert this // if true, we won't automatically replace File -> Blob with File -> File. We do that replacement by default because File -> File is the more common scenario (and, for sync, File -> Blob is not even supported currently).
froms []common.Location
tos []common.Location
filter func(to common.FromTo) bool
}
// AllSourcesToOneDest means use all possible sources, and test each source to one destination (generally Blob is the destination,
// except for sources that don't support Blob, in which case, a download to local is done).
// Use this for tests that are primarily about enumeration of the source (rather than support for a wide range of destinations)
func (TestFromTo) AllSourcesToOneDest() TestFromTo {
return TestFromTo{
desc: "AllSourcesToOneDest",
useAllTos: false,
froms: common.ELocation.AllStandardLocations(),
tos: []common.Location{
common.ELocation.Blob(), // auto-replaced with File when source is File
common.ELocation.Local(),
},
}
}
// AllSourcesDownAndS2S means use all possible sources, and for each to both Blob and a download to local (except
// when not applicable. E.g. local source doesn't support download; AzCopy's ADLS Gen doesn't (currently) support S2S.
// This is a good general purpose choice, because it lets you do exercise things fairly comprehensively without
// actually getting into all pairwise combinations
func (TestFromTo) AllSourcesDownAndS2S() TestFromTo {
return TestFromTo{
desc: "AllSourcesDownAndS2S",
useAllTos: true,
froms: common.ELocation.AllStandardLocations(),
tos: []common.Location{
common.ELocation.Blob(), // auto-replaced with File when source is File
common.ELocation.Local(),
},
}
}
// AllPairs tests literally all Source/Dest pairings that are supported by AzCopy.
// Use this sparingly, because it runs a lot of cases. Prefer AllSourcesToOneDest or AllSourcesDownAndS2S or similar.
func (TestFromTo) AllPairs() TestFromTo {
return TestFromTo{
desc: "AllPairs",
useAllTos: true,
suppressAutoFileToFile: true, // not needed for AllPairs
froms: common.ELocation.AllStandardLocations(),
tos: common.ELocation.AllStandardLocations(),
}
}
// AllUploads represents the subset of AllPairs that are uploads
func (TestFromTo) AllUploads() TestFromTo {
result := TestFromTo{}.AllPairs()
result.desc = "AllUploads"
result.filter = func(ft common.FromTo) bool {
return ft.IsUpload()
}
return result
}
// AllDownloads represents the subset of AllPairs that are downloads
func (TestFromTo) AllDownloads() TestFromTo {
result := TestFromTo{}.AllPairs()
result.desc = "AllDownloads"
result.filter = func(ft common.FromTo) bool {
return ft.IsDownload()
}
return result
}
// AllS2S represents the subset of AllPairs that are S2S transfers
func (TestFromTo) AllS2S() TestFromTo {
result := TestFromTo{}.AllPairs()
result.desc = "AllS2S"
result.filter = func(ft common.FromTo) bool {
return ft.IsS2S()
}
return result
}
// AllAzureS2S is like AllS2S, but it excludes non-Azure sources. (No need to exclude non-Azure destinations, since AzCopy doesn't have those)
func (TestFromTo) AllAzureS2S() TestFromTo {
result := TestFromTo{}.AllPairs()
result.desc = "AllAzureS2S"
result.filter = func(ft common.FromTo) bool {
isFromAzure := ft.From() == common.ELocation.BlobFS() ||
ft.From() == common.ELocation.Blob() ||
ft.From() == common.ELocation.File()
return ft.IsS2S() && isFromAzure
}
return result
}
// AllRemove represents the subset of AllPairs that are remove/delete
func (TestFromTo) AllRemove() TestFromTo {
return TestFromTo{
desc: "AllRemove",
useAllTos: true,
froms: []common.Location{
common.ELocation.Blob(),
common.ELocation.File(),
common.ELocation.BlobFS(),
},
tos: []common.Location{
common.ELocation.Unknown(),
},
}
}
func (TestFromTo) AllSync() TestFromTo {
return TestFromTo{
desc: "AllSync",
useAllTos: true,
froms: []common.Location{
common.ELocation.Blob(),
common.ELocation.File(),
common.ELocation.Local(),
common.ELocation.BlobFS(),
},
tos: []common.Location{
common.ELocation.Blob(),
common.ELocation.File(),
common.ELocation.Local(),
common.ELocation.BlobFS(),
},
}
}
// Other is for when you want to list one or more specific from-tos that the test should cover.
// Generally avoid this method, because it does not automatically pick up new pairs as we add new supported
// resource types to AzCopy.
func (TestFromTo) Other(values ...common.FromTo) TestFromTo {
result := TestFromTo{}.AllPairs()
result.desc = "Other"
result.filter = func(ft common.FromTo) bool {
for _, v := range values {
if ft == v {
return true
}
}
return false
}
return result
}
func NewTestFromTo(desc string, useAllTos bool, froms []common.Location, tos []common.Location) TestFromTo {
return TestFromTo{
desc: desc,
useAllTos: useAllTos,
suppressAutoFileToFile: true, // turn off this fancy trick for custom ones
froms: froms,
tos: tos,
}
}
func (tft TestFromTo) String() string {
return tft.desc
}
func (tft TestFromTo) getValues(op Operation) []common.FromTo {
result := make([]common.FromTo, 0, 4)
for _, from := range tft.froms {
haveEnoughTos := false
for _, to := range tft.tos {
if haveEnoughTos {
continue
}
// replace File -> Blob with File -> File if configured to do so.
// So that we can use Blob as a generic "remote" to, but still do File->File in those case where that makes more sense
// (Specifically, if testing just one of FileFile and FileBlob, it makes much more sense to do FileFile because its the
// more common case in real usage.)
if !tft.suppressAutoFileToFile {
if from == common.ELocation.File() && to == common.ELocation.Blob() {
to = common.ELocation.File()
}
}
// parse the combination and see if its valid
var fromTo common.FromTo
var err error
if to == common.ELocation.Unknown() {
err = fromTo.Parse(from.String() + "Trash")
} else {
err = fromTo.Parse(from.String() + to.String())
}
if err != nil {
continue // this pairing wasn't valid
}
// if we are doing sync, skip combos that are not currently valid for sync
if op == eOperation.Sync() {
switch fromTo {
case common.EFromTo.BlobBlob(),
common.EFromTo.BlobFSBlob(),
common.EFromTo.BlobBlobFS(),
common.EFromTo.BlobFSBlobFS(),
common.EFromTo.BlobFSLocal(),
common.EFromTo.LocalBlobFS(),
common.EFromTo.FileFile(),
common.EFromTo.LocalBlob(),
common.EFromTo.BlobLocal(),
common.EFromTo.LocalFile(),
common.EFromTo.FileLocal(),
common.EFromTo.BlobFile(),
common.EFromTo.FileBlob():
// do nothing, these are fine
default:
continue // not supported for sync
}
}
// TODO: remove this temp block
// temp
if fromTo.From() == common.ELocation.S3() {
continue // until we implement the declarativeResourceManagers
}
// check filter
if tft.filter != nil {
if !tft.filter(fromTo) {
continue
}
}
// this one is valid
result = append(result, fromTo)
if !tft.useAllTos {
haveEnoughTos = true // we only need the one we just found
}
}
}
return result
}
// //
var eValidate = Validate(0)
type Validate uint8
// Auto automatically validates everything except for the actual content of the transferred files.
// It includes "which transfers did we attempt, and what was their outcome?" AND, if any of the shouldTransfer files specify
// file properties that should be validated, it validates those too
func (Validate) Auto() Validate { return Validate(0) }
// BasicPlusContent also validates the file content
func (Validate) AutoPlusContent() Validate { return Validate(1) }
func (v Validate) String() string {
return enum.StringInt(v, reflect.TypeOf(v))
}
// ////
// hookHelper is functions that hooks can call to influence test execution
// NOTE: this interface will have to actively evolve as we discover what we need our hooks to do.
type hookHelper interface {
// FromTo returns the fromTo for the scenario
FromTo() common.FromTo
Operation() Operation
// GetModifiableParameters returns a pointer to the AzCopy parameters that will be used in the scenario
GetModifiableParameters() *params
// GetTestFiles returns (a copy of) the testFiles object that defines which files will be used in the test
GetTestFiles() testFiles
// SetTestFiles allows the test to set the test files in a callback (e.g. adding new files to the test dynamically w/o creation)
SetTestFiles(fs testFiles)
// CreateFiles creates the specified files (overwriting any that are already there of the same name)
CreateFiles(fs testFiles, atSource bool, setTestFiles bool, createSourceFilesAtDest bool)
// CreateFile creates a specified file (overwriting what was already there of the same name)
// This is intended to be used in hook functions for pre or mid transfer adjustments.
CreateFile(f *testObject, atSource bool)
// CancelAndResume tells the runner to cancel the running AzCopy job (with "cancel" to stdin) and the resume the job
CancelAndResume()
// CreateSourceSnapshot Create a source snapshot to use it as the source
CreateSourceSnapshot()
// SkipTest skips the test
SkipTest()
// Assert gives access to the asserter
GetAsserter() asserter
// GetDestination returns the destination Resource Manager
GetDestination() resourceManager
// GetSource returns the source Resource Manager
GetSource() resourceManager
}
// /////
type hookFunc func(h hookHelper)
// hooks contains functions that are called at various points in the running of the test, so that we can do
// custom behaviour (for those func that are not nil).
// NOTE: the funcs you provide here must be threadsafe, because RunScenarios works in parallel for all its scenarios
type hooks struct {
// called before running a scenario
beforeTestRun hookFunc
// called after all the setup is done, and before AzCopy is actually invoked
beforeRunJob hookFunc
// called after the first execution, but before the resume.
beforeResumeHook hookFunc
// called after AzCopy has started running, but before it has started its first transfer. Moment of call may be
// before, during or after AzCopy's scanning phase. If this hook is set, AzCopy won't open its first file, to start
// transferring data, until this function executes.
beforeOpenFirstFile hookFunc
// called after AzCopy finishes running & validation of transfer states completes.
afterValidation hookFunc
}