fixed audit logging with saves and loads (#727)

This commit is contained in:
Zack Brady
2026-08-10 12:30:42 -04:00
committed by GitHub
parent 7d7adcaf17
commit 351544b2c0
8 changed files with 252 additions and 33 deletions
+2 -4
View File
@@ -166,9 +166,8 @@ func addStoreLoad(rso *flags.StoreRootOpts, ro *flags.CliRootOpts) *cobra.Comman
if err != nil {
return err
}
_ = s
return store.LoadCmd(ctx, o, rso, ro)
return store.LoadCmd(ctx, o, s, rso, ro)
},
}
o.AddFlags(cmd)
@@ -252,9 +251,8 @@ func addStoreSave(rso *flags.StoreRootOpts, ro *flags.CliRootOpts) *cobra.Comman
if err != nil {
return err
}
_ = s
return store.SaveCmd(ctx, o, rso, ro)
return store.SaveCmd(ctx, o, s, rso, ro)
},
}
o.AddFlags(cmd)
+30 -10
View File
@@ -44,7 +44,7 @@ func TestLifecycle_FileArtifact_AddSaveLoadCopy(t *testing.T) {
// Step 3: SaveCmd -> archive (absolute paths required).
archivePath := filepath.Join(t.TempDir(), "lifecycle-file.tar.zst")
saveOpts := newSaveOpts(storeA.Root, archivePath)
if err := SaveCmd(ctx, saveOpts, defaultRootOpts(storeA.Root), defaultCliOpts()); err != nil {
if err := SaveCmd(ctx, saveOpts, storeA, defaultRootOpts(storeA.Root), defaultCliOpts()); err != nil {
t.Fatalf("SaveCmd: %v", err)
}
@@ -58,11 +58,15 @@ func TestLifecycle_FileArtifact_AddSaveLoadCopy(t *testing.T) {
// Step 4: LoadCmd -> store B.
storeBDir := t.TempDir()
storeBPreLoad, err := store.NewLayout(storeBDir)
if err != nil {
t.Fatalf("store.NewLayout(storeB pre-load): %v", err)
}
loadOpts := &flags.LoadOpts{
StoreRootOpts: defaultRootOpts(storeBDir),
FileName: []string{archivePath},
}
if err := LoadCmd(ctx, loadOpts, defaultRootOpts(storeBDir), defaultCliOpts()); err != nil {
if err := LoadCmd(ctx, loadOpts, storeBPreLoad, defaultRootOpts(storeBDir), defaultCliOpts()); err != nil {
t.Fatalf("LoadCmd: %v", err)
}
@@ -122,17 +126,21 @@ func TestLifecycle_Image_AddSaveLoadCopyRegistry(t *testing.T) {
// Step 3: SaveCmd -> archive.
archivePath := filepath.Join(t.TempDir(), "lifecycle-image.tar.zst")
saveOpts := newSaveOpts(storeA.Root, archivePath)
if err := SaveCmd(ctx, saveOpts, defaultRootOpts(storeA.Root), defaultCliOpts()); err != nil {
if err := SaveCmd(ctx, saveOpts, storeA, defaultRootOpts(storeA.Root), defaultCliOpts()); err != nil {
t.Fatalf("SaveCmd: %v", err)
}
// Step 4: LoadCmd -> store B.
storeBDir := t.TempDir()
storeBPreLoad, err := store.NewLayout(storeBDir)
if err != nil {
t.Fatalf("store.NewLayout(storeB pre-load): %v", err)
}
loadOpts := &flags.LoadOpts{
StoreRootOpts: defaultRootOpts(storeBDir),
FileName: []string{archivePath},
}
if err := LoadCmd(ctx, loadOpts, defaultRootOpts(storeBDir), defaultCliOpts()); err != nil {
if err := LoadCmd(ctx, loadOpts, storeBPreLoad, defaultRootOpts(storeBDir), defaultCliOpts()); err != nil {
t.Fatalf("LoadCmd: %v", err)
}
@@ -190,17 +198,21 @@ func TestLifecycle_Chart_AddSaveLoadExtract(t *testing.T) {
// Step 2: SaveCmd -> archive.
archivePath := filepath.Join(t.TempDir(), "lifecycle-chart.tar.zst")
saveOpts := newSaveOpts(storeA.Root, archivePath)
if err := SaveCmd(ctx, saveOpts, defaultRootOpts(storeA.Root), defaultCliOpts()); err != nil {
if err := SaveCmd(ctx, saveOpts, storeA, defaultRootOpts(storeA.Root), defaultCliOpts()); err != nil {
t.Fatalf("SaveCmd: %v", err)
}
// Step 3: LoadCmd -> new store.
storeBDir := t.TempDir()
storeBPreLoad, err := store.NewLayout(storeBDir)
if err != nil {
t.Fatalf("store.NewLayout(storeB pre-load): %v", err)
}
loadOpts := &flags.LoadOpts{
StoreRootOpts: defaultRootOpts(storeBDir),
FileName: []string{archivePath},
}
if err := LoadCmd(ctx, loadOpts, defaultRootOpts(storeBDir), defaultCliOpts()); err != nil {
if err := LoadCmd(ctx, loadOpts, storeBPreLoad, defaultRootOpts(storeBDir), defaultCliOpts()); err != nil {
t.Fatalf("LoadCmd: %v", err)
}
@@ -274,17 +286,21 @@ func TestLifecycle_DigestOnlyImage_AddSaveLoad(t *testing.T) {
// Step 3: SaveCmd -> archive
archivePath := filepath.Join(t.TempDir(), "lifecycle-digestonly.tar.zst")
saveOpts := newSaveOpts(storeA.Root, archivePath)
if err := SaveCmd(ctx, saveOpts, defaultRootOpts(storeA.Root), defaultCliOpts()); err != nil {
if err := SaveCmd(ctx, saveOpts, storeA, defaultRootOpts(storeA.Root), defaultCliOpts()); err != nil {
t.Fatalf("SaveCmd: %v", err)
}
// Step 4: LoadCmd -> fresh store B
storeBDir := t.TempDir()
storeBPreLoad, err := store.NewLayout(storeBDir)
if err != nil {
t.Fatalf("store.NewLayout(storeB pre-load): %v", err)
}
loadOpts := &flags.LoadOpts{
StoreRootOpts: defaultRootOpts(storeBDir),
FileName: []string{archivePath},
}
if err := LoadCmd(ctx, loadOpts, defaultRootOpts(storeBDir), defaultCliOpts()); err != nil {
if err := LoadCmd(ctx, loadOpts, storeBPreLoad, defaultRootOpts(storeBDir), defaultCliOpts()); err != nil {
t.Fatalf("LoadCmd: %v", err)
}
@@ -339,17 +355,21 @@ func TestLifecycle_Remove_ThenSave(t *testing.T) {
// Step 3: SaveCmd -> archive.
archivePath := filepath.Join(t.TempDir(), "lifecycle-remove.tar.zst")
saveOpts := newSaveOpts(storeA.Root, archivePath)
if err := SaveCmd(ctx, saveOpts, defaultRootOpts(storeA.Root), defaultCliOpts()); err != nil {
if err := SaveCmd(ctx, saveOpts, storeA, defaultRootOpts(storeA.Root), defaultCliOpts()); err != nil {
t.Fatalf("SaveCmd: %v", err)
}
// Step 4: LoadCmd -> new store.
storeBDir := t.TempDir()
storeBPreLoad, err := store.NewLayout(storeBDir)
if err != nil {
t.Fatalf("store.NewLayout(storeB pre-load): %v", err)
}
loadOpts := &flags.LoadOpts{
StoreRootOpts: defaultRootOpts(storeBDir),
FileName: []string{archivePath},
}
if err := LoadCmd(ctx, loadOpts, defaultRootOpts(storeBDir), defaultCliOpts()); err != nil {
if err := LoadCmd(ctx, loadOpts, storeBPreLoad, defaultRootOpts(storeBDir), defaultCliOpts()); err != nil {
t.Fatalf("LoadCmd: %v", err)
}
+32 -3
View File
@@ -14,6 +14,7 @@ import (
"hauler.dev/go/hauler/v2/internal/flags"
"hauler.dev/go/hauler/v2/pkg/archives"
"hauler.dev/go/hauler/v2/pkg/audit"
"hauler.dev/go/hauler/v2/pkg/consts"
"hauler.dev/go/hauler/v2/pkg/content"
"hauler.dev/go/hauler/v2/pkg/getter"
@@ -28,7 +29,7 @@ import (
var legacyChunkRe = regexp.MustCompile(`_\d+\.`)
// extracts the contents of an archived oci layout to an existing oci layout
func LoadCmd(ctx context.Context, o *flags.LoadOpts, rso *flags.StoreRootOpts, ro *flags.CliRootOpts) error {
func LoadCmd(ctx context.Context, o *flags.LoadOpts, s *store.Layout, rso *flags.StoreRootOpts, ro *flags.CliRootOpts) error {
l := log.FromContext(ctx)
tempOverride := rso.TempOverride
@@ -66,6 +67,25 @@ func LoadCmd(ctx context.Context, o *flags.LoadOpts, rso *flags.StoreRootOpts, r
if err != nil {
return err
}
if auditLevel(ro) != "none" {
e := audit.Entry{
StoreID: s.StoreID,
Store: s.Root,
Command: "store load",
Reference: audit.SanitizeURL(resolved),
}
if auditLevel(ro) == "verbose" {
sys := audit.BuildSystem()
g := audit.BuildGlobal(ro, rso)
e.System = &sys
e.Global = &g
}
if err := audit.Append(ro.HaulerDir, e); err != nil {
l.Warnf("failed to write audit entry: %v", err)
}
}
clearDir(tempDir)
}
@@ -242,8 +262,17 @@ func unarchiveLayoutTo(ctx context.Context, haulPath string, dest string, tempDi
return err
}
_, err = s.CopyAll(ctx, ts, nil)
return err
if _, err := s.CopyAll(ctx, ts, nil); err != nil {
return err
}
// CopyAll only copies OCI content; the haul's audit.log sits alongside
// it, not in it, so it has to be merged in separately.
if err := audit.MergeStoreLog(tempDir, dest); err != nil {
l.Warnf("failed to merge audit log from haul: %v", err)
}
return nil
}
// matches the <path>.NNN chunk suffix that is used to filter resolveHaulPath
+65 -6
View File
@@ -103,6 +103,47 @@ func TestUnarchiveLayoutTo(t *testing.T) {
}
}
// TestUnarchiveLayoutTo_MergesAuditLog checks a haul's audit.log survives
// the load and merges into (not overwrites) whatever dest already has.
func TestUnarchiveLayoutTo_MergesAuditLog(t *testing.T) {
srcDir := t.TempDir()
srcLayout, err := store.NewLayout(srcDir)
if err != nil {
t.Fatalf("store.NewLayout(srcDir): %v", err)
}
// NewLayout doesn't write index.json until something forces a save --
// unarchiveLayoutTo requires one to exist in the archive.
if err := srcLayout.OCI.SaveIndex(); err != nil {
t.Fatalf("SaveIndex(srcDir): %v", err)
}
if err := os.WriteFile(filepath.Join(srcDir, "audit.log"), []byte(`{"command":"from-haul"}`+"\n"), 0o644); err != nil {
t.Fatalf("seed source audit.log: %v", err)
}
archivePath := filepath.Join(t.TempDir(), "haul.tar.zst")
if err := createRootLevelArchive(srcDir, archivePath); err != nil {
t.Fatalf("createRootLevelArchive: %v", err)
}
destDir := t.TempDir()
if err := os.WriteFile(filepath.Join(destDir, "audit.log"), []byte(`{"command":"already-here"}`+"\n"), 0o644); err != nil {
t.Fatalf("seed dest audit.log: %v", err)
}
ctx := newTestContext(t)
if err := unarchiveLayoutTo(ctx, archivePath, destDir, t.TempDir(), defaultCliOpts(), false); err != nil {
t.Fatalf("unarchiveLayoutTo: %v", err)
}
data, err := os.ReadFile(filepath.Join(destDir, "audit.log"))
if err != nil {
t.Fatalf("ReadFile dest audit.log: %v", err)
}
if !strings.Contains(string(data), "already-here") || !strings.Contains(string(data), "from-haul") {
t.Fatalf("expected both entries preserved after load, got: %s", data)
}
}
// --------------------------------------------------------------------------
// TestLoadCmd_LocalFile
// --------------------------------------------------------------------------
@@ -114,16 +155,22 @@ func TestLoadCmd_LocalFile(t *testing.T) {
t.Run("single archive", func(t *testing.T) {
destDir := t.TempDir()
s, err := store.NewLayout(destDir)
if err != nil {
t.Fatalf("store.NewLayout: %v", err)
}
o := &flags.LoadOpts{
StoreRootOpts: defaultRootOpts(destDir),
FileName: []string{testHaulArchive},
}
if err := LoadCmd(ctx, o, defaultRootOpts(destDir), defaultCliOpts()); err != nil {
if err := LoadCmd(ctx, o, s, defaultRootOpts(destDir), defaultCliOpts()); err != nil {
t.Fatalf("LoadCmd: %v", err)
}
s, err := store.NewLayout(destDir)
// re-open: LoadCmd writes through its own content.OCI instance, so s's
// in-memory index predates the load.
s, err = store.NewLayout(destDir)
if err != nil {
t.Fatalf("store.NewLayout: %v", err)
t.Fatalf("store.NewLayout (post-load): %v", err)
}
if countArtifactsInStore(t, s) == 0 {
t.Error("expected artifacts in store after LoadCmd")
@@ -135,11 +182,15 @@ func TestLoadCmd_LocalFile(t *testing.T) {
// silently discarded by the OCI pusher. The descriptor count after two
// loads must equal the count after a single load.
singleDir := t.TempDir()
singlePreLoad, err := store.NewLayout(singleDir)
if err != nil {
t.Fatalf("store.NewLayout single (pre-load): %v", err)
}
singleOpts := &flags.LoadOpts{
StoreRootOpts: defaultRootOpts(singleDir),
FileName: []string{testHaulArchive},
}
if err := LoadCmd(ctx, singleOpts, defaultRootOpts(singleDir), defaultCliOpts()); err != nil {
if err := LoadCmd(ctx, singleOpts, singlePreLoad, defaultRootOpts(singleDir), defaultCliOpts()); err != nil {
t.Fatalf("LoadCmd single: %v", err)
}
singleStore, err := store.NewLayout(singleDir)
@@ -149,11 +200,15 @@ func TestLoadCmd_LocalFile(t *testing.T) {
singleCount := countArtifactsInStore(t, singleStore)
doubleDir := t.TempDir()
doublePreLoad, err := store.NewLayout(doubleDir)
if err != nil {
t.Fatalf("store.NewLayout double (pre-load): %v", err)
}
doubleOpts := &flags.LoadOpts{
StoreRootOpts: defaultRootOpts(doubleDir),
FileName: []string{testHaulArchive, testHaulArchive},
}
if err := LoadCmd(ctx, doubleOpts, defaultRootOpts(doubleDir), defaultCliOpts()); err != nil {
if err := LoadCmd(ctx, doubleOpts, doublePreLoad, defaultRootOpts(doubleDir), defaultCliOpts()); err != nil {
t.Fatalf("LoadCmd double: %v", err)
}
doubleStore, err := store.NewLayout(doubleDir)
@@ -192,12 +247,16 @@ func TestLoadCmd_RemoteArchive(t *testing.T) {
destDir := t.TempDir()
remoteURL := srv.URL + "/haul.tar.zst"
preLoad, err := store.NewLayout(destDir)
if err != nil {
t.Fatalf("store.NewLayout (pre-load): %v", err)
}
o := &flags.LoadOpts{
StoreRootOpts: defaultRootOpts(destDir),
FileName: []string{remoteURL},
}
if err := LoadCmd(ctx, o, defaultRootOpts(destDir), defaultCliOpts()); err != nil {
if err := LoadCmd(ctx, o, preLoad, defaultRootOpts(destDir), defaultCliOpts()); err != nil {
t.Fatalf("LoadCmd remote: %v", err)
}
+26 -1
View File
@@ -22,12 +22,14 @@ import (
"hauler.dev/go/hauler/v2/internal/flags"
"hauler.dev/go/hauler/v2/pkg/archives"
"hauler.dev/go/hauler/v2/pkg/audit"
"hauler.dev/go/hauler/v2/pkg/consts"
"hauler.dev/go/hauler/v2/pkg/log"
"hauler.dev/go/hauler/v2/pkg/store"
)
// saves a content store to store archives
func SaveCmd(ctx context.Context, o *flags.SaveOpts, rso *flags.StoreRootOpts, ro *flags.CliRootOpts) error {
func SaveCmd(ctx context.Context, o *flags.SaveOpts, s *store.Layout, rso *flags.StoreRootOpts, ro *flags.CliRootOpts) error {
l := log.FromContext(ctx)
// maps to handle compression and archival types
@@ -94,6 +96,29 @@ func SaveCmd(ctx context.Context, o *flags.SaveOpts, rso *flags.StoreRootOpts, r
l.Infof("saving store [%s] to archive [%s]", o.StoreDir, o.FileName)
}
if auditLevel(ro) != "none" {
e := audit.Entry{
StoreID: s.StoreID,
Store: s.Root,
Command: "store save",
Reference: o.FileName,
}
if auditLevel(ro) == "verbose" {
sys := audit.BuildSystem()
g := audit.BuildGlobal(ro, rso)
e.System = &sys
e.Global = &g
e.Flags = map[string]any{
"platform": o.Platform,
"containerd": o.ContainerdCompatibility,
"chunk-size": o.ChunkSize,
}
}
if err := audit.Append(ro.HaulerDir, e); err != nil {
l.Warnf("failed to write audit entry: %v", err)
}
}
return nil
}
+5 -5
View File
@@ -182,7 +182,7 @@ func TestSaveCmd(t *testing.T) {
archivePath := filepath.Join(t.TempDir(), "haul.tar.zst")
o := newSaveOpts(s.Root, archivePath)
if err := SaveCmd(ctx, o, defaultRootOpts(s.Root), defaultCliOpts()); err != nil {
if err := SaveCmd(ctx, o, s, defaultRootOpts(s.Root), defaultCliOpts()); err != nil {
t.Fatalf("SaveCmd: %v", err)
}
@@ -215,7 +215,7 @@ func TestSaveCmd_ContainerdCompatibility(t *testing.T) {
o := newSaveOpts(s.Root, archivePath)
o.ContainerdCompatibility = true
if err := SaveCmd(ctx, o, defaultRootOpts(s.Root), defaultCliOpts()); err != nil {
if err := SaveCmd(ctx, o, s, defaultRootOpts(s.Root), defaultCliOpts()); err != nil {
t.Fatalf("SaveCmd ContainerdCompatibility: %v", err)
}
@@ -244,7 +244,7 @@ func TestSaveCmd_EmptyStore(t *testing.T) {
archivePath := filepath.Join(t.TempDir(), "haul-empty.tar.zst")
o := newSaveOpts(s.Root, archivePath)
if err := SaveCmd(ctx, o, defaultRootOpts(s.Root), defaultCliOpts()); err != nil {
if err := SaveCmd(ctx, o, s, defaultRootOpts(s.Root), defaultCliOpts()); err != nil {
t.Fatalf("SaveCmd empty store: %v", err)
}
@@ -315,7 +315,7 @@ func TestSaveCmd_ChunkSize(t *testing.T) {
o := newSaveOpts(s.Root, archivePath)
o.ChunkSize = "1K"
if err := SaveCmd(ctx, o, defaultRootOpts(s.Root), defaultCliOpts()); err != nil {
if err := SaveCmd(ctx, o, s, defaultRootOpts(s.Root), defaultCliOpts()); err != nil {
t.Fatalf("SaveCmd with chunk-size: %v", err)
}
@@ -344,7 +344,7 @@ func TestSaveCmd_ChunkSize_Invalid(t *testing.T) {
o := newSaveOpts(s.Root, filepath.Join(t.TempDir(), "haul.tar.zst"))
o.ChunkSize = "0"
if err := SaveCmd(ctx, o, defaultRootOpts(s.Root), defaultCliOpts()); err == nil {
if err := SaveCmd(ctx, o, s, defaultRootOpts(s.Root), defaultCliOpts()); err == nil {
t.Fatal("SaveCmd: expected error for chunk-size=0, got nil")
}
}
+36 -4
View File
@@ -200,9 +200,13 @@ func Append(haulerDir string, e Entry) error {
return globalErr
}
// appendMu serializes appendLine calls: os.OpenFile with O_APPEND is only
// atomic for a single write() syscall on POSIX, and concurrent `store sync`
// image jobs (runImageJobs) can each call this at once without it.
// LogFileName is the audit log's filename, under both haulerDir and a store's Root.
const LogFileName = "audit.log"
// appendMu serializes appendLine/MergeStoreLog calls: os.OpenFile with
// O_APPEND is only atomic for a single write() syscall on POSIX, and
// concurrent `store sync` image jobs (runImageJobs) can each call this at
// once without it.
var appendMu sync.Mutex
func appendLine(dir string, v any) error {
@@ -216,7 +220,7 @@ func appendLine(dir string, v any) error {
if err != nil {
return fmt.Errorf("audit: marshal: %w", err)
}
f, err := os.OpenFile(filepath.Join(dir, "audit.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
f, err := os.OpenFile(filepath.Join(dir, LogFileName), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return fmt.Errorf("audit: open log: %w", err)
}
@@ -225,6 +229,34 @@ func appendLine(dir string, v any) error {
return err
}
// MergeStoreLog appends tempDir's staged audit.log onto destDir's. No-op if tempDir has none.
func MergeStoreLog(tempDir, destDir string) error {
data, err := os.ReadFile(filepath.Join(tempDir, LogFileName))
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("audit: read staged log: %w", err)
}
if len(data) == 0 {
return nil
}
appendMu.Lock()
defer appendMu.Unlock()
if err := os.MkdirAll(destDir, 0o755); err != nil {
return fmt.Errorf("audit: ensure dir: %w", err)
}
f, err := os.OpenFile(filepath.Join(destDir, LogFileName), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return fmt.Errorf("audit: open log: %w", err)
}
defer f.Close()
_, err = f.Write(data)
return err
}
func resolveDir(haulerDir string) string {
if haulerDir != "" {
return haulerDir
+56
View File
@@ -186,6 +186,62 @@ func TestAppend_MultipleEntries(t *testing.T) {
}
}
func TestMergeStoreLog_AppendsOntoExisting(t *testing.T) {
temp, dest := t.TempDir(), t.TempDir()
if err := os.WriteFile(filepath.Join(dest, LogFileName), []byte(`{"command":"original"}`+"\n"), 0o644); err != nil {
t.Fatalf("seed dest log: %v", err)
}
if err := os.WriteFile(filepath.Join(temp, LogFileName), []byte(`{"command":"from-haul"}`+"\n"), 0o644); err != nil {
t.Fatalf("seed temp log: %v", err)
}
if err := MergeStoreLog(temp, dest); err != nil {
t.Fatalf("MergeStoreLog: %v", err)
}
data, err := os.ReadFile(filepath.Join(dest, LogFileName))
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if !strings.Contains(string(data), "original") || !strings.Contains(string(data), "from-haul") {
t.Fatalf("expected both entries preserved, got: %s", data)
}
}
func TestMergeStoreLog_CreatesDestWhenMissing(t *testing.T) {
temp := t.TempDir()
dest := filepath.Join(t.TempDir(), "not-yet-created")
if err := os.WriteFile(filepath.Join(temp, LogFileName), []byte(`{"command":"from-haul"}`+"\n"), 0o644); err != nil {
t.Fatalf("seed temp log: %v", err)
}
if err := MergeStoreLog(temp, dest); err != nil {
t.Fatalf("MergeStoreLog: %v", err)
}
data, err := os.ReadFile(filepath.Join(dest, LogFileName))
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if !strings.Contains(string(data), "from-haul") {
t.Fatalf("expected dest log created with haul's entry, got: %s", data)
}
}
func TestMergeStoreLog_NoopWhenTempHasNoLog(t *testing.T) {
temp, dest := t.TempDir(), t.TempDir()
if err := MergeStoreLog(temp, dest); err != nil {
t.Fatalf("MergeStoreLog: %v", err)
}
if _, err := os.Stat(filepath.Join(dest, LogFileName)); !os.IsNotExist(err) {
t.Fatalf("expected no dest log to be created, stat err: %v", err)
}
}
func TestShortFileRef(t *testing.T) {
tests := []struct {
name string