Repository: rancher/fleet
Pull request #1638 (2023-07-07 09:52:47)

Adapt e2e tests for local infra setup tests
* add infra setup to nightly CI
* add `infra-setup` label to tests that need our infra-setup
* don't run `infra-setup` tests where we can't load images into a local registry (AKS/GKE) and use ttl.sh instead
* dev and github scripts use `$external_ip` instead of `$url` (🚨update your dev environment if you use `$url`)

* add wrapper script for `create-zot-certs`, to keep CI scripts [isolated](https://github.com/rancher/fleet/blob/master/.github/scripts/README.md)
* update `.gitignore`
* make `dev/import-images-tests-k3d` executable
* infra binary should work without cd
* refactor gitrepo test into one describe block, some shared setup
* remove boolean parameter from one test func (:


Patch:
diff --git a/.github/scripts/create-zot-certs.sh b/.github/scripts/create-zot-certs.sh
new file mode 100755
index 00000000..c26d6a4c
--- /dev/null
+++ b/.github/scripts/create-zot-certs.sh
@@ -0,0 +1,54 @@
+#!/bin/bash
+
+CA_name=${1-'FleetCI-RootCA'}
+validity_days=365
+passphrase=foo # this should not matter too much for local, ephemeral certs and keys
+
+mkdir -p $CA_name
+cd $CA_name
+
+must_create_certs ()
+{
+    if [ -z "$(find . -iname '*.crt')" ]; then
+        return 1
+    fi
+
+    age_of_most_recent_cert=$(( $(date +%s) - $(date -r $(ls -1 -t *.crt | head -n 1) +%s) ))
+
+    [ $(( $age_of_most_recent_crt )) -lt $(( validity_days * 24 * 3600 )) ]
+
+    return $?
+}
+
+if [ -z $FLEET_E2E_NS ]; then
+    echo "Error: env var FLEET_E2E_NS is not set. Exiting."
+    exit 1
+fi
+
+must_create_certs
+if [ $? -eq 1 ]; then
+    # generate private key for CA
+    CA_key_name="$CA_name.key"
+    openssl genrsa -aes256 -passout pass:$passphrase -out "$CA_key_name" 4096
+
+    # generate CA cert using private key
+    openssl req -x509 -new -nodes -key "$CA_key_name" -sha256 -days $validity_days -subj "/C=DE/ST=Fleetland/L=Fleetcity/O=Rancher/OU=Fleet/CN=Fleet-Test Root CA" -passin pass:$passphrase -out root.crt
+
+    # generate Zot cert and key
+    openssl req -new -newkey rsa:4096 -sha256 -nodes -subj "/C=DE/ST=Fleetland/L=Fleetcity/O=Rancher/OU=Fleet/CN=Fleet-Test" -out helm.csr -keyout helm.key
+
+    cat > crt.ext << EOF
+    authorityKeyIdentifier=keyid,issuer
+    basicConstraints=CA:FALSE
+    keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment
+    subjectAltName = @alt_names
+    [alt_names]
+    DNS.1 = zot-service.$FLEET_E2E_NS.svc.cluster.local
+    DNS.2 = chartmuseum-service.$FLEET_E2E_NS.svc.cluster.local
+EOF
+
+    # sign Zot cert with CA root key
+    openssl x509 -req -in helm.csr -CA root.crt -CAkey "$CA_key_name" -CAcreateserial -out helm.crt -days $validity_days -extfile crt.ext -passin pass:$passphrase
+else
+    echo "Skipping creation of certificates, as certificates more recent than $validity_days days exist."
+fi
diff --git a/.github/scripts/register-downstream-clusters.sh b/.github/scripts/register-downstream-clusters.sh
index b8aa606b..fbf68e42 100755
--- a/.github/scripts/register-downstream-clusters.sh
+++ b/.github/scripts/register-downstream-clusters.sh
@@ -3,7 +3,7 @@
 set -euxo pipefail
 
 
-url="${url-172.18.0.1.omg.howdoi.website}"
+external_ip="${external_ip-172.18.0.1.omg.howdoi.website}"
 cluster_downstream="${cluster_downstream-k3d-downstream}"
 ctx=$(kubectl config current-context)
 
@@ -41,7 +41,7 @@ userPrincipal:
   provider: local
 EOF
 
-echo -e "4\n" | rancher login "https://$url" --token "$token" --skip-verify
+echo -e "4\n" | rancher login "https://$external_ip" --token "$token" --skip-verify
 
 rancher clusters create second --import
 until rancher cluster ls --format json | jq -r 'select(.Name=="second") | .ID' | grep -Eq "c-[a-z0-9]" ; do sleep 1; done
diff --git a/.github/scripts/setup-rancher.sh b/.github/scripts/setup-rancher.sh
index 351a0c75..f2d49d7d 100755
--- a/.github/scripts/setup-rancher.sh
+++ b/.github/scripts/setup-rancher.sh
@@ -2,7 +2,7 @@
 
 set -euxo pipefail
 
-url="${url-172.18.0.1.omg.howdoi.website}"
+external_ip="${external_ip-172.18.0.1.omg.howdoi.website}"
 fleetns="${fleetns-cattle-fleet-system}"
 upstream_ctx="${FLEET_E2E_CLUSTER-k3d-upstream}"
 version="${1-v2.7.0}"
@@ -21,10 +21,10 @@ helm upgrade rancher rancher-latest/rancher --version "$version" \
   --install --wait \
   --create-namespace \
   --namespace cattle-system \
-  --set hostname="$url" \
+  --set hostname="$external_ip" \
   --set bootstrapPassword=admin \
   --set "extraEnv[0].name=CATTLE_SERVER_URL" \
-  --set "extraEnv[0].value=https://$url" \
+  --set "extraEnv[0].value=https://$external_ip" \
   --set "extraEnv[1].name=CATTLE_BOOTSTRAP_PASSWORD" \
   --set "extraEnv[1].value=rancherpassword"
 
diff --git a/.github/workflows/aks.yml b/.github/workflows/aks.yml
index e314c2da..308d0988 100644
--- a/.github/workflows/aks.yml
+++ b/.github/workflows/aks.yml
@@ -137,7 +137,7 @@ jobs:
           FLEET_E2E_NS: fleet-local
         run: |
           export KUBECONFIG="$GITHUB_WORKSPACE/kubeconfig-fleet-ci"
-          ginkgo e2e/single-cluster
+          ginkgo --label-filter="!infra-setup" e2e/single-cluster
       -
         name: Fleet Tests Requiring Github Secrets
         # These tests can't run for PRs, because PRs don't have access to the secrets
diff --git a/.github/workflows/gke.yml b/.github/workflows/gke.yml
index 4e31462e..75b3436f 100644
--- a/.github/workflows/gke.yml
+++ b/.github/workflows/gke.yml
@@ -139,7 +139,7 @@ jobs:
         env:
           FLEET_E2E_NS: fleet-local
         run: |
-          ginkgo e2e/single-cluster
+          ginkgo --label-filter="!infra-setup" e2e/single-cluster
       -
         name: Fleet Tests Requiring Github Secrets
         # These tests can't run for PRs, because PRs don't have access to the secrets
diff --git a/.github/workflows/nightly-ci.yml b/.github/workflows/nightly-ci.yml
index 2021ea52..f80240e1 100644
--- a/.github/workflows/nightly-ci.yml
+++ b/.github/workflows/nightly-ci.yml
@@ -55,11 +55,14 @@ jobs:
 
           go build -o "bin/fleet-linux-$GOARCH" ./cmd/fleetcli
           go build -o "bin/fleetagent-linux-$GOARCH" ./cmd/fleetagent
+          cd e2e/testenv/infra
+          go build -o . ./...
       -
         name: Build Docker Images
         run: |
           docker build -f package/Dockerfile -t rancher/fleet:dev --build-arg="ARCH=$GOARCH" .
           docker build -f package/Dockerfile.agent -t rancher/fleet-agent:dev --build-arg="ARCH=$GOARCH" .
+          DOCKER_BUILDKIT=1 docker build -f Dockerfile.gitserver -t nginx-git:test --build-arg="passwd=$(openssl passwd foo)" .
       -
         name: Provision k3d Cluster
         uses: AbsaOSS/k3d-action@v2
@@ -75,16 +78,35 @@ jobs:
         name: Import Images Into k3d
         run: |
           k3d image import rancher/fleet:dev rancher/fleet-agent:dev
+          k3d image import nginx-git:test nginx-git:test
       -
         name: Deploy Fleet
         run: |
           ./.github/scripts/deploy-fleet.sh
+      -
+        name: Create Zot certificates for OCI tests
+        env:
+          FLEET_E2E_NS: fleet-local
+        run: |
+          # Generate cert and key for TLS
+          ./.github/scripts/create-zot-certs.sh "FleetCI-RootCA"
       -
         name: E2E Tests
         env:
           FLEET_E2E_NS: fleet-local
+          # Git and OCI credentials are here used in a local, ephemeral environment. Leaks would be harmless.
+          GIT_HTTP_USER: "fleet-ci"
+          GIT_HTTP_PASSWORD: "foo"
+          CI_OCI_USERNAME: "fleet-ci"
+          CI_OCI_PASSWORD: "foo"
+          HELM_PATH: /usr/local/bin/helm
         run: |
+          export CI_OCI_CERTS_DIR="$(git rev-parse --show-toplevel)/FleetCI-RootCA"
+          e2e/testenv/infra/infra setup
+
           ginkgo e2e/single-cluster e2e/keep-resources
+
+          e2e/testenv/infra/infra teardown
       -
         name: Acceptance Tests for Examples
         env:
diff --git a/.github/workflows/rancher-integration.yml b/.github/workflows/rancher-integration.yml
index 89b88ffc..3a78adea 100644
--- a/.github/workflows/rancher-integration.yml
+++ b/.github/workflows/rancher-integration.yml
@@ -160,13 +160,13 @@ jobs:
       -
         name: Set up latest Rancher
         env:
-          url: "172.18.0.1.omg.howdoi.website"
+          external_ip: "172.18.0.1.omg.howdoi.website"
         run: |
           ./.github/scripts/setup-latest-rancher.sh
       -
         name: Register Rancher's downstream clusters
         env:
-          url: "172.18.0.1.omg.howdoi.website"
+          external_ip: "172.18.0.1.omg.howdoi.website"
         run: |
           ./.github/scripts/wait-for-loadbalancer.sh
           ./.github/scripts/register-downstream-clusters.sh
diff --git a/.github/workflows/rancher-upgrade-fleet.yml b/.github/workflows/rancher-upgrade-fleet.yml
index b4ac6bab..324ac440 100644
--- a/.github/workflows/rancher-upgrade-fleet.yml
+++ b/.github/workflows/rancher-upgrade-fleet.yml
@@ -124,7 +124,7 @@ jobs:
       -
         name: Set up Rancher
         env:
-          url: "172.18.0.1.omg.howdoi.website"
+          external_ip: "172.18.0.1.omg.howdoi.website"
           fleetns: "${{ matrix.rancher_version == 'v2.5.16' && 'fleet-system' || 'cattle-fleet-system' }}"
         run: |
           ./.github/scripts/setup-rancher.sh "${{matrix.rancher_version}}"
diff --git a/.gitignore b/.gitignore
index 934c1ff2..39a25da6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,3 +14,5 @@ docs/fleet-controller/
 e2e/testenv/infra/infra
 *.tgz
 ^fleet$
+FleetCI-RootCA
+.envrc
diff --git a/dev/README.md b/dev/README.md
index 17c94900..4684b127 100644
--- a/dev/README.md
+++ b/dev/README.md
@@ -50,8 +50,8 @@ This should set up k3d, and the fleet standalone images for single cluster tests
     dev/import-images-k3d
     # needed for gitrepo tests
     dev/import-images-tests-k3d
-    dev/setup-fleet
     dev/create-zot-certs 'FleetCI-RootCA' # for OCI tests
+    dev/setup-fleet
 
     # This should be needed only once
     cd e2e/testenv/infra
diff --git a/dev/create-zot-certs b/dev/create-zot-certs
index c26d6a4c..f413b914 100755
--- a/dev/create-zot-certs
+++ b/dev/create-zot-certs
@@ -1,54 +1,3 @@
 #!/bin/bash
 
-CA_name=${1-'FleetCI-RootCA'}
-validity_days=365
-passphrase=foo # this should not matter too much for local, ephemeral certs and keys
-
-mkdir -p $CA_name
-cd $CA_name
-
-must_create_certs ()
-{
-    if [ -z "$(find . -iname '*.crt')" ]; then
-        return 1
-    fi
-
-    age_of_most_recent_cert=$(( $(date +%s) - $(date -r $(ls -1 -t *.crt | head -n 1) +%s) ))
-
-    [ $(( $age_of_most_recent_crt )) -lt $(( validity_days * 24 * 3600 )) ]
-
-    return $?
-}
-
-if [ -z $FLEET_E2E_NS ]; then
-    echo "Error: env var FLEET_E2E_NS is not set. Exiting."
-    exit 1
-fi
-
-must_create_certs
-if [ $? -eq 1 ]; then
-    # generate private key for CA
-    CA_key_name="$CA_name.key"
-    openssl genrsa -aes256 -passout pass:$passphrase -out "$CA_key_name" 4096
-
-    # generate CA cert using private key
-    openssl req -x509 -new -nodes -key "$CA_key_name" -sha256 -days $validity_days -subj "/C=DE/ST=Fleetland/L=Fleetcity/O=Rancher/OU=Fleet/CN=Fleet-Test Root CA" -passin pass:$passphrase -out root.crt
-
-    # generate Zot cert and key
-    openssl req -new -newkey rsa:4096 -sha256 -nodes -subj "/C=DE/ST=Fleetland/L=Fleetcity/O=Rancher/OU=Fleet/CN=Fleet-Test" -out helm.csr -keyout helm.key
-
-    cat > crt.ext << EOF
-    authorityKeyIdentifier=keyid,issuer
-    basicConstraints=CA:FALSE
-    keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment
-    subjectAltName = @alt_names
-    [alt_names]
-    DNS.1 = zot-service.$FLEET_E2E_NS.svc.cluster.local
-    DNS.2 = chartmuseum-service.$FLEET_E2E_NS.svc.cluster.local
-EOF
-
-    # sign Zot cert with CA root key
-    openssl x509 -req -in helm.csr -CA root.crt -CAkey "$CA_key_name" -CAcreateserial -out helm.crt -days $validity_days -extfile crt.ext -passin pass:$passphrase
-else
-    echo "Skipping creation of certificates, as certificates more recent than $validity_days days exist."
-fi
+./.github/scripts/create-zot-certs.sh "$@"
diff --git a/dev/setup-fleet-downstream b/dev/setup-fleet-downstream
index 2da3f9aa..5ec1bb87 100755
--- a/dev/setup-fleet-downstream
+++ b/dev/setup-fleet-downstream
@@ -54,13 +54,13 @@ else
 fi
 
 # docker network inspect bridge -f '{{(index .IPAM.Config 0).Gateway}}'
-url="${url-172.17.0.1.omg.howdoi.website}"
+external_ip="${external_ip-172.17.0.1.omg.howdoi.website}"
 
 kubectl config use-context "$downstream_ctx"
 helm -n cattle-fleet-system upgrade --install --create-namespace --wait fleet-agent charts/fleet-agent \
   --set-string labels.env=test \
   --set apiServerCA="$ca" \
-  --set apiServerURL="https://$url:36443" \
+  --set apiServerURL="https://$external_ip:36443" \
   --set clusterNamespace="$ns" \
   --set token="$token"
   #--set systemRegistrationNamespace="fleet-clusters-system" \
diff --git a/dev/setup-rancher-clusters b/dev/setup-rancher-clusters
index 3b801db2..ea4c8b4c 100755
--- a/dev/setup-rancher-clusters
+++ b/dev/setup-rancher-clusters
@@ -7,7 +7,7 @@ if [ ! -d ./.github/scripts ]; then
   exit 1
 fi
 
-url="${url-172.18.0.1.omg.howdoi.website}"
+external_ip="${external_ip-172.18.0.1.omg.howdoi.website}"
 upstream_ctx="${FLEET_E2E_CLUSTER-k3d-upstream}"
 downstream_ctx="${FLEET_E2E_CLUSTER_DOWNSTREAM-k3d-downstream}"
 
@@ -31,10 +31,10 @@ helm upgrade rancher rancher-latest/rancher --version "$version" \
   --install --wait \
   --create-namespace \
   --namespace cattle-system \
-  --set hostname="$url" \
+  --set hostname="$external_ip" \
   --set bootstrapPassword=admin \
   --set "extraEnv[0].name=CATTLE_SERVER_URL" \
-  --set "extraEnv[0].value=https://$url" \
+  --set "extraEnv[0].value=https://$external_ip" \
   --set "extraEnv[1].name=CATTLE_BOOTSTRAP_PASSWORD" \
   --set "extraEnv[1].value=rancherpassword"
 
@@ -48,6 +48,6 @@ kubectl -n cattle-fleet-system rollout status deploy/fleet-controller
 ./.github/scripts/wait-for-loadbalancer.sh
 
 export cluster_downstream="$downstream_ctx"
-./.github/scripts/register-downstream-clusters.sh "$url"
+./.github/scripts/register-downstream-clusters.sh "$external_ip"
 
 ./.github/scripts/label-downstream-cluster.sh
diff --git a/internal/bundlereader/loaddirectory.go b/internal/bundlereader/loaddirectory.go
index 0c97a954..994e84e1 100644
--- a/internal/bundlereader/loaddirectory.go
+++ b/internal/bundlereader/loaddirectory.go
@@ -1,15 +1,18 @@
 package bundlereader
 
 import (
+	"bufio"
 	"context"
 	"crypto/tls"
 	"crypto/x509"
 	"encoding/base64"
 	"fmt"
+	"io/fs"
 	"net/http"
 	"net/url"
 	"os"
 	"path/filepath"
+	"regexp"
 	"strings"
 	"unicode/utf8"
 
@@ -23,10 +26,133 @@ import (
 	"helm.sh/helm/v3/pkg/registry"
 )
 
+// ignoreTree represents a tree of ignored paths (read from .fleetignore files), each node being a directory.
+// It provides a means for ignored paths to be propagated down the tree, but not between subdirectories of a same
+// directory.
+type ignoreTree struct {
+	path         string
+	ignoredPaths []string
+	children     []*ignoreTree
+}
+
+// isIgnored checks whether any path within xt matches path, and returns true if so.
+func (xt *ignoreTree) isIgnored(path string) (bool, error) {
+	steps := xt.findNode(path, false, nil)
+
+	for _, step := range steps {
+		for _, ignoredPath := range step.ignoredPaths {
+			toIgnore, err := filepath.Match(ignoredPath, filepath.Base(path))
+			if err != nil {
+				return false, err
+			}
+
+			if toIgnore {
+				return true, nil
+			}
+		}
+	}
+
+	return false, nil
+}
+
+// addNode reads a `.fleetignore` file in dir's root and adds each of its entries to ignored paths for dir.
+// Returns an error if a `.fleetignore` file exists for dir but reading it fails.
+func (xt *ignoreTree) addNode(dir string) error {
+	toIgnore, err := readFleetIgnore(dir)
+	if err != nil {
+		return fmt.Errorf("read .fleetignore for %s: %v", dir, err)
+	}
+
+	if len(toIgnore) == 0 {
+		return nil
+	}
+
+	steps := xt.findNode(dir, true, nil)
+	if steps == nil {
+		return fmt.Errorf("ignore tree node not found for path %q", dir)
+	}
+
+	destNode := steps[len(steps)-1]
+	destNode.ignoredPaths = append(destNode.ignoredPaths, toIgnore...)
+
+	return nil
+}
+
+// findNode finds the right node for path, creating that node if needed and if isDir is true.
+// Returns a slice representing all relevant nodes in the path to the destination, in order of traversal from the root.
+// The last element of that slice is the destination node.
+func (xt *ignoreTree) findNode(path string, isDir bool, nodesRoute []*ignoreTree) []*ignoreTree {
+	if path == xt.path {
+		return append(nodesRoute, xt)
+	}
+
+	// The path doesn't even belong in the tree. This should never happen.
+	if !strings.HasPrefix(path, xt.path) {
+		return nil
+	}
+
+	for _, c := range xt.children {
+		if steps := c.findNode(path, isDir, nodesRoute); steps != nil {
+			crossed := append(nodesRoute, steps...)
+
+			return crossed
+		}
+	}
+
+	if isDir {
+		xt.children = append(xt.children, &ignoreTree{path: path})
+
+		createdChild := xt.children[len(xt.children)-1]
+
+		return append(nodesRoute, createdChild)
+	}
+
+	return append(nodesRoute, xt)
+}
+
+// readFleetIgnore reads a possible .fleetignore file within path and returns its entries as a slice of strings.
+// If no .fleetignore exists, then an empty slice and a nil error are returned.
+// If an error happens while opening an existing .fleetignore file, that error is returned along with an empty slice.
+func readFleetIgnore(path string) ([]string, error) {
+	file, err := os.Open(filepath.Join(path, ".fleetignore"))
+	if err != nil {
+		// No ignored paths to add if no .fleetignore exists.
+		if os.IsNotExist(err) {
+			return nil, nil
+		}
+		return nil, err
+	}
+
+	defer file.Close()
+
+	scanner := bufio.NewScanner(file)
+	scanner.Split(bufio.ScanLines)
+
+	var ignored []string
+
+	trailingSpaceRegex := regexp.MustCompile(`([^\\])\s+$`)
+
+	for scanner.Scan() {
+		path := scanner.Text()
+
+		// Trim trailing spaces unless escaped.
+		path = trailingSpaceRegex.ReplaceAllString(path, "$1")
+
+		// Ignore empty lines and comments (although they should not match any file).
+		if path == "" || strings.HasPrefix(path, "#") {
+			continue
+		}
+
+		ignored = append(ignored, path)
+	}
+
+	return ignored, nil
+}
+
 func loadDirectory(ctx context.Context, compress bool, prefix, base, source, version string, auth Auth) ([]fleet.BundleResource, error) {
 	var resources []fleet.BundleResource
 
-	files, err := getContent(ctx, base, source, version, auth)
+	files, err := GetContent(ctx, base, source, version, auth)
 	if err != nil {
 		return nil, err
 	}
@@ -52,8 +178,8 @@ func loadDirectory(ctx context.Context, compress bool, prefix, base, source, ver
 	return resources, nil
 }
 
-// getContent uses go-getter (and helm for oci) to read the files from directories and servers
-func getContent(ctx context.Context, base, source, version string, auth Auth) (map[string][]byte, error) {
+// GetContent uses go-getter (and Helm for OCI) to read the files from directories and servers.
+func GetContent(ctx context.Context, base, source, version string, auth Auth) (map[string][]byte, error) {
 	temp, err := os.MkdirTemp("", "fleet")
 	if err != nil {
 		return nil, err
@@ -120,23 +246,37 @@ func getContent(ctx context.Context, base, source, version string, auth Auth) (m
 		temp = dest
 	}
 
-	err = filepath.Walk(temp, func(path string, info os.FileInfo, err error) error {
+	ignoredPaths := ignoreTree{path: temp}
+
+	err = filepath.WalkDir(temp, func(path string, info fs.DirEntry, err error) error {
+		if err != nil {
+			return err
+		}
+
+		name, err := filepath.Rel(temp, path)
+		if err != nil {
+			return err
+		}
+
+		ignore, err := ignoredPaths.isIgnored(path)
 		if err != nil {
 			return err
 		}
 
 		if info.IsDir() {
-			if strings.HasPrefix(filepath.Base(path), ".") {
+			// Skip .fleetignore'd and hidden directories
+			if ignore || strings.HasPrefix(filepath.Base(path), ".") {
 				return filepath.SkipDir
 			}
-			return nil
+
+			return ignoredPaths.addNode(path)
 		}
 
-		name, err := filepath.Rel(temp, path)
-		if err != nil {
-			return err
+		if ignore {
+			return nil
 		}
 
+		// Skip hidden files
 		if strings.HasPrefix(filepath.Base(name), ".") {
 			return nil
 		}


Test patch:
diff --git a/.github/scripts/setup-latest-rancher.sh b/.github/scripts/setup-latest-rancher.sh
index 012cd6ab..5a7d19ce 100755
--- a/.github/scripts/setup-latest-rancher.sh
+++ b/.github/scripts/setup-latest-rancher.sh
@@ -2,7 +2,7 @@
 
 set -euxo pipefail
 
-url="${url-172.18.0.1.omg.howdoi.website}"
+external_ip="${external_ip-172.18.0.1.omg.howdoi.website}"
 cluster="${cluster-k3d-upstream}"
 
 kubectl config use-context "$cluster"
@@ -15,10 +15,10 @@ helm upgrade rancher rancher-latest/rancher \
   --install --wait \
   --create-namespace \
   --namespace cattle-system \
-  --set hostname="$url" \
+  --set hostname="$external_ip" \
   --set bootstrapPassword=admin \
   --set "extraEnv[0].name=CATTLE_SERVER_URL" \
-  --set "extraEnv[0].value=https://$url" \
+  --set "extraEnv[0].value=https://$external_ip" \
   --set "extraEnv[1].name=CATTLE_BOOTSTRAP_PASSWORD" \
   --set "extraEnv[1].value=rancherpassword"
 
diff --git a/.github/workflows/e2e-ci.yml b/.github/workflows/e2e-ci.yml
index 9d36fb5c..1f54fba1 100644
--- a/.github/workflows/e2e-ci.yml
+++ b/.github/workflows/e2e-ci.yml
@@ -57,6 +57,8 @@ jobs:
 
           go build -o "bin/fleet-linux-$GOARCH" ./cmd/fleetcli
           go build -o "bin/fleetagent-linux-$GOARCH" ./cmd/fleetagent
+          cd e2e/testenv/infra
+          go build -o . ./...
       -
         name: Build Docker Images
         run: |
@@ -98,7 +100,7 @@ jobs:
           FLEET_E2E_NS: fleet-local
         run: |
           # Generate cert and key for TLS
-          ./dev/create-zot-certs "FleetCI-RootCA"
+          ./.github/scripts/create-zot-certs.sh "FleetCI-RootCA"
       -
         name: E2E Tests
         env:
@@ -111,17 +113,11 @@ jobs:
           HELM_PATH: /usr/local/bin/helm
         run: |
           export CI_OCI_CERTS_DIR="$(git rev-parse --show-toplevel)/FleetCI-RootCA"
-
-          cd e2e/testenv/infra
-          go build -o . ./...
-          ./infra setup
-          cd -
+          e2e/testenv/infra/infra setup
 
           ginkgo e2e/single-cluster e2e/keep-resources
 
-          cd -
-
-          ./infra teardown
+          e2e/testenv/infra/infra teardown
       -
         name: Acceptance Tests for Examples
         if: >
diff --git a/dev/import-images-tests-k3d b/dev/import-images-tests-k3d
old mode 100644
new mode 100755
index 42fad61e..c45fbb76
--- a/dev/import-images-tests-k3d
+++ b/dev/import-images-tests-k3d
@@ -7,6 +7,7 @@ upstream_ctx="${FLEET_E2E_CLUSTER-k3d-upstream}"
 
 gitSrvImage=$( docker image ls -q nginx-git:test )
 
+GIT_HTTP_PASSWORD=${GIT_HTTP_PASSWORD-foo}
 if [ -n "${FORCE_GIT_SERVER_BUILD-}" -o -z "$gitSrvImage" ]; then
     cd e2e/assets/gitrepo
     docker build . -f Dockerfile.gitserver --build-arg passwd=$(openssl passwd $GIT_HTTP_PASSWORD) -t nginx-git:test
diff --git a/e2e/single-cluster/gitrepo_test.go b/e2e/single-cluster/gitrepo_test.go
index cd474125..25ad20a1 100644
--- a/e2e/single-cluster/gitrepo_test.go
+++ b/e2e/single-cluster/gitrepo_test.go
@@ -25,18 +25,22 @@ const (
 	repoName = "repo"
 )
 
-var _ = Describe("Git Repo with polling", func() {
+var _ = Describe("Monitoring Git repos via HTTP for change", Label("infra-setup"), func() {
 	var (
-		tmpdir   string
-		clonedir string
-		k        kubectl.Command
-		gh       *githelper.Git
-		clone    *git.Repository
+		tmpdir           string
+		clonedir         string
+		k                kubectl.Command
+		gh               *githelper.Git
+		clone            *git.Repository
+		repoName         string
+		inClusterRepoURL string
 	)
 
 	BeforeEach(func() {
 		k = env.Kubectl.Namespace(env.Namespace)
+	})
 
+	JustBeforeEach(func() {
 		// Build git repo URL reachable _within_ the cluster, for the GitRepo
 		host, err := githelper.BuildGitHostname(env.Namespace)
 		Expect(err).ToNot(HaveOccurred())
@@ -45,25 +49,10 @@ var _ = Describe("Git Repo with polling", func() {
 		Expect(err).ToNot(HaveOccurred())
 		gh = githelper.NewHTTP(addr)
 
-		inClusterRepoURL := gh.GetInClusterURL(host, port, repoName)
-
-		err = testenv.ApplyTemplate(k, testenv.AssetPath("gitrepo/gitrepo.yaml"), struct {
-			Repo            string
-			Branch          string
-			PollingInterval string
-		}{
-			inClusterRepoURL,
-			gh.Branch,
-			"15s", // default
-		})
-		Expect(err).ToNot(HaveOccurred())
+		inClusterRepoURL = gh.GetInClusterURL(host, port, repoName)
 
 		tmpdir, _ = os.MkdirTemp("", "fleet-")
-
 		clonedir = path.Join(tmpdir, repoName)
-
-		clone, err = gh.Create(clonedir, testenv.AssetPath("gitrepo/sleeper-chart"), "examples")
-		Expect(err).ToNot(HaveOccurred())
 	})
 
 	AfterEach(func() {
@@ -72,6 +61,26 @@ var _ = Describe("Git Repo with polling", func() {
 	})
 
 	When("updating a git repository monitored via polling", func() {
+		BeforeEach(func() {
+			repoName = "repo"
+		})
+
+		JustBeforeEach(func() {
+			err := testenv.ApplyTemplate(k, testenv.AssetPath("gitrepo/gitrepo.yaml"), struct {
+				Repo            string
+				Branch          string
+				PollingInterval string
+			}{
+				inClusterRepoURL,
+				gh.Branch,
+				"15s", // default
+			})
+			Expect(err).ToNot(HaveOccurred())
+
+			clone, err = gh.Create(clonedir, testenv.AssetPath("gitrepo/sleeper-chart"), "examples")
+			Expect(err).ToNot(HaveOccurred())
+		})
+
 		It("updates the deployment", func() {
 			By("checking the pod exists")
 			Eventually(func() string {
@@ -99,95 +108,69 @@ var _ = Describe("Git Repo with polling", func() {
 			}).Should(ContainSubstring("newsleep"))
 		})
 	})
-})
-
-var _ = Describe("Git Repo with webhook", func() {
-	var (
-		tmpdir   string
-		clonedir string
-		k        kubectl.Command
-		gh       *githelper.Git
-		clone    *git.Repository
-	)
 
-	BeforeEach(func() {
-		k = env.Kubectl.Namespace(env.Namespace)
-		repoName := "webhook-test"
-
-		// Build git repo URL reachable _within_ the cluster, for the GitRepo
-		host, err := githelper.BuildGitHostname(env.Namespace)
-		Expect(err).ToNot(HaveOccurred())
-
-		addr, err := githelper.GetExternalRepoAddr(env, port, repoName)
-		Expect(err).ToNot(HaveOccurred())
-		gh = githelper.NewHTTP(addr)
+	When("updating a git repository monitored via webhook", func() {
+		BeforeEach(func() {
+			repoName = "webhook-test"
+		})
 
-		// Get git server pod name and create post-receive hook script from template
-		out, err := k.Get("pod", "-l", "app=git-server", "-o", "name")
-		Expect(err).ToNot(HaveOccurred())
+		JustBeforeEach(func() {
+			// Get git server pod name and create post-receive hook script from template
+			out, err := k.Get("pod", "-l", "app=git-server", "-o", "name")
+			Expect(err).ToNot(HaveOccurred())
 
-		gitServerPod := strings.TrimPrefix(strings.TrimSpace(out), "pod/")
+			gitServerPod := strings.TrimPrefix(strings.TrimSpace(out), "pod/")
 
-		tmpdir, _ = os.MkdirTemp("", "fleet-")
-		hookScript := path.Join(tmpdir, "hook_script")
+			hookScript := path.Join(tmpdir, "hook_script")
 
-		inClusterRepoURL := gh.GetInClusterURL(host, port, repoName)
+			err = testenv.Template(hookScript, testenv.AssetPath("gitrepo/post-receive.sh"), struct {
+				RepoURL string
+			}{
+				inClusterRepoURL,
+			})
+			Expect(err).ToNot(HaveOccurred())
 
-		err = testenv.Template(hookScript, testenv.AssetPath("gitrepo/post-receive.sh"), struct {
-			RepoURL string
-		}{
-			inClusterRepoURL,
-		})
-		Expect(err).ToNot(HaveOccurred())
+			// Create a git repo, erasing a previous repo with the same name if any
+			out, err = k.Run(
+				"exec",
+				gitServerPod,
+				"--",
+				"/bin/sh",
+				"-c",
+				fmt.Sprintf(
+					`dir=/srv/git/%s; rm -rf "$dir"; mkdir -p "$dir"; git init "$dir" --bare; GIT_DIR="$dir" git update-server-info`,
+					repoName,
+				),
+			)
+			Expect(err).ToNot(HaveOccurred(), out)
+
+			// Copy the script into the repo on the server pod
+			hookPathInRepo := fmt.Sprintf("/srv/git/%s/hooks/post-receive", repoName)
+
+			out, err = k.Run("cp", hookScript, fmt.Sprintf("%s:%s", gitServerPod, hookPathInRepo))
+			Expect(err).ToNot(HaveOccurred(), out)
+
+			// Make hook script executable
+			out, err = k.Run("exec", gitServerPod, "--", "chmod", "+x", hookPathInRepo)
+			Expect(err).ToNot(HaveOccurred(), out)
+
+			err = testenv.ApplyTemplate(k, testenv.AssetPath("gitrepo/gitrepo.yaml"), struct {
+				Repo            string
+				Branch          string
+				PollingInterval string
+			}{
+				inClusterRepoURL,
+				gh.Branch,
+				"24h", // prevent polling
+			})
+			Expect(err).ToNot(HaveOccurred())
 
-		// Create a git repo, erasing a previous repo with the same name if any
-		out, err = k.Run(
-			"exec",
-			gitServerPod,
-			"--",
-			"/bin/sh",
-			"-c",
-			fmt.Sprintf(
-				`dir=/srv/git/%s; rm -rf "$dir"; mkdir -p "$dir"; git init "$dir" --bare; GIT_DIR="$dir" git update-server-info`,
-				repoName,
-			),
-		)
-		Expect(err).ToNot(HaveOccurred(), out)
-
-		// Copy the script into the repo on the server pod
-		hookPathInRepo := fmt.Sprintf("/srv/git/%s/hooks/post-receive", repoName)
-
-		out, err = k.Run("cp", hookScript, fmt.Sprintf("%s:%s", gitServerPod, hookPathInRepo))
-		Expect(err).ToNot(HaveOccurred(), out)
-
-		// Make hook script executable
-		out, err = k.Run("exec", gitServerPod, "--", "chmod", "+x", hookPathInRepo)
-		Expect(err).ToNot(HaveOccurred(), out)
-
-		err = testenv.ApplyTemplate(k, testenv.AssetPath("gitrepo/gitrepo.yaml"), struct {
-			Repo            string
-			Branch          string
-			PollingInterval string
-		}{
-			inClusterRepoURL,
-			gh.Branch,
-			"24h", // prevent polling
+			// Clone previously created repo
+			clone, err = gh.Create(clonedir, testenv.AssetPath("gitrepo/sleeper-chart"), "examples")
+			Expect(err).ToNot(HaveOccurred())
 		})
-		Expect(err).ToNot(HaveOccurred())
 
-		// Clone previously created repo
-		clonedir = path.Join(tmpdir, "clone")
-		clone, err = gh.Create(clonedir, testenv.AssetPath("gitrepo/sleeper-chart"), "examples")
-		Expect(err).ToNot(HaveOccurred())
-	})
 
-	AfterEach(func() {
-		os.RemoveAll(tmpdir)
-		_, _ = k.Delete("gitrepo", "gitrepo-test")
-		_, _ = k.Delete("configmap", "hook-script")
-	})
-
-	When("updating a git repository monitored via webhook", func() {
 		It("updates the deployment", func() {
 			By("checking the pod exists")
 			Eventually(func() string {
diff --git a/e2e/single-cluster/helm_auth_test.go b/e2e/single-cluster/helm_auth_test.go
index 5c2e648f..7e694f06 100644
--- a/e2e/single-cluster/helm_auth_test.go
+++ b/e2e/single-cluster/helm_auth_test.go
@@ -12,7 +12,7 @@ import (
 	. "github.com/onsi/gomega"
 )
 
-var _ = Describe("Single Cluster Examples", func() {
+var _ = Describe("Single Cluster Examples", Label("infra-setup"), func() {
 	var (
 		gitRepoPath string
 		tmpdir      string
diff --git a/e2e/single-cluster/imagescan_test.go b/e2e/single-cluster/imagescan_test.go
index c4742c4e..453b062e 100644
--- a/e2e/single-cluster/imagescan_test.go
+++ b/e2e/single-cluster/imagescan_test.go
@@ -12,7 +12,7 @@ import (
 	. "github.com/onsi/gomega"
 )
 
-var _ = Describe("Image Scan", func() {
+var _ = Describe("Image Scan", Label("infra-setup"), func() {
 	var (
 		tmpdir   string
 		clonedir string
diff --git a/e2e/testenv/githelper/git.go b/e2e/testenv/githelper/git.go
index ad4f785f..79918e53 100644
--- a/e2e/testenv/githelper/git.go
+++ b/e2e/testenv/githelper/git.go
@@ -109,46 +109,46 @@ type Git struct {
 
 // NewHTTP creates a new Git instance with HTTP auth, using environment variables.
 func NewHTTP(addr string) *Git {
-	return newGit(addr, false)
+	g := newGit()
+	g.Auth = &HTTPAuth{
+		Username: os.Getenv("GIT_HTTP_USER"),
+		Password: os.Getenv("GIT_HTTP_PASSWORD"),
+	}
+	if addr != "" {
+		g.Auth.setURL(addr)
+	} else {
+		g.Auth.setURL(os.Getenv("GIT_REPO_URL"))
+	}
+	g.Auth.check()
+
+	return g
 }
 
 // NewSSH creates a new Git instance with SSH auth, using environment variables.
 func NewSSH() *Git {
-	return newGit("", true)
+	g := newGit()
+	g.Auth = &SSHAuth{
+		User:      os.Getenv("GIT_REPO_USER"),
+		SSHKey:    os.Getenv("GIT_SSH_KEY"),
+		SSHPubKey: os.Getenv("GIT_SSH_PUBKEY"),
+	}
+	g.Auth.setURL(os.Getenv("GIT_REPO_URL"))
+	g.Auth.check()
+
+	return g
 }
 
 // newGit creates a new Git instance using environment variables.
-func newGit(url string, isSSH bool) *Git {
+func newGit() *Git {
 	g := &Git{
 		User:   os.Getenv("GIT_REPO_USER"),
 		Branch: os.Getenv("GIT_REPO_BRANCH"),
 	}
 
-	if isSSH {
-		g.Auth = &SSHAuth{
-			User:      os.Getenv("GIT_REPO_USER"),
-			SSHKey:    os.Getenv("GIT_SSH_KEY"),
-			SSHPubKey: os.Getenv("GIT_SSH_PUBKEY"),
-		}
-	} else {
-		g.Auth = &HTTPAuth{
-			Username: os.Getenv("GIT_HTTP_USER"),
-			Password: os.Getenv("GIT_HTTP_PASSWORD"),
-		}
-	}
-
-	if url != "" {
-		g.Auth.setURL(url)
-	} else {
-		g.Auth.setURL(os.Getenv("GIT_REPO_URL"))
-	}
-
 	if g.Branch == "" {
 		g.Branch = "master"
 	}
 
-	g.Auth.check()
-
 	return g
 }
 
@@ -212,7 +212,7 @@ func (g *Git) Create(repodir string, from string, subdir string) (*git.Repositor
 	}
 	k, err := g.Auth.getKeys()
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("failed to getKeys: %w", err)
 	}
 
 	if k != nil {
@@ -294,7 +294,7 @@ func GetExternalRepoAddr(env *testenv.Env, port int, repoName string) (string, e
 
 	externalIP, err := systemk.Get("service", "git-service", "-o", "jsonpath={.status.loadBalancer.ingress[0].ip}")
 	if err != nil {
-		return "", err
+		return "", fmt.Errorf("failed to get ingress ip for git-service in %s: %w", env.Namespace, err)
 	}
 
 	return fmt.Sprintf("http://%s:%d/%s", externalIP, port, repoName), nil
diff --git a/e2e/testenv/infra/cmd/setup.go b/e2e/testenv/infra/cmd/setup.go
index bc5183bd..2c93b8fa 100644
--- a/e2e/testenv/infra/cmd/setup.go
+++ b/e2e/testenv/infra/cmd/setup.go
@@ -67,9 +67,12 @@ Parallelism is used when possible to save time.`,
 			fail(fmt.Errorf("create Helm registry client: %v", err))
 		}
 
-		externalIP, err := k.Get("service", "zot-service", "-o", "jsonpath={.status.loadBalancer.ingress[0].ip}")
-		if err != nil {
-			fail(fmt.Errorf("get external Zot service IP: %v", err))
+		externalIP := os.Getenv("external_ip")
+		if externalIP == "" {
+			externalIP, err = k.Get("service", "zot-service", "-o", "jsonpath={.status.loadBalancer.ingress[0].ip}")
+			if err != nil {
+				fail(fmt.Errorf("get external Zot service IP: %v", err))
+			}
 		}
 
 		helmHost := fmt.Sprintf("%s:5000", externalIP)
diff --git a/internal/bundlereader/loaddirectory_test.go b/internal/bundlereader/loaddirectory_test.go
new file mode 100644
index 00000000..fabe4573
--- /dev/null
+++ b/internal/bundlereader/loaddirectory_test.go
@@ -0,0 +1,310 @@
+package bundlereader_test
+
+import (
+	"context"
+	"io"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/rancher/fleet/internal/bundlereader"
+)
+
+// fsNode represents a directory structure used to model `.fleetignore` test cases.
+type fsNode struct {
+	name string
+
+	contents string   // if not a directory
+	children []fsNode // non-empty only in case of a directory
+
+	isDir bool
+}
+
+func TestGetContent(t *testing.T) {
+	cases := []struct {
+		name               string
+		directoryStructure fsNode
+		expectedFiles      map[string][]byte
+	}{
+		{
+			name: "no .fleetignore",
+			directoryStructure: fsNode{
+				isDir: true,
+				name:  "no-fleetignore",
+				children: []fsNode{
+					{
+						name:     "something.yaml",
+						contents: "foo",
+					},
+				},
+			},
+			expectedFiles: map[string][]byte{
+				"something.yaml": []byte("foo"),
+			},
+		},
+		{
+			name: "empty .fleetignore",
+			directoryStructure: fsNode{
+				isDir: true,
+				name:  "empty-fleetignore",
+				children: []fsNode{
+					{
+						name:     "something.yaml",
+						contents: "foo",
+					},
+					{
+						name:     ".fleetignore",
+						contents: "",
+					},
+				},
+			},
+			expectedFiles: map[string][]byte{
+				"something.yaml": []byte("foo"),
+			},
+		},
+		{
+			name: "ignore lines with leading # unless escaped",
+			directoryStructure: fsNode{
+				isDir: true,
+				name:  "comments",
+				children: []fsNode{
+					{
+						name:     "something.yaml",
+						contents: "foo",
+					},
+					{
+						name:     "#something_else.yaml",
+						contents: "bar",
+					},
+					{
+						name:     ".fleetignore",
+						contents: "#something.yaml\n\\#something_else.yaml",
+					},
+				},
+			},
+			expectedFiles: map[string][]byte{
+				"something.yaml": []byte("foo"),
+			},
+		},
+		{
+			name: "simple .fleetignore",
+			directoryStructure: fsNode{
+				isDir: true,
+				name:  "simple-fleetignore",
+				children: []fsNode{
+					{
+						name:     "something.yaml",
+						contents: "foo",
+					},
+					{
+						name:     "something_else.yaml",
+						contents: "bar",
+					},
+					{
+						name:     ".fleetignore",
+						contents: "not_here.txt\nsomething.yaml",
+					},
+				},
+			},
+			expectedFiles: map[string][]byte{
+				"something_else.yaml": []byte("bar"),
+			},
+		},
+		{
+			name: "glob syntax",
+			directoryStructure: fsNode{
+				isDir: true,
+				name:  "glob-syntax",
+				children: []fsNode{
+					{
+						name:     "something.yaml",
+						contents: "foo",
+					},
+					{
+						name:     ".fleetignore",
+						contents: "something*",
+					},
+				},
+			},
+			expectedFiles: map[string][]byte{},
+		},
+		{
+			name: "ignore trailing spaces unless escaped",
+			directoryStructure: fsNode{
+				isDir: true,
+				name:  "trim-space",
+				children: []fsNode{
+					{
+						name:     "something.yaml ",
+						contents: "foo",
+					},
+					{
+						name:     "something_else.yaml  ",
+						contents: "bar",
+					},
+					{
+						name:     ".fleetignore",
+						contents: "something_else.yaml\\ \\ \nsomething.yaml ",
+					},
+				},
+			},
+			expectedFiles: map[string][]byte{
+				"something.yaml ": []byte("foo"),
+			},
+		},
+		{
+			name: "ignore directories",
+			directoryStructure: fsNode{
+				isDir: true,
+				name:  "ignore-directories",
+				children: []fsNode{
+					{
+						name:     "something.yaml",
+						contents: "foo",
+					},
+					{
+						name:     ".fleetignore",
+						contents: "subdir",
+					},
+					{
+						name:  "subdir",
+						isDir: true,
+						children: []fsNode{
+							{
+								name:     "in_dir.yaml",
+								contents: "baz",
+							},
+						},
+					},
+				},
+			},
+			expectedFiles: map[string][]byte{
+				"something.yaml": []byte("foo"),
+			},
+		},
+		{
+			name: "ignore file multiple levels below .fleetignore",
+			directoryStructure: fsNode{
+				isDir: true,
+				name:  "ignore-file-multiple-levels",
+				children: []fsNode{
+					{
+						name:     "something.yaml",
+						contents: "foo",
+					},
+					{
+						name:     ".fleetignore",
+						contents: "in_dir.yaml",
+					},
+					{
+						name:  "subdir",
+						isDir: true,
+						children: []fsNode{
+							{
+								name:  "subsubdir",
+								isDir: true,
+								children: []fsNode{
+									{
+										name:     "in_dir.yaml",
+										contents: "bar",
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+			expectedFiles: map[string][]byte{
+				"something.yaml": []byte("foo"),
+			},
+		},
+		{
+			name: ".fleetignore files in neighbour dirs do not interfere with one another",
+			directoryStructure: fsNode{
+				isDir: true,
+				name:  "multiple-files-same-level",
+				children: []fsNode{
+					{
+						name:     "something.yaml",
+						contents: "foo",
+					},
+					{
+						name:  "subdir1",
+						isDir: true,
+						children: []fsNode{
+							{
+								name:     "in_dir.yaml",
+								contents: "from dir 1",
+							},
+							{
+								name:     ".fleetignore",
+								contents: "in_dir.yaml",
+							},
+						},
+					},
+					{
+						name:  "subdir2",
+						isDir: true,
+						children: []fsNode{
+							{
+								name:     "in_dir.yaml",
+								contents: "from dir 2",
+							},
+						},
+					},
+				},
+			},
+			expectedFiles: map[string][]byte{
+				"something.yaml":      []byte("foo"),
+				"subdir2/in_dir.yaml": []byte("from dir 2"),
+			},
+		},
+	}
+
+	base, err := os.MkdirTemp("", "test-fleet")
+	require.NoError(t, err)
+
+	defer os.RemoveAll(base)
+
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+
+			root := createDirStruct(t, base, c.directoryStructure)
+
+			files, err := bundlereader.GetContent(context.Background(), root, root, "", bundlereader.Auth{})
+			assert.NoError(t, err)
+
+			assert.Equal(t, len(c.expectedFiles), len(files))
+			for k, v := range c.expectedFiles {
+				assert.Equal(t, v, files[k])
+			}
+		})
+	}
+}
+
+// createDirStruct generates and populates a directory structure which root is node, placing it at basePath.
+func createDirStruct(t *testing.T, basePath string, node fsNode) string {
+	path := filepath.Join(basePath, node.name)
+
+	if !node.isDir {
+		f, err := os.Create(path)
+		require.NoError(t, err)
+
+		_, err = io.WriteString(f, node.contents)
+		require.NoError(t, err)
+
+		return ""
+	}
+
+	err := os.Mkdir(path, 0777)
+	require.NoError(t, err)
+
+	for _, c := range node.children {
+		createDirStruct(t, path, c)
+	}
+
+	return path
+}