Repository: srl-labs/containerlab
Pull request #1689 (2023-11-01 10:56:31)

Update install.md and improve dev container setup and fix some spelling mistakes.
Hi,

as I use the dev container setup daily for my work, I made some improvements to make using this setup easier. Some of these changes are specific to my use case, but everything which is a general improvement is added in this PR.

And i fixed some spelling mistakes I found on the way. 

hellt: Hi @neilschark 
thanks for your contribution.
Since the contributor of the dev container part is @ankudinov I will let him comment/eval the improvements first.


Patch:
diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml
index b53f30b1..484890fd 100644
--- a/.github/workflows/cicd.yml
+++ b/.github/workflows/cicd.yml
@@ -132,7 +132,7 @@ jobs:
         if: always()
         with:
           name: coverage
-          path: ./tests/coverage/*
+          path: /tmp/clab-tests/coverage/*
           retention-days: 7
 
   smoke-tests:
@@ -189,7 +189,7 @@ jobs:
         if: always()
         with:
           name: coverage
-          path: ./tests/coverage/*
+          path: /tmp/clab-tests/coverage/*
           retention-days: 7
 
   ext-container-tests:
@@ -242,7 +242,7 @@ jobs:
         if: always()
         with:
           name: coverage
-          path: ./tests/coverage/*
+          path: /tmp/clab-tests/coverage/*
           retention-days: 7
 
   ceos-basic-tests:
@@ -297,7 +297,7 @@ jobs:
         if: always()
         with:
           name: coverage
-          path: ./tests/coverage/*
+          path: /tmp/clab-tests/coverage/*
           retention-days: 7
 
   srlinux-basic-tests:
@@ -358,7 +358,7 @@ jobs:
         if: always()
         with:
           name: coverage
-          path: ./tests/coverage/*
+          path: /tmp/clab-tests/coverage/*
           retention-days: 7
 
   ixiac-one-basic-tests:
@@ -405,7 +405,7 @@ jobs:
         if: always()
         with:
           name: coverage
-          path: ./tests/coverage/*
+          path: /tmp/clab-tests/coverage/*
           retention-days: 7
 
   vxlan-tests:
@@ -437,7 +437,7 @@ jobs:
       - uses: actions/download-artifact@v3
         with:
           name: coverage
-          path: tests/coverage
+          path: /tmp/clab-tests/coverage
       - name: convert Go's binary coverage to text coverage
         run: make convert-coverage
       - name: Upload coverage to codecov
diff --git a/Makefile b/Makefile
index 5e873ede..a15212f3 100644
--- a/Makefile
+++ b/Makefile
@@ -40,12 +40,12 @@ build-with-podman-debug:
 	CGO_ENABLED=1 go build -o $(BINARY) -gcflags=all="-N -l" -race -cover -trimpath -tags "podman exclude_graphdriver_btrfs btrfs_noversion exclude_graphdriver_devicemapper exclude_graphdriver_overlay containers_image_openpgp" main.go
 
 convert-coverage:
-	go tool covdata textfmt -i=./tests/coverage -o coverage.out
+	go tool covdata textfmt -i=/tmp/clab-tests/coverage -o coverage.out
 
 test:
-	rm -rf $$PWD/tests/coverage
-	mkdir -p $$PWD/tests/coverage
-	CGO_ENABLED=1 go test -cover -race ./... -v -covermode atomic -args -test.gocoverdir="$$PWD/tests/coverage"
+	rm -rf /tmp/clab-tests/coverage
+	mkdir -p /tmp/clab-tests/coverage
+	CGO_ENABLED=1 go test -cover -race ./... -v -covermode atomic -args -test.gocoverdir="/tmp/clab-tests/coverage"
 
 
 ifndef runtime
diff --git a/clab/clab.go b/clab/clab.go
index d4cdfb88..d3f3c9cf 100644
--- a/clab/clab.go
+++ b/clab/clab.go
@@ -131,14 +131,20 @@ func WithTopoPath(path, varsFile string) ClabOption {
 		var file string
 		var err error
 
-		switch path {
-		case "-", "stdin":
+		switch {
+		case path == "-" || path == "stdin":
 			file, err = c.readFromStdin()
 			if err != nil {
 				return err
 			}
+		// if the path is not a local file and a URL, download the file and store it in the tmp dir
+		case !utils.FileOrDirExists(path) && utils.IsHttpURL(path, true):
+			file, err = c.downloadTopoFile(path)
+			if err != nil {
+				return err
+			}
 
-		case "":
+		case path == "":
 			return fmt.Errorf("provide a path to the clab topology file")
 
 		default:
@@ -216,6 +222,19 @@ func (c *CLab) readFromStdin() (string, error) {
 	return tmpFile.Name(), nil
 }
 
+func (c *CLab) downloadTopoFile(url string) (string, error) {
+	c.TopoPaths.CreateTmpDir()
+
+	tmpFile, err := os.CreateTemp(c.TopoPaths.ClabTmpDir(), "topo-*.clab.yml")
+	if err != nil {
+		return "", err
+	}
+
+	err = utils.CopyFile(url, tmpFile.Name(), 0644)
+
+	return tmpFile.Name(), err
+}
+
 // WithNodeFilter option sets a filter for nodes to be deployed.
 // A filter is a list of node names to be deployed,
 // names are provided exactly as they are listed in the topology file.
diff --git a/clab/config.go b/clab/config.go
index 8e793afa..569ab761 100644
--- a/clab/config.go
+++ b/clab/config.go
@@ -264,7 +264,7 @@ func (c *CLab) processStartupConfig(nodeCfg *types.NodeConfig) error {
 	// it contains at least one newline
 	isEmbeddedConfig := strings.Count(p, "\n") >= 1
 	// downloadable config starts with http(s)://
-	isDownloadableConfig := utils.IsHttpUri(p)
+	isDownloadableConfig := utils.IsHttpURL(p, false)
 
 	if isEmbeddedConfig || isDownloadableConfig {
 		// both embedded and downloadable configs are require clab tmp dir to be created
@@ -522,7 +522,7 @@ func (c *CLab) resolveBindPaths(binds []string, nodedir string) error {
 	return nil
 }
 
-// setClabIntfsEnvVar sets CLAB_INTFS env var for each node
+// SetClabIntfsEnvVar sets CLAB_INTFS env var for each node
 // which holds the number of interfaces a node expects to have (without mgmt interfaces).
 func (c *CLab) SetClabIntfsEnvVar() {
 	for _, n := range c.Nodes {
diff --git a/cmd/deploy.go b/cmd/deploy.go
index fded27c1..4936b6d8 100644
--- a/cmd/deploy.go
+++ b/cmd/deploy.go
@@ -155,6 +155,12 @@ func deployFn(_ *cobra.Command, _ []string) error {
 
 	log.Info("Creating lab directory: ", c.TopoPaths.TopologyLabDir())
 	utils.CreateDirectory(c.TopoPaths.TopologyLabDir(), 0755)
+	// adjust ACL for Labdir such that SUDO_UID Users will
+	// also have access to lab directory files
+	err = utils.AdjustFileACLs(c.TopoPaths.TopologyLabDir())
+	if err != nil {
+		log.Infof("unable to adjust Labdir file ACLs: %v", err)
+	}
 
 	// create an empty ansible inventory file that will get populated later
 	// we create it here first, so that bind mounts of ansible-inventory.yml file could work
diff --git a/cmd/root.go b/cmd/root.go
index 49ab0df4..e2043b59 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -6,13 +6,14 @@ package cmd
 
 import (
 	"errors"
-	"fmt"
 	"os"
 	"path/filepath"
+	"strings"
 	"time"
 
 	log "github.com/sirupsen/logrus"
 	"github.com/spf13/cobra"
+	"github.com/srl-labs/containerlab/git"
 	"github.com/srl-labs/containerlab/utils"
 )
 
@@ -111,38 +112,21 @@ func getTopoFilePath(cmd *cobra.Command) error {
 	}
 
 	var err error
-	if utils.IsHttpUri(topo) {
+	// perform topology clone/fetch if the topo file is not available locally
+	if !utils.FileOrDirExists(topo) {
 		switch {
-		case utils.IsGitHubURL(topo):
-			githubURL := utils.NewGithubURL()
-
-			err := githubURL.Parse(topo)
+		case git.IsGitHubOrGitLabURL(topo) || git.IsGitHubShortURL(topo):
+			topo, err = processGitTopoFile(topo)
 			if err != nil {
 				return err
 			}
-
-			err = utils.CloneGithubRepo(githubURL)
-			if err != nil {
-				return err
+		case utils.IsHttpURL(topo, true):
+			// canonize the passed topo as URL by adding https schema if it was missing
+			if !strings.HasPrefix(topo, "http://") && !strings.HasPrefix(topo, "https://") {
+				topo = "https://" + topo
 			}
-
-			err = os.Chdir(githubURL.RepositoryName)
-			if err != nil {
-				return err
-			}
-
-			// once the repo is cloned the topo file is emptied
-			// to ensure that auto find functionality can kick in
-			topo = ""
-
-			// unless the file name is provided in the github url
-			if githubURL.FileName != "" {
-				topo = githubURL.FileName
-			}
-
-		default:
-			return fmt.Errorf("unsupported git repository: %s", topo)
 		}
+
 	}
 
 	// if topo or name flags have been provided, don't try to derive the topo file
@@ -164,3 +148,38 @@ func getTopoFilePath(cmd *cobra.Command) error {
 
 	return err
 }
+
+func processGitTopoFile(topo string) (string, error) {
+	// for short github urls, prepend https://github.com
+	// note that short notation only works for github links
+	if git.IsGitHubShortURL(topo) {
+		topo = "https://github.com/" + topo
+	}
+
+	repo, err := git.NewRepo(topo)
+	if err != nil {
+		return "", err
+	}
+
+	// Instantiate the git implementation to use.
+	gitImpl := git.NewGoGit(repo)
+
+	// clone the repo via the Git Implementation
+	err = gitImpl.Clone()
+	if err != nil {
+		return "", err
+	}
+
+	// prepare the path with the repo based path
+	path := filepath.Join(repo.GetPath()...)
+	// prepend that path with the repo base directory
+	path = filepath.Join(repo.GetName(), path)
+
+	// change dir to the
+	err = os.Chdir(path)
+	if err != nil {
+		return "", err
+	}
+
+	return repo.GetFilename(), err
+}
diff --git a/docs/cmd/deploy.md b/docs/cmd/deploy.md
index e02351ba..c37dd6ec 100644
--- a/docs/cmd/deploy.md
+++ b/docs/cmd/deploy.md
@@ -26,20 +26,24 @@ It is possible to read the topology file from stdin by passing `-` as a value to
 
 ##### Remote topology files
 
-To simplify the deployment of labs that are stored in remote version control systems, containerlab supports the use of remote topology files for Github.
+###### Git
 
-By specifying an URL to a repository or a `.clab.yml` file in a repository, containerlab will automatically clone[^1] the repository in your current directory and deploy it. If the URL points to a `.clab.yml` file, containerlab will clone the repository and deploy the lab defined in the file.
+To simplify the deployment of labs that are stored in remote version control systems, containerlab supports the use of remote topology files for GitHub.com and GitLab.com hosted projects.
+
+By specifying a URL to a repository or a `.clab.yml` file in a repository, containerlab will automatically clone[^1] the repository in your current directory and deploy it. If the URL points to a `.clab.yml` file, containerlab will clone the repository and deploy the lab defined in the file.
 
 The following URL formats are supported:
 
-| Type                                  | Example                                                            | Which topology file is used                                                        |
-| ------------------------------------- | ------------------------------------------------------------------ | ---------------------------------------------------------------------------------- |
-| Link to github repository             | https://github.com/hellt/clab-test-repo/                           | An auto-find procedure will find a `clab.yml` in the repository root and deploy it |
-| Link to a file in a github repository | https://github.com/hellt/clab-test-repo/blob/main/lab1.clab.yml    | A file specified in the URL will be deployed                                       |
-| Link to a repo's branch               | https://github.com/hellt/clab-test-repo/tree/branch1               | A branch of a repo is cloned and auto-find procedure kicks in                      |
-| Link to a file in a branch of a repo  | https://github.com/hellt/clab-test-repo/blob/branch1/lab2.clab.yml | A branch is cloned and a file specified in the URL is used for deployment          |
+| Type                                  | Example                                                             | Which topology file is used                                                        |
+| ------------------------------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
+| Link to github repository             | https://github.com/hellt/clab-test-repo/                            | An auto-find procedure will find a `clab.yml` in the repository root and deploy it |
+| Link to a file in a github repository | https://github.com/hellt/clab-test-repo/blob/main/lab1.clab.yml     | A file specified in the URL will be deployed                                       |
+| Link to a repo's branch               | https://github.com/hellt/clab-test-repo/tree/branch1                | A branch of a repo is cloned and auto-find procedure kicks in                      |
+| Link to a file in a branch of a repo  | https://github.com/hellt/clab-test-repo/blob/branch1/lab2.clab.yml  | A branch is cloned and a file specified in the URL is used for deployment          |
+| Link to a file in a subdir of a repo  | https://github.com/hellt/clab-test-repo/blob/main/dir/lab3.clab.yml | A file specified in the subdir of the branch will be deployed                      |
+| Shortcut of a github project          | hellt/clab-test-repo                                                | An auto-find procedure will find a `clab.yml` in the repository root and deploy it |
 
-When the lab is deployed using the URL, the repository is cloned in the current working directory. If the repository is already cloned, the clone operation will fail.
+When the lab is deployed using the URL, the repository is cloned in the current working directory. If the repository is already cloned it will be used and not cloned again; containerlab will try to fetch the latest changes from the remote repository.
 
 Subsequent lab operations (such as destroy) must use the filesystem path to the topology file and not the URL.
 
@@ -48,6 +52,19 @@ Subsequent lab operations (such as destroy) must use the filesystem path to the
         <source src="https://gitlab.com/rdodin/pics/-/wikis/uploads/5f0a7579f85c7d6af1fe05c254f42bb5/remote-labs2.mp4" type="video/mp4">
     </video>
 
+###### HTTP(S)
+
+Labs can be deployed from remote HTTP(S) URLs as well. These labs should be self-contained and not reference any external resources, like startup-config files, licenses, binds, etc.
+
+The following URL formats are supported:
+
+| Type                           | Example                                                             | Description                                           |
+| ------------------------------ | ------------------------------------------------------------------- | ----------------------------------------------------- |
+| Link to raw github gist        | https://gist.githubusercontent.com/hellt/abc/raw/def/linux.clab.yml | A file is downloaded to a temp directory and launched |
+| Link to a short schemaless URL | srlinux.dev/clab-srl                                                | A file is downloaded to a temp directory and launched |
+
+Containerlab distinct HTTP URLs from GitHub/GitLab by checking if github.com or gitlab.com is present in the URL. If not, it will treat the URL as a plain HTTP(S) URL.
+
 #### name
 
 With the global `--name | -n` flag a user sets a lab name. This value will override the lab name value passed in the topology definition file.
@@ -116,7 +133,7 @@ Read more about [node filtering](../manual/node-filtering.md) in the documentati
 
 ### Environment variables
 
-#### CLAB_RUNTIME
+#### `CLAB_RUNTIME`
 
 Default value of "runtime" key for nodes, same as global `--runtime | -r` flag described above.
 Affects all containerlab commands in the same way, not just `deploy`.
@@ -125,7 +142,7 @@ Intended to be set in environments where non-default container runtime should be
 
 Example command-line usage: `CLAB_RUNTIME=podman containerlab deploy`
 
-#### CLAB_VERSION_CHECK
+#### `CLAB_VERSION_CHECK`
 
 Can be set to "disable" value to prevent deploy command making a network request to check new version to report if one is available.
 
@@ -133,7 +150,7 @@ Useful when running in an automated environments with restricted network access.
 
 Example command-line usage: `CLAB_VERSION_CHECK=disable containerlab deploy`
 
-#### CLAB_LABDIR_BASE
+#### `CLAB_LABDIR_BASE`
 
 To change the [lab directory](../manual/conf-artifacts.md#identifying-a-lab-directory) location, set `CLAB_LABDIR_BASE` environment variable accordingly. It denotes the base directory in which the lab directory will be created.
 
diff --git a/docs/install.md b/docs/install.md
index 3e450689..839eb862 100644
--- a/docs/install.md
+++ b/docs/install.md
@@ -150,7 +150,7 @@ Once installed, issue `sudo service docker start` to start the docker service in
 
 ### Apple macOS
 
-Running containerlab on Mac OS is possible both on ARM (M1/M2) and Intel chipsets with certain limitations and caveats rooted in different architectures and underlying OS.
+Running containerlab on macOS is possible both on ARM (M1/M2) and Intel chipsets with certain limitations and caveats rooted in different architectures and underlying OS.
 
 #### ARM
 
@@ -184,7 +184,7 @@ and start downloading the labs you want to run.
 
 ##### Docker in Docker
 
-Another option to run containerlab on ARM-based Macs is to use Docker in Docker approach. With this approach, a docker-in-docker container is launched on the Mac OS inside the VM providing a docker environment. Below is a step-by-step guide on how to set it up.
+Another option to run containerlab on ARM-based Macs is to use Docker in Docker approach. With this approach, a docker-in-docker container is launched on the macOS inside the VM providing a docker environment. This setup also works on other operating systems where Docker is available. Below is a step-by-step guide on how to set it up.
 
 ???tip "Docker in docker guide"
     We'll provide an example of a custom [devcontainer](https://code.visualstudio.com/docs/devcontainers/containers) that can be opened in [VSCode](https://code.visualstudio.com) with [Remote Development extension pack](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.vscode-remote-extensionpack) installed.
@@ -200,28 +200,33 @@ Another option to run containerlab on ARM-based Macs is to use Docker in Docker
     === "Dockerfile"
 
         ```Dockerfile
-        # The devcontainer will be based on Python 3.9
+        # The devcontainer will be based on debian bullseye
         # The base container already has entrypoint, vscode user account, etc. out of the box
-        FROM mcr.microsoft.com/devcontainers/python:0-3.9-bullseye
+        FROM mcr.microsoft.com/vscode/devcontainers/base:bullseye
 
         # containelab version will be set in devcontainer.json
         ARG _CLAB_VERSION
 
+        # Set permissions for mounts in devcontainer.json
+        RUN mkdir -p /home/vscode/.vscode-server/bin
+        RUN chown -R vscode:vscode /home/vscode/.vscode-server
+
         # install some basic tools inside the container
         # adjust this list based on your demands
-        RUN apt-get update \
-            && apt-get install -y --no-install-recommends \
+        RUN apt-get update && \
+            apt-get upgrade -y && \
+            apt-get install -y --no-install-recommends \
             sshpass \
             curl \
             iputils-ping \
             htop \
+            yamllint \
             && rm -rf /var/lib/apt/lists/* \
             && rm -Rf /usr/share/doc && rm -Rf /usr/share/man \
             && apt-get clean
 
         # install preferred version of the containerlab
-        RUN bash -c "$(curl -sL https://get.containerlab.dev)" -- -v ${_CLAB_VERSION} \
-            && pip3 install --user yamllint
+        RUN bash -c "$(curl -sL https://get.containerlab.dev)" -- -v ${_CLAB_VERSION}
         ```
     === "devcontainer.json"
 
@@ -229,31 +234,46 @@ Another option to run containerlab on ARM-based Macs is to use Docker in Docker
         // For format details, see https://aka.ms/devcontainer.json. For config options, see the
         // README at: https://github.com/devcontainers/templates/tree/main/src/python
         {
-            "name": "clab-for-arm",
+            "name": "clab-dev-container",
             "build": {
                 "dockerfile": "Dockerfile",
                 "args": {
-                    "_CLAB_VERSION": "0.43.0"
+                    "_CLAB_VERSION": "0.47.2"
                 }
             },
             "features": {
                 // Containerlab will run in a docker-in-docker container
                 // it is also possible to use docker-outside-docker feature
-                "ghcr.io/devcontainers/features/docker-in-docker:2.2.0": {
+                "ghcr.io/devcontainers/features/docker-in-docker:latest": {
                     "version": "latest"
                 }
+                // You can add other features from this list: https://github.com/orgs/devcontainers/packages?repo_name=features
+                // For example:
+                //"ghcr.io/devcontainers/features/go:latest": {
+                //    "version": "1.21"
+                //}
+
             },
             // add any required extensions that must be pre-installed in the devcontainer
             "customizations": {
                 "vscode": {
                     "extensions": [
                         // various tools
+                        "ms-azuretools.vscode-docker",
                         "tuxtina.json2yaml",
                         "vscode-icons-team.vscode-icons",
                         "mutantdino.resourcemonitor"
                     ]
                 }
-            }
+            },
+            // This adds persistent mounts, so some configuration like docker credentials are saved for the vscode user and root (for sudo).
+            // Furthermore, your bash history and other configurations you made in your container users 'vscode' home are saved.
+            // .vscode-server is an anonymous volume. Gets destroyed on rebuild, which allows vscode to reinstall the extensions and dotfiles.
+            "mounts": [
+            "source=clab-vscode-home-dir,target=/home/vscode,type=volume",
+            "source=clab-docker-root-config,target=/root/.docker,type=volume",
+            "target=/home/vscode/.vscode-server,type=volume"
+        ]
         }
         ```
 
@@ -261,7 +281,7 @@ Another option to run containerlab on ARM-based Macs is to use Docker in Docker
 
     * Open the devcontainer in VSCode
     * Import the required images for your cLab inside the container (if you are using Docker-in-Docker option)
-    * Start you Containerlab
+    * Start your Containerlab
 
 #### Intel
 
@@ -280,7 +300,7 @@ docker run --rm -it --privileged \
     ghcr.io/srl-labs/clab bash
 ```
 
-The first command in the snippet above sets the working directory which you intend to use on your Mac OS. The `~/clab` in the example above expands to `/Users/<username>/clab` and means that we intent to have our containerlab labs to be stored in this directory.
+The first command in the snippet above sets the working directory which you intend to use on your macOS. The `~/clab` in the example above expands to `/Users/<username>/clab` and means that we intend to have our containerlab labs to be stored in this directory.
 
 !!!note
     1. It is best to create a directory under the `~/some/path` unless you know what to do[^5]
@@ -288,10 +308,10 @@ The first command in the snippet above sets the working directory which you inte
     3. Docker Desktop for Mac introduced cgroups v2 support in 4.3.0 version; to support the images that require cgroups v1 follow [these instructions](https://github.com/docker/for-mac/issues/6073).
     4. Docker Desktop relies on a LinuxKit based HyperKit VM. Unfortunately, it is shipped with a minimalist kernel, and some modules such as VRF are disabled by default. Follow [these instructions](https://medium.com/@notsinge/making-your-own-linuxkit-with-docker-for-mac-5c1234170fb1) to rebuild it with more modules.
 
-When the container is started, you will have a bash shell opened with the directory contents mounted from the Mac OS. There you can use `containerlab` commands right away.
+When the container is started, you will have a bash shell opened with the directory contents mounted from the macOS. There you can use `containerlab` commands right away.
 
 ???tip "Step by step example"
-    Let's imagine I want to run a lab with two SR Linux containers running directly on a Mac OS.
+    Let's imagine I want to run a lab with two SR Linux containers running directly on a macOS.
 
     First, I need to have Docker Desktop for Mac installed and running.
 
@@ -426,6 +446,6 @@ sudo setsebool -P selinuxuser_execmod 1
 [^5]: otherwise make sure to add a custom shared directory to the docker on mac.
 [^6]: FRR is a good example of arm64-capable network OS. Nokia SR Linux is going to be available for arm64 in the 2024.
 [^7]: There are two options to install UTM: via [downloadable dmg](https://github.com/utmapp/UTM/releases/latest/download/UTM.dmg) file (free) or App Store (paid). The App Store version is exactly the same, it is just a way to support the project.
-[^8]: This command requires docker to be installed on your Mac OS. You can use Docker Desktop, Rancher or [colima](https://github.com/abiosoft/colima) to run docker on your Mac OS.
+[^8]: This command requires docker to be installed on your macOS. You can use Docker Desktop, Rancher or [colima](https://github.com/abiosoft/colima) to run docker on your macOS.
 [^9]: If you want to install these tools on an existing Debian machine, you can run `wget -qO- containerlab.dev/setup-debian | bash -s -- all` command.
 [^10]: The UTM image has a pre-installed ssh key for the `debian` user. You can download the shared private key from [here](https://github.com/srl-labs/clabernetes/blob/main/launcher/assets/default_id_rsa).
diff --git a/docs/lab-examples/vsrx01.md b/docs/lab-examples/vsrx01.md
new file mode 100644
index 00000000..e696bae4
--- /dev/null
+++ b/docs/lab-examples/vsrx01.md
@@ -0,0 +1,90 @@
+|                               |                                                                                    |
+| ----------------------------- | ---------------------------------------------------------------------------------- |
+| **Description**               | A Juniper vSRX connected to two Alpine Linux Hosts                                 |
+| **Components**                | [Juniper vSRX][vsrx], [Multitool Alpine Linux][client]                             |
+| **Resource requirements**[^1] | :fontawesome-solid-microchip: 2 <br/>:fontawesome-solid-memory: 4 GB               |
+| **Topology file**             | [vsrx01.yml][topofile]                                                             |
+| **Name**                      | vsrx01                                                                             |
+| **Version information**[^2]   | `containerlab:0.47.2`, `junos-vsrx3-x86-64-23.2R1.13.qcow2`, `docker:24.0.6`       |
+
+## Description
+
+This lab consists of one Juniper vSRX router connected to two Alpine Linux nodes.
+
+```
+client1<---->vSRX<---->client2
+```
+
+### Configuration
+
+The vSRX takes about 5 minutes to complete its start up. Check using "docker container ls" until the vSRX shows up as "healthy"
+
+```
+# docker container ls
+CONTAINER ID   IMAGE                                  COMMAND                  CREATED          STATUS                    PORTS                                        NAMES
+85e3251a27c1   vrnetlab/vr-vsrx:23.2R1.13             "/launch.py --userna…"   10 minutes ago   Up 10 minutes (healthy)   22/tcp, 830/tcp, 5000/tcp, 10000-10099/tcp   clab-vsrx1-srx1
+f06a4997ac1b   wbitt/network-multitool:alpine-extra   "/bin/sh /docker-ent…"   10 minutes ago   Up 10 minutes             80/tcp, 443/tcp, 1180/tcp, 11443/tcp         clab-vsrx1-client1
+c77b68244805   wbitt/network-multitool:alpine-extra   "/bin/sh /docker-ent…"   10 minutes ago   Up 10 minutes             80/tcp, 443/tcp, 1180/tcp, 11443/tcp         clab-vsrx1-client2
+```
+
+#### vsrx1
+
+Log into the vSRX using SSH with `ssh admin@clab-vsrx1-srx1` and add the configuration from srx01.cfg. Password is `admin@123`.
+
+```
+admin>configure
+set interfaces ge-0/0/0 unit 0 family inet address 192.168.1.1/30
+set interfaces ge-0/0/1 unit 0 family inet address 192.168.2.1/30
+set security zones security-zone trust interfaces ge-0/0/0 host-inbound-traffic system-services all
+set security zones security-zone trust interfaces ge-0/0/1 host-inbound-traffic system-services all
+set system services web-management https system-generated-certificate
+set security forwarding-options family mpls mode packet-based
+# commit 
+```
+
+#### client1
+
+The two clients should be configured with the correct IP addresses and a route to the other client via the vSRX.
+First attach to the container process `docker exec -it clab-vsrx1-client1 ash`
+
+```
+docker exec -it clab-vsrx1-client1 ash
+
+# ip a show dev eth1
+131: eth1@if132: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 9500 qdisc noqueue state UP group default
+   link/ether aa:c1:ab:ac:1b:19 brd ff:ff:ff:ff:ff:ff link-netnsid 1
+   inet 192.168.1.2/30 scope global eth1
+      valid_lft forever preferred_lft forever
+   inet6 fe80::a8c1:abff:feac:1b19/64 scope link
+      valid_lft forever preferred_lft forever
+
+# ip route
+default via 172.20.20.1 dev eth0
+172.20.20.0/24 dev eth0 proto kernel scope link src 172.20.20.4
+192.168.1.0/30 dev eth1 proto kernel scope link src 192.168.1.2
+192.168.2.0/30 via 192.168.1.1 dev eth1
+```
+
+### Verification
+
+Traceroute from client1 to client2 to verify the dataplane via the vSRX.
+
+#### client1
+
+```
+# traceroute 192.168.2.2
+traceroute to 192.168.2.2 (192.168.2.2), 30 hops max, 46 byte packets
+1  192.168.1.1 (192.168.1.1)  0.397 ms  0.347 ms  0.290 ms
+2  192.168.2.2 (192.168.2.2)  0.263 ms  0.374 ms  0.762 ms
+```
+
+#### vSRX Web Gui
+
+To access the vSRX web interface point a browsers at the vSRX management IP address (fxp0) and use https. Login is `admin/admin@123`.
+  
+[vsrx]: https://www.juniper.net/us/en/products/security/srx-series/vsrx-virtual-firewall-datasheet.html
+[client]: https://github.com/wbitt/Network-MultiTool
+[topofile]: https://github.com/srl-labs/containerlab/tree/main/lab-examples/vsrx1/vsrx01.yml
+
+[^1]: Resource requirements are provisional. Consult with the installation guides for additional information.
+[^2]: The lab has been validated using these versions of the required tools/components. Using versions other than stated might lead to a non-operational setup process.
diff --git a/docs/manual/kinds/vr-vsrx.md b/docs/manual/kinds/vr-vsrx.md
index af42cf65..35e442a3 100644
--- a/docs/manual/kinds/vr-vsrx.md
+++ b/docs/manual/kinds/vr-vsrx.md
@@ -63,4 +63,6 @@ Configuration is applied after the node is started. Thus it can contain partial
 
 ## Lab examples
 
-Coming soon.
+The following simple lab consists of two Linux hosts connected via one vSRX:
+
+* [SR Linux and cRPD](../../lab-examples/vsrx01.md)
diff --git a/docs/manual/nodes.md b/docs/manual/nodes.md
index 63fd5ed4..b987e5f3 100644
--- a/docs/manual/nodes.md
+++ b/docs/manual/nodes.md
@@ -166,6 +166,7 @@ The remote file will be downloaded to the containerlab's temp directory at `$TMP
 
     * Upon deletion of a lab, the downloaded startup-config files will not be removed. A manual cleanup should be performed if required.
     * If a lab is redeployed with the lab name and startup-config paths unchanged, the local file will be overwritten.
+    * For https locations the certificates won't be verified to allow fetching artifacts from servers with self-signed certificates.
 
 ### enforce-startup-config
 
diff --git a/docs/quickstart.md b/docs/quickstart.md
index 59c1de15..dcfebeb3 100644
--- a/docs/quickstart.md
+++ b/docs/quickstart.md
@@ -99,6 +99,9 @@ containerlab deploy # (1)!
 1. `deploy` command will automatically lookup a file matching the `*.clab.y*ml` patter to select it.  
   If you have several files and want to pick a specific one, use `--topo <path>` flag.
 
+!!!tip "Remote topology files"
+    Containerlab allows to deploy labs from files located in remote Git repositories and/or HTTP URLs. Check out deploy command [documentation](cmd/deploy.md#remote-topology-files) for more details.
+
 After a couple of seconds you will see the summary of the deployed nodes:
 
 ```
diff --git a/git/git.go b/git/git.go
new file mode 100644
index 00000000..436ff5f7
--- /dev/null
+++ b/git/git.go
@@ -0,0 +1,272 @@
+package git
+
+import (
+	"bytes"
+	"fmt"
+	"os"
+	"os/exec"
+
+	gogit "github.com/go-git/go-git/v5"
+	"github.com/go-git/go-git/v5/config"
+	"github.com/go-git/go-git/v5/plumbing"
+	"github.com/go-git/go-git/v5/storage/memory"
+	log "github.com/sirupsen/logrus"
+)
+
+type GoGit struct {
+	gitRepo GitRepo
+	r       *gogit.Repository
+}
+
+// make sure GoGit satisfies the Git interface
+var _ Git = (*GoGit)(nil)
+
+func NewGoGit(gitRepo GitRepo) *GoGit {
+
+	return &GoGit{
+		gitRepo: gitRepo,
+	}
+}
+
+// Clone takes the given GitRepo reference and clones the repo
+// with its internal implementation.
+func (g *GoGit) Clone() error {
+	// if the directory is not present
+	if s, err := os.Stat(g.gitRepo.GetName()); os.IsNotExist(err) {
+		return g.cloneNonExisting()
+	} else if s.IsDir() {
+		return g.cloneExistingRepo()
+	}
+	return fmt.Errorf("error %q exists already but is a file", g.gitRepo.GetName())
+}
+
+func (g *GoGit) getDefaultBranch() (string, error) {
+
+	rem := gogit.NewRemote(memory.NewStorage(), &config.RemoteConfig{
+		Name: "origin",
+		URLs: []string{g.gitRepo.GetCloneURL().String()},
+	})
+
+	// We can then use every Remote functions to retrieve wanted information
+	refs, err := rem.List(&gogit.ListOptions{})
+	if err != nil {
+		return "", err
+	}
+
+	for _, ref := range refs {
+		if ref.Type() == plumbing.SymbolicReference && ref.Name() == plumbing.HEAD {
+			return ref.Target().Short(), nil
+		}
+	}
+
+	return "", fmt.Errorf("unable to determine default branch for %q", g.gitRepo.GetCloneURL().String())
+
+}
+
+func (g *GoGit) openRepo() error {
+	var err error
+
+	// load the git repository
+	g.r, err = gogit.PlainOpen(g.gitRepo.GetName())
+	if err != nil {
+		return err
+	}
+	return nil
+}
+
+func (g *GoGit) cloneExistingRepo() error {
+	log.Debugf("loading git repository %q", g.gitRepo.GetName())
+
+	// open the existing repo
+	err := g.openRepo()
+	if err != nil {
+		return err
+	}
+
+	// loading remote
+	remote, err := g.r.Remote("origin")
+	if err != nil {
+		return err
+	}
+
+	// checking that the configured remote equals the provided remote
+	if remote.Config().URLs[0] != g.gitRepo.GetCloneURL().String() {
+		return fmt.Errorf("repository url of %q differs (%q) from the provided url (%q). stopping",
+			g.gitRepo.GetName(), remote.Config().URLs[0], g.gitRepo.GetCloneURL().String())
+	}
+
+	// get the worktree reference
+	tree, err := g.r.Worktree()
+	if err != nil {
+		return err
+	}
+
+	// resolve the branch
+	// the branch ref from the URL might be empty -> ""
+	// then we need to figure out whats the default branch main / master / sth. else.
+	branch := g.gitRepo.GetBranch()
+	if branch == "" {
+		log.Debugf("default branch not set. determining it")
+		branch, err = g.getDefaultBranch()
+		if err != nil {
+			return err
+		}
+		log.Debugf("default branch is %q", branch)
+	}
+
+	// prepare the checkout options
+	checkoutOpts := &gogit.CheckoutOptions{
+		Branch: plumbing.NewBranchReferenceName(branch),
+	}
+
+	// check if the branch already exists locally.
+	// if not fetch it and check it out.
+
+	if _, err = g.r.Reference(plumbing.NewBranchReferenceName(branch), false); err != nil {
+		err = g.fetchNonExistingBranch(branch)
+		if err != nil {
+			return err
+		}
+
+		ref, err := g.r.Reference(plumbing.NewRemoteReferenceName("origin", branch), true)
+		if err != nil {
+			return err
+		}
+
+		checkoutOpts.Hash = ref.Hash()
+		checkoutOpts.Create = true
+	}
+
+	log.Debugf("checking out branch %q", branch)
+
+	// execute the checkout
+	err = tree.Checkout(checkoutOpts)
+	if err != nil {
+		return err
+	}
+
+	log.Debug("pulling latest repo data")
+	// init the pull options
+	pullOpts := &gogit.PullOptions{
+		Depth:        1,
+		SingleBranch: true,
+		Force:        true,
+	}
+	// execute the pull
+	err = tree.Pull(pullOpts)
+	if err == gogit.NoErrAlreadyUpToDate {
+		log.Debugf("git repository up to date")
+		err = nil
+	}
+
+	return err
+}
+
+func (g *GoGit) fetchNonExistingBranch(branch string) error {
+	// init the remote
+	remote, err := g.r.Remote("origin")
+	if err != nil {
+		return err
+	}
+
+	// build the RefSpec, that wires the remote to the locla branch
+	localRef := plumbing.NewBranchReferenceName(branch)
+	remoteRef := plumbing.NewRemoteReferenceName("origin", branch)
+	refSpec := config.RefSpec(fmt.Sprintf("+%s:%s", localRef, remoteRef))
+
+	// init fetch options
+	fetchOpts := &gogit.FetchOptions{
+		Depth:    1,
+		RefSpecs: []config.RefSpec{refSpec},
+	}
+
+	// execute the fetch
+	err = remote.Fetch(fetchOpts)
+	if err == gogit.NoErrAlreadyUpToDate {
+		log.Debugf("git repository up to date")
+	} else if err != nil {
+		return err
+	}
+
+	// make sure the branch is also showing up in .git/config
+	err = g.r.CreateBranch(&config.Branch{
+		Name:   branch,
+		Remote: "origin",
+		Merge:  localRef,
+	})
+
+	return err
+}
+
+func (g *GoGit) cloneNonExisting() error {
+	var err error
+	// init clone options
+	co := &gogit.CloneOptions{
+		Depth:        1,
+		URL:          g.gitRepo.GetCloneURL().String(),
+		SingleBranch: true,
+	}
+	// set brach reference if set
+	if g.gitRepo.GetBranch() != "" {
+		co.ReferenceName = plumbing.NewBranchReferenceName(g.gitRepo.GetBranch())
+	} else {
+		branchName, err := g.getDefaultBranch()
+		if err != nil {
+			return err
+		}
+		co.ReferenceName = plumbing.NewBranchReferenceName(branchName)
+	}
+
+	// perform clone
+	g.r, err = gogit.PlainClone(g.gitRepo.GetName(), false, co)
+
+	return err
+}
+
+type ExecGit struct {
+	gitRepo GitRepo
+}
+
+// make sure ExecGit satisfies the Git interface
+var _ Git = (*ExecGit)(nil)
+
+func NewExecGit(gitRepo GitRepo) *ExecGit {
+	return &ExecGit{
+		gitRepo: gitRepo,
+	}
+}
+
+// Clone takes the given GitRepo reference and clones the repo
+// with its internal implementation.
+func (g *ExecGit) Clone() error {
+	// build the URL with owner and repo name
+	repoUrl := g.gitRepo.GetCloneURL().String()
+	cloneArgs := []string{"clone", repoUrl, "--depth", "1"}
+	if g.gitRepo.GetBranch() != "" {
+		cloneArgs = append(cloneArgs, []string{"--branch", g.gitRepo.GetBranch()}...)
+	}
+
+	cmd := exec.Command("git", cloneArgs...)
+
+	log.Infof("cloning %q", repoUrl)
+
+	cmd.Stdout = log.New().Writer()
+
+	var stderr bytes.Buffer
+	cmd.Stderr = &stderr
+
+	err := cmd.Run()
+	if err != nil {
+		log.Errorf("failed to clone %q: %v", repoUrl, err)
+		log.Error(stderr.String())
+		return err
+	}
+
+	return nil
+}
+
+type Git interface {
+	// Clone takes the given GitRepo reference and clones the repo
+	// with its internal implementation.
+	Clone() error
+}
diff --git a/git/github.go b/git/github.go
new file mode 100644
index 00000000..d1b03f64
--- /dev/null
+++ b/git/github.go
@@ -0,0 +1,113 @@
+package git
+
+import (
+	"fmt"
+
+	neturl "net/url"
+	"strings"
+)
+
+// NewGitHubRepoFromURL parses the given url and returns a GitHubRepo.
+func NewGitHubRepoFromURL(url *neturl.URL) (*GitHubRepo, error) {
+	r := &GitHubRepo{
+		GitRepoStruct: GitRepoStruct{
+			URL: url,
+		}}
+
+	// trimming the leading and trailing slashes
+	// so that splitPath will have the slashes between the elements only
+	splitPath := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
+
+	// path needs to hold at least 2 elements,
+	// user / org and repo
+	if len(splitPath) < 2 || splitPath[0] == "" || splitPath[1] == "" {
+		return nil, fmt.Errorf("%w %s", errInvalidURL, r.URL.String())
+	}
+
+	// github.dev links can be cloned using github.com
+	if r.URL.Host == "github.dev" {
+		r.URL.Host = "github.com"
+	}
+
+	// set CloneURL to the copy of the original URL
+	// this is overriden later for repo urls with
+	// paths containing blob or tree elements
+	r.CloneURL = &neturl.URL{}
+	*r.CloneURL = *r.URL
+
+	// remove trailing slash from the path
+	// as it bears no meaning for the clone url
+	r.CloneURL.Path = strings.TrimSuffix(r.CloneURL.Path, "/")
+
+	r.ProjectOwner = splitPath[0]
+
+	// in case repo url has a trailing .git suffix, trim it
+	r.RepositoryName = strings.TrimSuffix(splitPath[1], ".git")
+
+	switch {
+	case len(splitPath) == 2:
+		return r, nil
+	case len(splitPath) < 4:
+		return nil, fmt.Errorf("%w invalid github path. should have either 2 or >= 4 path elements", errInvalidURL)
+	}
+
+	r.GitBranch = splitPath[3]
+
+	switch {
+	// path points to a file at a specific git ref
+	case splitPath[2] == "blob":
+		if !(strings.HasSuffix(r.URL.Path, ".yml") || strings.HasSuffix(r.URL.Path, ".yaml")) {
+			return nil, fmt.Errorf("%w: topology file must have yml or yaml extension", errInvalidURL)
+		}
+
+		if len(splitPath)-1 > 4 {
+			r.Path = splitPath[4 : len(splitPath)-1]
+		}
+
+		// overriding CloneURL Path to point to the git repo
+		r.CloneURL.Path = "/" + splitPath[0] + "/" + splitPath[1]
+
+		r.FileName = splitPath[len(splitPath)-1]
+
+	// path points to a git ref (branch or tag)
+	case splitPath[2] == "tree":
+		if len(splitPath) > 4 {
+			r.Path = splitPath[4:]
+		}
+
+		// overriding CloneURL Path to point to the git repo
+		r.CloneURL.Path = "/" + splitPath[0] + "/" + splitPath[1]
+
+		r.FileName = "" // no filename, a dir is referenced
+	}
+
+	return r, nil
+}
+
+// IsGitHubURL checks if the url is a github url.
+func IsGitHubURL(url *neturl.URL) bool {
+	return strings.Contains(url.Host, "github.com") ||
+		strings.Contains(url.Host, "github.dev")
+}
+
+// GitHubRepo struct holds the parsed github url.
+type GitHubRepo struct {
+	GitRepoStruct
+}
+
+// IsGitHubShortURL returns true for github-friendly short urls
+// such as srl-labs/containerlab.
+func IsGitHubShortURL(s string) bool {
+	split := strings.Split(s, "/")
+	// only 2 elements are allowed
+	if len(split) != 2 {
+		return false
+	}
+
+	// dot is not allowed in the project owner
+	if strings.Contains(split[0], ".") {
+		return false
+	}
+
+	return true
+}
diff --git a/git/gitlab.go b/git/gitlab.go
new file mode 100644
index 00000000..b1882b51
--- /dev/null
+++ b/git/gitlab.go
@@ -0,0 +1,96 @@
+package git
+
+import (
+	"fmt"
+	neturl "net/url"
+	"strings"
+)
+
+// IsGitLabURL returns true if the given url is a gitlab url.
+func IsGitLabURL(u *neturl.URL) bool {
+	// besides simply looking for gitlab substring in the url,
+	// we should consider fetching the http response and check
+	// for gitlab string in the body.
+	return strings.Contains(u.String(), "gitlab")
+}
+
+// GitLabRepo represents a gitlab repository.
+type GitLabRepo struct {
+	GitRepoStruct
+}
+
+// NewGitLabRepoFromURL parses the given url and returns a GitLabRepo.
+func NewGitLabRepoFromURL(url *neturl.URL) (*GitLabRepo, error) {
+	r := &GitLabRepo{
+		GitRepoStruct: GitRepoStruct{
+			URL: url,
+		}}
+
+	// trimming the leading and trailing slashes
+	// so that splitPath will have the slashes between the elements only
+	splitPath := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
+
+	// path needs to hold at least 2 elements,
+	// user / org and repo
+	if len(splitPath) < 2 || splitPath[0] == "" || splitPath[1] == "" {
+		return nil, fmt.Errorf("%w %s", errInvalidURL, r.URL.String())
+	}
+
+	// set CloneURL to the copy of the original URL
+	// this is overriden later for repo urls with
+	// paths containing blob or tree elements
+	r.CloneURL = &neturl.URL{}
+	*r.CloneURL = *r.URL
+	// remove raw query from the clone url
+	// raw query is ref_type=heads
+	// in https://gitlab.com/rdodin/clab-test-repo/-/tree/branch1?ref_type=heads
+	r.CloneURL.RawQuery = ""
+
+	// remove trailing slash from the path
+	// as it bears no meaning for the clone url
+	r.CloneURL.Path = strings.TrimSuffix(r.CloneURL.Path, "/")
+
+	r.ProjectOwner = splitPath[0]
+
+	// in case repo url has a trailing .git suffix, trim it
+	r.RepositoryName = strings.TrimSuffix(splitPath[1], ".git")
+
+	switch {
+	case len(splitPath) == 2:
+		return r, nil
+	case len(splitPath) < 5:
+		return nil, fmt.Errorf("%w invalid github path. should have either 2 or >= 5 path elements", errInvalidURL)
+	}
+
+	r.GitBranch = splitPath[4]
+
+	switch {
+	// path points to a file at a specific git ref
+	case splitPath[3] == "blob":
+		if !(strings.HasSuffix(r.URL.Path, ".yml") || strings.HasSuffix(r.URL.Path, ".yaml")) {
+			return nil, fmt.Errorf("%w: topology file must have yml or yaml extension", errInvalidURL)
+		}
+
+		if len(splitPath)-1 > 5 {
+			r.Path = splitPath[5 : len(splitPath)-1]
+		}
+
+		// overriding CloneURL Path to point to the git repo
+		r.CloneURL.Path = "/" + splitPath[0] + "/" + splitPath[1]
+
+		r.FileName = splitPath[len(splitPath)-1]
+
+	// path points to a git ref (branch or tag)
+	case splitPath[3] == "tree":
+		if len(splitPath) > 5 {
+			r.Path = splitPath[5:]
+		}
+
+		// overriding CloneURL Path to point to the git repo
+		r.CloneURL.Path = "/" + splitPath[0] + "/" + splitPath[1]
+
+		r.FileName = "" // no filename, a dir is referenced
+	}
+
+	return r, nil
+}
diff --git a/git/repo.go b/git/repo.go
new file mode 100644
index 00000000..11aa0cab
--- /dev/null
+++ b/git/repo.go
@@ -0,0 +1,92 @@
+package git
+
+import (
+	"errors"
+	"net/url"
+)
+
+var errInvalidURL = errors.New("invalid URL")
+
+// GitRepoStruct is a struct that contains all the fields
+// required for a GitRepo instance.
+type GitRepoStruct struct {
+	// original URL parsed from the user input
+	URL *url.URL
+	// CloneURL is the URL that will be used for cloning the repo
+	CloneURL       *url.URL
+	ProjectOwner   string
+	RepositoryName string
+	// GitBranch is the referenced Git branch name.
+	GitBranch string
+	// Path is the path within the repository where the file is located.
+	Path []string
+	// FileName is the name of the file located at repo_root/path/filename.
+	FileName string
+}
+
+// GetFilename returns the filename if a file was specifically referenced.
+// the empty string is returned otherwise.
+func (u *GitRepoStruct) GetFilename() string {
+	return u.FileName
+}
+
+// GetPath returns the path of the repository.
+func (u *GitRepoStruct) GetPath() []string {
+	return u.Path
+}
+
+// GetName returns the repository name.
+func (u *GitRepoStruct) GetName() string {
+	return u.RepositoryName
+}
+
+// GetBranch returns the referenced Git branch name.
+// the empty string is returned otherwise.
+func (u *GitRepoStruct) GetBranch() string {
+	return u.GitBranch
+}
+
+// GetCloneURL returns the CloneURL of the repository
+func (u *GitRepoStruct) GetCloneURL() *url.URL {
+	return u.CloneURL
+}
+
+type GitRepo interface {
+	GetName() string
+	GetFilename() string
+	GetPath() []string
+	GetCloneURL() *url.URL
+	GetBranch() string
+}
+
+// NewRepo parses the given git urlPath and returns an interface
+// that is backed by Github or Gitlab repo implementations.
+func NewRepo(urlPath string) (GitRepo, error) {
+	var r GitRepo
+	var err error
+
+	u, err := url.Parse(urlPath)
+	if err != nil {
+		return nil, err
+	}
+
+	if IsGitHubURL(u) {
+		r, err = NewGitHubRepoFromURL(u)
+	}
+
+	if IsGitLabURL(u) {
+		r, err = NewGitLabRepoFromURL(u)
+	}
+
+	return r, err
+}
+
+// IsGitHubOrGitLabURL checks if the url is a github or gitlab url.
+func IsGitHubOrGitLabURL(u string) bool {
+	_url, err := url.ParseRequestURI(u)
+	if err != nil {
+		return false
+	}
+
+	return IsGitHubURL(_url) || IsGitLabURL(_url)
+}
diff --git a/go.mod b/go.mod
index fe30dff4..7b4e9136 100644
--- a/go.mod
+++ b/go.mod
@@ -7,7 +7,7 @@ require (
 	github.com/awalterschulze/gographviz v2.0.3+incompatible
 	github.com/containernetworking/plugins v1.3.0
 	github.com/containers/common v0.56.0
-	github.com/containers/podman/v4 v4.7.1
+	github.com/containers/podman/v4 v4.7.2
 	github.com/digitalocean/go-openvswitch v0.0.0-20201214180534-ce0f183468d8
 	github.com/distribution/reference v0.5.0
 	github.com/docker/docker v24.0.7+incompatible
@@ -33,7 +33,8 @@ require (
 	github.com/pmorjan/kmod v1.1.0
 	github.com/scrapli/scrapligo v1.2.0
 	github.com/sirupsen/logrus v1.9.3
-	github.com/spf13/cobra v1.7.0
+	github.com/spf13/cobra v1.8.0
+	github.com/steiler/acls v0.1.0
 	github.com/stretchr/testify v1.8.4
 	github.com/tklauser/numcpus v0.6.1
 	github.com/vishvananda/netlink v1.2.1-beta.2
@@ -87,12 +88,14 @@ require (
 	github.com/mistifyio/go-zfs/v3 v3.0.1 // indirect
 	github.com/moby/sys/sequential v0.5.0 // indirect
 	github.com/oklog/ulid v1.3.1 // indirect
+	github.com/pjbgf/sha1cd v0.3.0 // indirect
 	github.com/pkg/sftp v1.13.6 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/secure-systems-lab/go-securesystemslib v0.7.0 // indirect
 	github.com/sigstore/fulcio v1.4.0 // indirect
 	github.com/sigstore/rekor v1.2.2 // indirect
 	github.com/sigstore/sigstore v1.7.3 // indirect
+	github.com/skeema/knownhosts v1.2.0 // indirect
 	github.com/tchap/go-patricia/v2 v2.3.1 // indirect
 	github.com/theupdateframework/go-tuf v0.5.2 // indirect
 	github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 // indirect
@@ -115,11 +118,11 @@ require (
 	github.com/Masterminds/goutils v1.1.1 // indirect
 	github.com/Microsoft/go-winio v0.6.1 // indirect
 	github.com/Microsoft/hcsshim v0.12.0-rc.0 // indirect
-	github.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95 // indirect
+	github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 // indirect
 	github.com/Shopify/ejson v1.3.3 // indirect
 	github.com/VividCortex/ewma v1.2.0 // indirect
 	github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect
-	github.com/acomagu/bufpipe v1.0.3 // indirect
+	github.com/acomagu/bufpipe v1.0.4 // indirect
 	github.com/apparentlymart/go-cidr v1.1.0 // indirect
 	github.com/armon/go-metrics v0.4.1 // indirect
 	github.com/aws/aws-sdk-go v1.44.295 // indirect
@@ -173,9 +176,9 @@ require (
 	github.com/fatih/color v1.15.0 // indirect
 	github.com/freddierice/go-losetup v0.0.0-20170407175016-fc9adea44124 // indirect
 	github.com/fsnotify/fsnotify v1.6.0 // indirect
-	github.com/go-git/gcfg v1.5.0 // indirect
-	github.com/go-git/go-billy/v5 v5.3.1 // indirect
-	github.com/go-git/go-git/v5 v5.4.2 // indirect
+	github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
+	github.com/go-git/go-billy/v5 v5.5.0 // indirect
+	github.com/go-git/go-git/v5 v5.9.0
 	github.com/go-logr/logr v1.2.4 // indirect
 	github.com/go-logr/stdr v1.2.2 // indirect
 	github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect
@@ -212,7 +215,6 @@ require (
 	github.com/hashicorp/hcl v1.0.0 // indirect
 	github.com/hashicorp/serf v0.10.1 // indirect
 	github.com/hashicorp/vault/api v1.9.2 // indirect
-	github.com/imdario/mergo v0.3.15 // indirect
 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
 	github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
 	github.com/jinzhu/copier v0.4.0 // indirect
@@ -263,7 +265,7 @@ require (
 	github.com/vbatts/tar-split v0.11.5 // indirect
 	github.com/vishvananda/netns v0.0.4 // indirect
 	github.com/weaveworks/libgitops v0.0.0-20200611103311-2c871bbbbf0c // indirect
-	github.com/xanzy/ssh-agent v0.3.1 // indirect
+	github.com/xanzy/ssh-agent v0.3.3 // indirect
 	github.com/zealic/xignore v0.3.3 // indirect
 	go.etcd.io/bbolt v1.3.7 // indirect
 	go.mozilla.org/pkcs7 v0.0.0-20210826202110-33d05740a352 // indirect
@@ -273,7 +275,7 @@ require (
 	go4.org/intern v0.0.0-20230205224052-192e9f60865c // indirect
 	go4.org/unsafe/assume-no-moving-gc v0.0.0-20230204201903-c31fa085b70e // indirect
 	gocloud.dev v0.30.0 // indirect
-	golang.org/x/mod v0.13.0
+	golang.org/x/mod v0.14.0
 	golang.org/x/net v0.17.0
 	golang.org/x/oauth2 v0.12.0 // indirect
 	golang.org/x/sync v0.3.0 // indirect
diff --git a/go.sum b/go.sum
index 15cdf7f3..ab179724 100644
--- a/go.sum
+++ b/go.sum
@@ -727,7 +727,6 @@ github.com/Microsoft/go-winio v0.4.16/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugX
 github.com/Microsoft/go-winio v0.4.17-0.20210211115548-6eac466e5fa3/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84=
 github.com/Microsoft/go-winio v0.4.17-0.20210324224401-5516f17a5958/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84=
 github.com/Microsoft/go-winio v0.4.17/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84=
-github.com/Microsoft/go-winio v0.5.0/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84=
 github.com/Microsoft/go-winio v0.5.1/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84=
 github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
 github.com/Microsoft/go-winio v0.6.0/go.mod h1:cTAf44im0RAYeL23bpB+fzCyDH2MJiz2BO69KH/soAE=
@@ -753,9 +752,8 @@ github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb0
 github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c=
 github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
 github.com/OpenPeeDeeP/depguard v1.0.1/go.mod h1:xsIw86fROiiwelg+jB2uM9PiKihMMmUx/1V+TNhjQvM=
-github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7/go.mod h1:z4/9nQmJSSwwds7ejkxaJwO37dru3geImFUdJlaLzQo=
-github.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95 h1:KLq8BE0KwCL+mmXnjLWEAOYO+2l2AE4YMmqG1ZpZHBs=
-github.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
+github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 h1:kkhsdkhsCvIsutKu5zLMgWtgh9YxGCNAw8Ad8hjwfYg=
+github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
 github.com/PuerkitoBio/goquery v1.5.0/go.mod h1:qD2PgZ9lccMbQlc7eEOjaeRlFQON7xY8kdmcsrnKqMg=
 github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
 github.com/PuerkitoBio/purell v1.1.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
@@ -775,8 +773,8 @@ github.com/a8m/envsubst v1.4.2 h1:4yWIHXOLEJHQEFd4UjrWDrYeYlV7ncFWJOCBRLOZHQg=
 github.com/a8m/envsubst v1.4.2/go.mod h1:MVUTQNGQ3tsjOOtKCNd+fl8RzhsXcDvvAEzkhGtlsbY=
 github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8=
 github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo=
-github.com/acomagu/bufpipe v1.0.3 h1:fxAGrHZTgQ9w5QqVItgzwj235/uYZYgbXitB+dLupOk=
-github.com/acomagu/bufpipe v1.0.3/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4=
+github.com/acomagu/bufpipe v1.0.4 h1:e3H4WUzM3npvo5uv95QuJM3cQspFNtFBzvJ2oNjKIDQ=
+github.com/acomagu/bufpipe v1.0.4/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4=
 github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c=
 github.com/agnivade/levenshtein v1.0.1/go.mod h1:CURSv5d9Uaml+FovSIICkLbAUZ9S4RqaHDIsdSBg7lM=
 github.com/ajstarks/deck v0.0.0-20200831202436-30c9fc6549a9/go.mod h1:JynElWSGnm/4RlzPXRlREEwqTHAN3T56Bv2ITsFT3gY=
@@ -798,8 +796,8 @@ github.com/alexflint/go-filemutex v1.1.0/go.mod h1:7P4iRhttt/nUvUOrYIhcpMzv2G6CY
 github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
 github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
 github.com/andybalholm/cascadia v1.0.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
-github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239 h1:kFOfPq6dUM1hTo4JG6LR5AXSUEsOjtdm0kw0FtQtMJA=
 github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c=
+github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
 github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
 github.com/apache/arrow/go/v10 v10.0.1/go.mod h1:YvhnlEePVnBS4+0z3fhPfUy7W1Ikj0Ih0vcRo/gZ1M0=
 github.com/apache/arrow/go/v11 v11.0.0/go.mod h1:Eg5OsL5H+e299f7u5ssuXsuHQVEGC4xei5aX110hRiI=
@@ -1122,8 +1120,8 @@ github.com/containers/ocicrypt v1.1.1/go.mod h1:Dm55fwWm1YZAjYRaJ94z2mfZikIyIN4B
 github.com/containers/ocicrypt v1.1.2/go.mod h1:Dm55fwWm1YZAjYRaJ94z2mfZikIyIN4B0oB3dj3jFxY=
 github.com/containers/ocicrypt v1.1.8 h1:saSBF0/8DyPUjzcxMVzL2OBUWCkvRvqIm75pu0ADSZk=
 github.com/containers/ocicrypt v1.1.8/go.mod h1:jM362hyBtbwLMWzXQZTlkjKGAQf/BN/LFMtH0FIRt34=
-github.com/containers/podman/v4 v4.7.1 h1:tMhVDvvCIMU64UZVSo2EfLG7ff0/GDHkDg7An2rJ0ZA=
-github.com/containers/podman/v4 v4.7.1/go.mod h1:FJPqIhiwdklJenJskZyoNd1ZNin6kvY6zL9Rypaajxs=
+github.com/containers/podman/v4 v4.7.2 h1:JYElav1y6/w9e4OxPLXZWUC3Tlskui0ysfOYeVSdclQ=
+github.com/containers/podman/v4 v4.7.2/go.mod h1:FJPqIhiwdklJenJskZyoNd1ZNin6kvY6zL9Rypaajxs=
 github.com/containers/psgo v1.8.0 h1:2loGekmGAxM9ir5OsXWEfGwFxorMPYnc6gEDsGFQvhY=
 github.com/containers/psgo v1.8.0/go.mod h1:T8ZxnX3Ur4RvnhxFJ7t8xJ1F48RhiZB4rSrOaR/qGHc=
 github.com/containers/storage v1.43.0/go.mod h1:uZ147thiIFGdVTjMmIw19knttQnUCl3y9zjreHrg11s=
@@ -1156,7 +1154,7 @@ github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfc
 github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
 github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
 github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
-github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
+github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
 github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
 github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
 github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
@@ -1250,6 +1248,7 @@ github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaB
 github.com/edsrzf/mmap-go v1.1.0/go.mod h1:19H/e8pUPLicwkyNgOykDXkJ9F0MHE+Z52B8EIth78Q=
 github.com/elazarl/goproxy v0.0.0-20170405201442-c4fc26588b6e/go.mod h1:/Zj4wYkgs4iZTTu3o/KG3Itv/qCCa8VVMlb3i9OVuzc=
 github.com/elazarl/goproxy v0.0.0-20180725130230-947c36da3153/go.mod h1:/Zj4wYkgs4iZTTu3o/KG3Itv/qCCa8VVMlb3i9OVuzc=
+github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU=
 github.com/emicklei/go-restful v0.0.0-20170410110728-ff4f55a20633/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs=
 github.com/emicklei/go-restful v2.9.5+incompatible/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs=
 github.com/emicklei/go-restful/v3 v3.8.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
@@ -1333,8 +1332,8 @@ github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeME
 github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
 github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
 github.com/gin-gonic/gin v1.7.7/go.mod h1:axIBovoeJpVj8S3BwE0uPMTeReE4+AfFtqpqaZ1qq1U=
-github.com/gliderlabs/ssh v0.2.2 h1:6zsha5zo/TWhRhwqCD3+EarCAgZ2yN28ipRnGPnwkI0=
 github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0=
+github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY=
 github.com/globalsign/mgo v0.0.0-20180905125535-1ca0a4f7cbcb/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q=
 github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q=
 github.com/go-critic/go-critic v0.3.5-0.20190904082202-d79a9f0c64db/go.mod h1:+sE8vrLDS2M0pZkBk0wy6+nLdKexVDrl/jBqQOTDThA=
@@ -1344,19 +1343,18 @@ github.com/go-fonts/latin-modern v0.2.0/go.mod h1:rQVLdDMK+mK1xscDwsqM5J8U2jrRa3
 github.com/go-fonts/liberation v0.1.1/go.mod h1:K6qoJYypsmfVjWg8KOVDQhLc8UDgIK2HYqyqAO9z7GY=
 github.com/go-fonts/liberation v0.2.0/go.mod h1:K6qoJYypsmfVjWg8KOVDQhLc8UDgIK2HYqyqAO9z7GY=
 github.com/go-fonts/stix v0.1.0/go.mod h1:w/c1f0ldAUlJmLBvlbkvVXLAD+tAMqobIIQpmnUIzUY=
-github.com/go-git/gcfg v1.5.0 h1:Q5ViNfGF8zFgyJWPqYwA7qGFoMTEiBmdlkcfRmpIMa4=
 github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E=
+github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
+github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
 github.com/go-git/go-billy/v5 v5.0.0/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0=
-github.com/go-git/go-billy/v5 v5.2.0/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0=
-github.com/go-git/go-billy/v5 v5.3.1 h1:CPiOUAzKtMRvolEKw+bG1PLRpT7D3LIs3/3ey4Aiu34=
-github.com/go-git/go-billy/v5 v5.3.1/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0=
+github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU=
+github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow=
 github.com/go-git/go-git-fixtures/v4 v4.0.1/go.mod h1:m+ICp2rF3jDhFgEZ/8yziagdT1C+ZpZcrJjappBCDSw=
-github.com/go-git/go-git-fixtures/v4 v4.2.1 h1:n9gGL1Ct/yIw+nfsfr8s4+sbhT+Ncu2SubfXjIWgci8=
-github.com/go-git/go-git-fixtures/v4 v4.2.1/go.mod h1:K8zd3kDUAykwTdDCr+I0per6Y6vMiRR/nnVTBtavnB0=
+github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20230305113008-0c11038e723f h1:Pz0DHeFij3XFhoBRGUDPzSJ+w2UcK5/0JvF8DRI58r8=
 github.com/go-git/go-git/v5 v5.0.0/go.mod h1:oYD8y9kWsGINPFJoLdaScGCN6dlKg23blmClfZwtUVA=
 github.com/go-git/go-git/v5 v5.1.0/go.mod h1:ZKfuPUoY1ZqIG4QG9BDBh3G4gLM5zvPuSJAozQrZuyM=
-github.com/go-git/go-git/v5 v5.4.2 h1:BXyZu9t0VkbiHtqrsvdq39UDhGJTl1h55VW6CSC4aY4=
-github.com/go-git/go-git/v5 v5.4.2/go.mod h1:gQ1kArt6d+n+BGd+/B/I74HwRTLhth2+zti4ihgckDc=
+github.com/go-git/go-git/v5 v5.9.0 h1:cD9SFA7sHVRdJ7AYck1ZaAa/yeuBvGPxwXDL8cxrObY=
+github.com/go-git/go-git/v5 v5.9.0/go.mod h1:RKIqga24sWdMGZF+1Ekv9kylsDz6LzdTSI2s/OsZWE0=
 github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
 github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
 github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
@@ -1947,8 +1945,6 @@ github.com/imdario/mergo v0.3.10/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH
 github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
 github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
 github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg=
-github.com/imdario/mergo v0.3.15 h1:M8XP7IuFNsqUx6VPK2P9OSmsYsI/YFaGil0uD21V3dM=
-github.com/imdario/mergo v0.3.15/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY=
 github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
@@ -2066,7 +2062,6 @@ github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:C
 github.com/kellerza/template v0.0.6 h1:JNOyQ4tLPcftWYCRVD62m9n4xPR0a0BreZyLuINFQ7g=
 github.com/kellerza/template v0.0.6/go.mod h1:wH8sMB80OBc9SO54GND7ceJBZzar6jpyB6PQ3thO2JE=
 github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
-github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
 github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
 github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
 github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
@@ -2452,6 +2447,8 @@ github.com/phpdave11/gofpdi v1.0.12/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk
 github.com/phpdave11/gofpdi v1.0.13/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
 github.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0jef7pBehbT1qWhCMrIgbYNnFAZCqQ5LRc=
 github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
+github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4=
+github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI=
 github.com/pkg/browser v0.0.0-20210115035449-ce105d075bb4/go.mod h1:N6UoU20jOqggOuDwUaBQpluzLNDqif3kq9z2wpdYEfQ=
 github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
 github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
@@ -2558,8 +2555,8 @@ github.com/rogpeppe/go-internal v1.2.2/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR
 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
 github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
 github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
-github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
 github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
+github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
 github.com/rs/cors v1.8.2/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
 github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
 github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
@@ -2634,6 +2631,8 @@ github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic
 github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
 github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
 github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
+github.com/skeema/knownhosts v1.2.0 h1:h9r9cf0+u7wSE+M183ZtMGgOJKiL96brpaz5ekfJCpM=
+github.com/skeema/knownhosts v1.2.0/go.mod h1:g4fPeYpque7P0xefxtGzV81ihjC8sX2IqpAoNkjxbMo=
 github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
 github.com/smartystreets/assertions v1.0.1 h1:voD4ITNjPL5jjBfgR/r8fPIIBrliWrWHeiJApdr3r4w=
 github.com/smartystreets/goconvey v0.0.0-20190330032615-68dc04aab96a/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
@@ -2663,8 +2662,8 @@ github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3
 github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
 github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE=
 github.com/spf13/cobra v1.1.3/go.mod h1:pGADOWyqRD/YMrPZigI/zbliZ2wVD/23d+is3pSWzOo=
-github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
-github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
+github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
+github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
 github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
 github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
 github.com/spf13/pflag v0.0.0-20170130214245-9ff6c6923cff/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
@@ -2679,6 +2678,12 @@ github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5q
 github.com/spf13/viper v1.13.0/go.mod h1:Icm2xNL3/8uyh/wFuB1jI7TiTNKp8632Nwegu+zgdYw=
 github.com/stefanberger/go-pkcs11uri v0.0.0-20201008174630-78d3cae3a980 h1:lIOOHPEbXzO3vnmx2gok1Tfs31Q8GQqKLc8vVqyQq/I=
 github.com/stefanberger/go-pkcs11uri v0.0.0-20201008174630-78d3cae3a980/go.mod h1:AO3tvPzVZ/ayst6UlUKUv6rcPQInYe3IknH3jYhAKu8=
+github.com/steiler/acls v0.0.0-20231106135104-9c34ae82c793 h1:lY8SggLL0JyttzcNEIRzbywKiKcIlVlF7ZPwTthG9eA=
+github.com/steiler/acls v0.0.0-20231106135104-9c34ae82c793/go.mod h1:kS9/GuHDS4t2YmY2ijbaK3m1iU4+BgRZS7GoDTC9BfY=
+github.com/steiler/acls v0.0.0-20231106152733-bb3d5d7b05c8 h1:RqP82h2DREJxj7AJbT0k7z2Jye6lweij5s14PUHic1o=
+github.com/steiler/acls v0.0.0-20231106152733-bb3d5d7b05c8/go.mod h1:kS9/GuHDS4t2YmY2ijbaK3m1iU4+BgRZS7GoDTC9BfY=
+github.com/steiler/acls v0.1.0 h1:fKVnEJ7ebghq2Ed5N1cU9fZrCCRj4xVRPrP7OswaRX8=
+github.com/steiler/acls v0.1.0/go.mod h1:kS9/GuHDS4t2YmY2ijbaK3m1iU4+BgRZS7GoDTC9BfY=
 github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8=
 github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw=
 github.com/streadway/amqp v1.0.0/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw=
@@ -2782,9 +2787,8 @@ github.com/vultr/govultr/v2 v2.17.2/go.mod h1:ZFOKGWmgjytfyjeyAdhQlSWwTjh2ig+X49
 github.com/willf/bitset v1.1.11-0.20200630133818-d5bec3311243/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
 github.com/willf/bitset v1.1.11/go.mod h1:83CECat5yLh5zVOf4P1ErAgKA5UDvKtgyUABdr3+MjI=
 github.com/xanzy/ssh-agent v0.2.1/go.mod h1:mLlQY/MoOhWBj+gOGMQkOeiEvkx+8pJSI+0Bx9h2kr4=
-github.com/xanzy/ssh-agent v0.3.0/go.mod h1:3s9xbODqPuuhK9JV1R321M/FlMZSBvE5aY6eAcqrDh0=
-github.com/xanzy/ssh-agent v0.3.1 h1:AmzO1SSWxw73zxFZPRwaMN1MohDw8UyHnmuxyceTEGo=
-github.com/xanzy/ssh-agent v0.3.1/go.mod h1:QIE4lCeL7nkC25x+yA3LBIYfwCc1TFziCtG7cBAac6w=
+github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
+github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
 github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
 github.com/xdg-go/scram v1.0.2/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+hCSs=
 github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
@@ -3086,8 +3090,8 @@ golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
 golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
 golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
-golang.org/x/mod v0.13.0 h1:I/DsJXRlw/8l/0c24sM9yb0T4z9liZTduXvdAWYiysY=
-golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
+golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
 golang.org/x/net v0.0.0-20170114055629-f2499483f923/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -3148,7 +3152,6 @@ golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v
 golang.org/x/net v0.0.0-20210224082022-3d97a244fca7/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc=
-golang.org/x/net v0.0.0-20210326060303-6b1517762897/go.mod h1:uSPa2vr4CLtc/ILN5odXGNXS6mhrKVzTaCXzk9m6W3k=
 golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
 golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8=
 golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
@@ -3368,7 +3371,6 @@ golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210426230700-d19ff857e887/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210502180810-71e4cd670f79/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210503080704-8803ae5d1324/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
diff --git a/lab-examples/vsrx01/srx1.txt b/lab-examples/vsrx01/srx1.txt
new file mode 100644
index 00000000..94997494
--- /dev/null
+++ b/lab-examples/vsrx01/srx1.txt
@@ -0,0 +1,6 @@
+set interfaces ge-0/0/0 unit 0 family inet address 192.168.1.1/30
+set interfaces ge-0/0/1 unit 0 family inet address 192.168.2.1/30
+set security zones security-zone trust interfaces ge-0/0/0 host-inbound-traffic system-services all
+set security zones security-zone trust interfaces ge-0/0/1 host-inbound-traffic system-services all
+set system services web-management https system-generated-certificate
+set security forwarding-options family mpls mode packet-based
diff --git a/lab-examples/vsrx01/vsrx01.yml b/lab-examples/vsrx01/vsrx01.yml
new file mode 100644
index 00000000..46b29c18
--- /dev/null
+++ b/lab-examples/vsrx01/vsrx01.yml
@@ -0,0 +1,22 @@
+name: vsrx1
+topology:
+  nodes:
+    srx1:
+      kind: vr-vsrx
+      image: vrnetlab/vr-vsrx:23.2R1.13
+      startup-config: srx1.txt 
+    client1:
+      kind: "linux"
+      image: wbitt/network-multitool:alpine-extra
+      exec:
+        - ip addr add 192.168.1.2/30 dev eth1
+        - ip route add 192.168.2.0/30 via 192.168.1.1
+    client2:
+      kind: "linux"
+      image: wbitt/network-multitool:alpine-extra
+      exec:
+        - ip addr add 192.168.2.2/30 dev eth1
+        - ip route add 192.168.1.0/30 via 192.168.2.1
+  links:
+    - endpoints: ["srx1:eth1", "client1:eth1"]
+    - endpoints: ["srx1:eth2", "client2:eth1"]
diff --git a/links/link.go b/links/link.go
index 2e39a803..2598ccf8 100644
--- a/links/link.go
+++ b/links/link.go
@@ -307,19 +307,29 @@ type Link interface {
 	GetMTU() int
 }
 
-func extractHostNodeInterfaceData(lb *LinkBriefRaw, specialEPIndex int) (host, hostIf, node, nodeIf string) {
+func extractHostNodeInterfaceData(lb *LinkBriefRaw, specialEPIndex int) (host, hostIf, node, nodeIf string, err error) {
 	// the index of the node is the specialEndpointIndex +1  modulo 2
 	nodeindex := (specialEPIndex + 1) % 2
 
 	hostData := strings.SplitN(lb.Endpoints[specialEPIndex], ":", 2)
 	nodeData := strings.SplitN(lb.Endpoints[nodeindex], ":", 2)
 
+	if len(hostData) != 2 {
+		return "", "", "", "",
+			fmt.Errorf("invalid link endpoint format. expected <node>:<port>, got %s", lb.Endpoints[specialEPIndex])
+	}
+
+	if len(nodeData) != 2 {
+		return "", "", "", "",
+			fmt.Errorf("invalid link endpoint format. expected <node>:<port>, got %s", lb.Endpoints[nodeindex])
+	}
+
 	host = hostData[0]
 	hostIf = hostData[1]
 	node = nodeData[0]
 	nodeIf = nodeData[1]
 
-	return host, hostIf, node, nodeIf
+	return host, hostIf, node, nodeIf, nil
 }
 
 func genRandomIfName() string {
diff --git a/links/link_host.go b/links/link_host.go
index 125fad7f..596bc029 100644
--- a/links/link_host.go
+++ b/links/link_host.go
@@ -29,8 +29,10 @@ func (r *LinkHostRaw) ToLinkBriefRaw() *LinkBriefRaw {
 }
 
 func hostLinkFromBrief(lb *LinkBriefRaw, specialEPIndex int) (*LinkHostRaw, error) {
-	_, hostIf, node, nodeIf := extractHostNodeInterfaceData(lb, specialEPIndex)
-
+	_, hostIf, node, nodeIf, err := extractHostNodeInterfaceData(lb, specialEPIndex)
+	if err != nil {
+		return nil, err
+	}
 	link := &LinkHostRaw{
 		LinkCommonParams: lb.LinkCommonParams,
 		HostInterface:    hostIf,
diff --git a/links/link_macvlan.go b/links/link_macvlan.go
index cbed0a62..36251852 100644
--- a/links/link_macvlan.go
+++ b/links/link_macvlan.go
@@ -39,8 +39,10 @@ func (*LinkMacVlanRaw) GetType() LinkType {
 }
 
 func macVlanLinkFromBrief(lb *LinkBriefRaw, specialEPIndex int) (*LinkMacVlanRaw, error) {
-	_, hostIf, node, nodeIf := extractHostNodeInterfaceData(lb, specialEPIndex)
-
+	_, hostIf, node, nodeIf, err := extractHostNodeInterfaceData(lb, specialEPIndex)
+	if err != nil {
+		return nil, err
+	}
 	link := &LinkMacVlanRaw{
 		LinkCommonParams: lb.LinkCommonParams,
 		HostInterface:    hostIf,
diff --git a/links/link_mgmt-net.go b/links/link_mgmt-net.go
index 7b179b3d..4d9d94f9 100644
--- a/links/link_mgmt-net.go
+++ b/links/link_mgmt-net.go
@@ -81,7 +81,10 @@ func (*LinkMgmtNetRaw) GetType() LinkType {
 }
 
 func mgmtNetLinkFromBrief(lb *LinkBriefRaw, specialEPIndex int) (*LinkMgmtNetRaw, error) {
-	_, hostIf, node, nodeIf := extractHostNodeInterfaceData(lb, specialEPIndex)
+	_, hostIf, node, nodeIf, err := extractHostNodeInterfaceData(lb, specialEPIndex)
+	if err != nil {
+		return nil, err
+	}
 
 	link := &LinkMgmtNetRaw{
 		LinkCommonParams: lb.LinkCommonParams,
diff --git a/links/link_veth.go b/links/link_veth.go
index 003e6fbf..20433408 100644
--- a/links/link_veth.go
+++ b/links/link_veth.go
@@ -71,7 +71,10 @@ func (r *LinkVEthRaw) Resolve(params *ResolveParams) (Link, error) {
 
 // linkVEthRawFromLinkBriefRaw creates a raw veth link from a LinkBriefRaw.
 func linkVEthRawFromLinkBriefRaw(lb *LinkBriefRaw) (*LinkVEthRaw, error) {
-	host, hostIf, node, nodeIf := extractHostNodeInterfaceData(lb, 0)
+	host, hostIf, node, nodeIf, err := extractHostNodeInterfaceData(lb, 0)
+	if err != nil {
+		return nil, err
+	}
 
 	link := &LinkVEthRaw{
 		LinkCommonParams: lb.LinkCommonParams,
diff --git a/mkdocs.yml b/mkdocs.yml
index 1b1cccbf..c593f802 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -102,10 +102,11 @@ nav:
       - BGP VPLS between Nokia and Juniper: lab-examples/bgp-vpls-nok-jun.md
       - Keysight IXIA-C and Nokia SR Linux: lab-examples/ixiacone-srl.md
       - Multi-node labs: lab-examples/multinode.md
+      - RARE/freeRtr: lab-examples/rare-freertr.md
+      - Juniper vSRX: lab-examples/vsrx01.md
       - Templated labs:
           - Leaf-spine topology: lab-examples/templated01.md
           - 5-stage Clos topology: lab-examples/templated02.md
-      - RARE/freeRtr: lab-examples/rare-freertr.md
   - Release notes:
       - "0.47": rn/0.47.md
       - "0.46": rn/0.46.md
diff --git a/nodes/srl/srl.go b/nodes/srl/srl.go
index d1b8b718..7786b6d3 100644
--- a/nodes/srl/srl.go
+++ b/nodes/srl/srl.go
@@ -252,7 +252,7 @@ func (s *srl) PreDeploy(_ context.Context, params *nodes.PreDeployParams) error
 		for _, fullpath := range agents {
 			basename := filepath.Base(fullpath)
 			// if it is a url extract filename from url or content-disposition header
-			if utils.IsHttpUri(fullpath) {
+			if utils.IsHttpURL(fullpath, false) {
 				basename = utils.FilenameForURL(fullpath)
 			}
 			// enforce yml extension
diff --git a/utils/file.go b/utils/file.go
index eefa4ad0..9f29c629 100644
--- a/utils/file.go
+++ b/utils/file.go
@@ -6,6 +6,7 @@ package utils
 
 import (
 	"bufio"
+	"crypto/tls"
 	"errors"
 	"fmt"
 	"io"
@@ -17,8 +18,11 @@ import (
 	"os/exec"
 	"os/user"
 	"path/filepath"
+	"strconv"
 	"strings"
 
+	"github.com/steiler/acls"
+
 	log "github.com/sirupsen/logrus"
 )
 
@@ -36,12 +40,19 @@ func FileExists(filename string) bool {
 	return !f.IsDir()
 }
 
+// FileOrDirExists returns true if a file or dir referenced by path exists & accessible.
+func FileOrDirExists(filename string) bool {
+	f, err := os.Stat(filename)
+
+	return err == nil && f != nil
+}
+
 // CopyFile copies a file from src to dst. If src and dst files exist, and are
 // the same, then return success. Otherwise, copy the file contents from src to dst.
 // mode is the desired target file permissions, e.g. "0644".
 func CopyFile(src, dst string, mode os.FileMode) (err error) {
 	var sfi os.FileInfo
-	if !IsHttpUri(src) {
+	if !IsHttpURL(src, false) {
 		sfi, err = os.Stat(src)
 		if err != nil {
 			return err
@@ -73,9 +84,29 @@ func CopyFile(src, dst string, mode os.FileMode) (err error) {
 	return CopyFileContents(src, dst, mode)
 }
 
-// IsHttpUri check if the url is a downloadable uri.
-func IsHttpUri(s string) bool {
-	return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://")
+// IsHttpURL checks if the url is a downloadable HTTP URL.
+// The allowSchemaless toggle when set to true will allow URLs without a schema
+// such as "srlinux.dev/clab-srl". This is shortened notion that is used with
+// "deploy -t <url>" only.
+// Other callers of IsHttpURL should set the toggle to false.
+func IsHttpURL(s string, allowSchemaless bool) bool {
+	// '-' denotes stdin and not the URL
+	if s == "-" {
+		return false
+	}
+
+	//
+	if !allowSchemaless && !strings.HasPrefix(s, "http://") && !strings.HasPrefix(s, "https://") {
+		return false
+	}
+
+	if !strings.HasPrefix(s, "http://") && !strings.HasPrefix(s, "https://") {
+		s = "https://" + s
+	}
+
+	u, err := url.ParseRequestURI(s)
+
+	return err == nil && u.Host != ""
 }
 
 // CopyFileContents copies the contents of the file named src to the file named
@@ -86,8 +117,11 @@ func IsHttpUri(s string) bool {
 func CopyFileContents(src, dst string, mode os.FileMode) (err error) {
 	var in io.ReadCloser
 
-	if IsHttpUri(src) {
-		resp, err := http.Get(src)
+	if IsHttpURL(src, false) {
+		client := NewHTTPClient()
+
+		// download using client
+		resp, err := client.Get(src)
 		if err != nil || resp.StatusCode != 200 {
 			return fmt.Errorf("%w: %s", errHTTPFetch, src)
 		}
@@ -263,7 +297,7 @@ func FilenameForURL(rawUrl string) string {
 	}
 
 	// try extracting the filename from "content-disposition" header
-	if IsHttpUri(rawUrl) {
+	if IsHttpURL(rawUrl, false) {
 		resp, err := http.Head(rawUrl)
 		if err != nil {
 			return filepath.Base(u.Path)
@@ -301,3 +335,75 @@ func FileLines(path, commentStr string) ([]string, error) {
 
 	return lines, nil
 }
+
+// NewHTTPClient creates a new HTTP client with
+// insecure skip verify set to true and min TLS version set to 1.2.
+func NewHTTPClient() *http.Client {
+	// set InsecureSkipVerify to true to allow fetching
+	// files form servers with self-signed certificates
+	tr := &http.Transport{
+		TLSClientConfig: &tls.Config{
+			InsecureSkipVerify: true, // skipcq: GSC-G402
+			MinVersion:         tls.VersionTLS12,
+		},
+	}
+
+	return &http.Client{Transport: tr}
+}
+
+// AdjustFileACLs takes the given fs path, tries to load
+// the access file acl of that path and adds ACL rules
+// rwx for the SUDO_UID and r-x for the SUDO_GID group.
+func AdjustFileACLs(fsPath string) error {
+	/// here we trust sudo to set up env variables
+	// a missing SUDO_UID env var indicates the root user
+	// runs clab without sudo
+	uid, isSet := os.LookupEnv("SUDO_UID")
+	if !isSet {
+		// nothing to do, already running as root
+		return nil
+	}
+
+	gid, isSet := os.LookupEnv("SUDO_GID")
+	if !isSet {
+		return fmt.Errorf("unable to retrieve GID. will only adjust UID for %q", fsPath)
+	}
+
+	iUID, err := strconv.Atoi(uid)
+	if err != nil {
+		return fmt.Errorf("unable to convert SUDO_UID %q to int", uid)
+	}
+
+	iGID, err := strconv.Atoi(gid)
+	if err != nil {
+		return fmt.Errorf("unable to convert SUDO_GID %q to int", gid)
+	}
+
+	// create a new ACL instance
+	a := &acls.ACL{}
+	// load the existing ACL entries of the PosixACLAccess type
+	err = a.Load(fsPath, acls.PosixACLAccess)
+	if err != nil {
+		return err
+	}
+
+	// add an entry for the group
+	err = a.AddEntry(acls.NewEntry(acls.TAG_ACL_GROUP, uint32(iGID), 5))
+	if err != nil {
+		return err
+	}
+
+	// add an entry for the User
+	err = a.AddEntry(acls.NewEntry(acls.TAG_ACL_USER, uint32(iUID), 7))
+	if err != nil {
+		return err
+	}
+
+	// apply the ACL and return the error result
+	err = a.Apply(fsPath, acls.PosixACLAccess)
+	if err != nil {
+		return err
+	}
+
+	return a.Apply(fsPath, acls.PosixACLDefault)
+}
diff --git a/utils/http.go b/utils/http.go
deleted file mode 100644
index cc4c1cd4..00000000
--- a/utils/http.go
+++ /dev/null
@@ -1,110 +0,0 @@
-package utils
-
-import (
-	"bytes"
-	"errors"
-	"fmt"
-	"net/url"
-	"os/exec"
-	"strings"
-
-	log "github.com/sirupsen/logrus"
-)
-
-var errInvalidGithubURL = errors.New("invalid Github URL")
-
-// GithubURL struct holds the parsed github url.
-type GithubURL struct {
-	URLBase        string
-	ProjectOwner   string
-	RepositoryName string
-	GitBranch      string
-	FileName       string
-}
-
-// NewGithubURL returns a pointer to a GithubURI struct
-func NewGithubURL() *GithubURL {
-	return &GithubURL{}
-}
-
-// Parse parses the github.com string url into the GithubURL struct.
-func (u *GithubURL) Parse(ghURL string) error {
-	// strip trailing slash
-	ghURL = strings.TrimSuffix(ghURL, "/")
-
-	parsedURL, err := url.Parse(ghURL)
-	if err != nil {
-		return err
-	}
-
-	splitPath := strings.Split(parsedURL.Path, "/")
-
-	if len(splitPath) < 3 || splitPath[2] == "" {
-		return fmt.Errorf("%w %s", errInvalidGithubURL, ghURL)
-	}
-
-	// github.dev links can be cloned using github.com
-	if parsedURL.Host == "github.dev" {
-		parsedURL.Host = "github.com"
-	}
-
-	u.URLBase = parsedURL.Scheme + "://" + parsedURL.Host
-	u.ProjectOwner = splitPath[1]
-
-	// in case repo url has a trailing .git suffix, trim it
-	splitPath[2] = strings.TrimSuffix(splitPath[2], ".git")
-	u.RepositoryName = splitPath[2]
-
-	switch {
-	// path points to a file at a specific git ref
-	case strings.Contains(ghURL, "/blob/"):
-		if splitPath[len(splitPath)-1] == "" || !(strings.HasSuffix(ghURL, ".yml") || strings.HasSuffix(ghURL, ".yaml")) {
-			return errInvalidGithubURL
-		}
-
-		u.FileName = splitPath[len(splitPath)-1]
-		u.GitBranch = splitPath[len(splitPath)-2]
-
-	// path points to a git ref (branch or tag)
-	case strings.Contains(ghURL, "/tree/"):
-		if splitPath[len(splitPath)-1] == "" {
-			return errInvalidGithubURL
-		}
-
-		u.GitBranch = splitPath[len(splitPath)-1]
-	}
-
-	return nil
-}
-
-// CloneGithubRepo clones the github repo into the current directory.
-func CloneGithubRepo(u *GithubURL) error {
-	cloneArgs := []string{"clone", u.URLBase + "/" + u.ProjectOwner + "/" + u.RepositoryName, "--depth", "1"}
-	if u.GitBranch != "" {
-		cloneArgs = append(cloneArgs, []string{"--branch", u.GitBranch}...)
-	}
-
-	cmd := exec.Command("git", cloneArgs...)
-
-	log.Infof("cloning %s/%s", u.ProjectOwner, u.RepositoryName)
-
-	cmd.Stdout = log.New().Writer()
-
-	var stderr bytes.Buffer
-	cmd.Stderr = &stderr
-
-	err := cmd.Run()
-	if err != nil {
-		log.Errorf("failed to clone %s/%s: %v", u.ProjectOwner, u.RepositoryName, err)
-		log.Error(stderr.String())
-		return err
-	}
-
-	return nil
-}
-
-// IsGitHubURL checks if the url is a github url.
-func IsGitHubURL(url string) bool {
-	return strings.Contains(url, "github.com") ||
-		strings.Contains(url, "github.dev")
-}


Test patch:
diff --git a/.github/workflows/vxlan-tests.yml b/.github/workflows/vxlan-tests.yml
index 37865a66..7563c592 100644
--- a/.github/workflows/vxlan-tests.yml
+++ b/.github/workflows/vxlan-tests.yml
@@ -61,5 +61,5 @@ jobs:
         if: always()
         with:
           name: coverage
-          path: ./tests/coverage/*
+          path: /tmp/clab-tests/coverage/*
           retention-days: 7
diff --git a/docs/htmltest-w-github.yml b/docs/htmltest-w-github.yml
index 8800b25c..92cf349e 100644
--- a/docs/htmltest-w-github.yml
+++ b/docs/htmltest-w-github.yml
@@ -16,6 +16,7 @@ IgnoreURLs:
   - https://linkedin.com/in
   - https://www.linkedin.com/in
   - mysocket.io # remove mysocket.io links until we rework to border0.com
+  - https://gist.githubusercontent.com/hellt/abc/raw/def/linux.clab.yml # test link used in docs
 IgnoreDirectoryMissingTrailingSlash: true
 IgnoreAltMissing: true
 IgnoreSSLVerify: true
diff --git a/docs/htmltest.yml b/docs/htmltest.yml
index 38c547d6..4a0f1c8d 100644
--- a/docs/htmltest.yml
+++ b/docs/htmltest.yml
@@ -17,6 +17,7 @@ IgnoreURLs:
   - mysocket.io # remove mysocket.io links until we rework to border0.com
   - https://supportcenter.checkpoint.com/ # started to fail, meh.
   - https://marketplace.visualstudio.com/*
+  - https://gist.githubusercontent.com/hellt/abc/raw/def/linux.clab.yml # test link used in docs
 IgnoreDirectoryMissingTrailingSlash: true
 IgnoreAltMissing: true
 IgnoreSSLVerify: true
diff --git a/git/__repo_parser_test.go b/git/__repo_parser_test.go
new file mode 100644
index 00000000..55d687b5
--- /dev/null
+++ b/git/__repo_parser_test.go
@@ -0,0 +1,586 @@
+package git
+
+import (
+	"errors"
+	"net/url"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+)
+
+func TestIsGitHubURL(t *testing.T) {
+	tests := []struct {
+		name  string
+		input string
+		want  bool
+	}{
+		{
+			name:  "https://github.com",
+			input: "https://github.com",
+			want:  true,
+		},
+		{
+			name:  "https://github.com/containers/containerlab/blob/master/README.md",
+			input: "https://github.com/containers/containerlab/blob/master/README.md",
+			want:  true,
+		},
+		{
+			name:  "https://google.com/containers",
+			input: "https://google.com/containers",
+			want:  false,
+		},
+		{
+			name:  "https://google.com/containers/containerlab/blob/master/README.md",
+			input: "https://google.com/containers/containerlab/blob/master/README.md",
+			want:  false,
+		},
+		{
+			name:  "https://gitlab.com/containers",
+			input: "https://gitlab.com/containers",
+			want:  false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			u, err := url.Parse(tt.input)
+			if err != nil {
+				t.Errorf("failed parsing url provided in test.")
+			}
+
+			if output := IsGitHubURL(u); output != tt.want {
+				t.Errorf("Test %q failed: want %v, but got %v", tt.name, tt.want, output)
+			}
+		})
+	}
+}
+
+func TestGitHubGitRepoParse(t *testing.T) {
+	tests := []struct {
+		name           string
+		input          string
+		expectedResult *GitHubRepo
+		expectedError  error
+	}{
+		{
+			name:  "bare github url without trailing slash",
+			input: "https://github.com/srl-labs/repo-name",
+			expectedResult: &GitHubRepo{
+				GitRepoStruct: GitRepoStruct{
+					URL: url.URL{
+						Scheme: "https",
+						Host:   "github.com",
+					},
+					Path:           nil,
+					ProjectOwner:   "srl-labs",
+					RepositoryName: "repo-name",
+				},
+			},
+			expectedError: nil,
+		},
+		{
+			name:  "bare github url with trailing slash",
+			input: "https://github.com/srl-labs/repo-name/",
+			expectedResult: &GitHubRepo{
+				GitRepoStruct: GitRepoStruct{
+					URL: url.URL{
+						Scheme: "https",
+						Host:   "github.com",
+					},
+					Path:           nil,
+					ProjectOwner:   "srl-labs",
+					RepositoryName: "repo-name",
+				},
+			},
+			expectedError: nil,
+		},
+		{
+			name:  "bare github.dev url with trailing slash",
+			input: "https://github.dev/srl-labs/repo-name/",
+			expectedResult: &GitHubRepo{
+				GitRepoStruct: GitRepoStruct{
+					URL: url.URL{
+						Scheme: "https",
+						Host:   "github.com",
+					},
+					Path:           nil,
+					ProjectOwner:   "srl-labs",
+					RepositoryName: "repo-name",
+				},
+			},
+			expectedError: nil,
+		},
+		{
+			name:  "bare github url with .git suffix",
+			input: "https://github.com/srl-labs/repo-name.git",
+			expectedResult: &GitHubRepo{
+				GitRepoStruct: GitRepoStruct{
+					URL: url.URL{
+						Scheme: "https",
+						Host:   "github.com",
+					},
+					Path:           nil,
+					ProjectOwner:   "srl-labs",
+					RepositoryName: "repo-name",
+				},
+			},
+			expectedError: nil,
+		},
+		{
+			name:           "invalid url with just org name",
+			input:          "https://github.com/srl-labs/",
+			expectedResult: &GitHubRepo{},
+			expectedError:  errInvalidURL,
+		},
+		{
+			name:           "invalid url with no owner and no org",
+			input:          "https://github.com/",
+			expectedResult: &GitHubRepo{},
+			expectedError:  errInvalidURL,
+		},
+		{
+			name:  "github url with a clab file on the main branch",
+			input: "https://github.com/srl-labs/repo-name/blob/main/file.clab.yml",
+			expectedResult: &GitHubRepo{
+				GitRepoStruct: GitRepoStruct{
+					URL: url.URL{
+						Scheme: "https",
+						Host:   "github.com",
+					},
+					Path:           []string{},
+					ProjectOwner:   "srl-labs",
+					RepositoryName: "repo-name",
+					GitBranch:      "main",
+					FileName:       "file.clab.yml",
+				},
+			},
+			expectedError: nil,
+		},
+		{
+			name:  "github url with a yaml file on the main branch",
+			input: "https://github.com/srl-labs/repo-name/blob/main/file.yaml",
+			expectedResult: &GitHubRepo{
+				GitRepoStruct: GitRepoStruct{
+					URL: url.URL{
+						Scheme: "https",
+						Host:   "github.com",
+					},
+					ProjectOwner:   "srl-labs",
+					RepositoryName: "repo-name",
+					GitBranch:      "main",
+					Path:           []string{},
+					FileName:       "file.yaml",
+				},
+			},
+			expectedError: nil,
+		},
+		{
+			name:           "url with invalid file on the main branch",
+			input:          "https://github.com/srl-labs/repo-name/blob/main/file.foo",
+			expectedResult: &GitHubRepo{},
+			expectedError:  errInvalidURL,
+		},
+		{
+			name:  "github url with a specified git ref and no file",
+			input: "https://github.com/srl-labs/repo-name/tree/some-branch",
+			expectedResult: &GitHubRepo{
+				GitRepoStruct: GitRepoStruct{
+					URL: url.URL{
+						Scheme: "https",
+						Host:   "github.com",
+					},
+					ProjectOwner:   "srl-labs",
+					RepositoryName: "repo-name",
+					GitBranch:      "some-branch",
+					Path:           []string{},
+				},
+			},
+			expectedError: nil,
+		},
+		{
+			name:  "github url with a specified git ref and no file and trailing slash",
+			input: "https://github.com/srl-labs/repo-name/tree/some-branch/",
+			expectedResult: &GitHubRepo{
+				GitRepoStruct: GitRepoStruct{
+					URL: url.URL{
+						Scheme: "https",
+						Host:   "github.com",
+					},
+					ProjectOwner:   "srl-labs",
+					RepositoryName: "repo-name",
+					GitBranch:      "some-branch",
+					Path:           []string{},
+				},
+			},
+			expectedError: nil,
+		},
+		{
+			name:  "github url with ref to file in subdir",
+			input: "https://github.com/srl-labs/containerlab/blob/main/lab-examples/srl01/srl01.clab.yml",
+			expectedResult: &GitHubRepo{
+				GitRepoStruct: GitRepoStruct{
+					URL: url.URL{
+						Scheme: "https",
+						Host:   "github.com",
+					},
+					ProjectOwner:   "srl-labs",
+					RepositoryName: "containerlab",
+					GitBranch:      "main",
+					Path:           []string{"lab-examples", "srl01"},
+					FileName:       "srl01.clab.yml",
+				},
+			},
+			expectedError: nil,
+		},
+		{
+			name:  "github url with ref to subdir",
+			input: "https://github.com/srl-labs/containerlab/tree/main/lab-examples/srl01/",
+			expectedResult: &GitHubRepo{
+				GitRepoStruct: GitRepoStruct{
+					URL: url.URL{
+						Scheme: "https",
+						Host:   "github.com",
+					},
+					ProjectOwner:   "srl-labs",
+					RepositoryName: "containerlab",
+					GitBranch:      "main",
+					Path:           []string{"lab-examples", "srl01"},
+				},
+			},
+			expectedError: nil,
+		},
+		{
+			name:  "github url with tree ref to repo root",
+			input: "https://github.com/srl-labs/containerlab/tree/main",
+			expectedResult: &GitHubRepo{
+				GitRepoStruct: GitRepoStruct{
+					URL: url.URL{
+						Scheme: "https",
+						Host:   "github.com",
+					},
+					ProjectOwner:   "srl-labs",
+					RepositoryName: "containerlab",
+					GitBranch:      "main",
+					Path:           []string{},
+				},
+			},
+			expectedError: nil,
+		},
+		{
+			name:  "github url with tree ref to file in repo root",
+			input: "https://github.com/srl-labs/containerlab/blob/main/mytopo.yml",
+			expectedResult: &GitHubRepo{
+				GitRepoStruct: GitRepoStruct{
+					URL: url.URL{
+						Scheme: "https",
+						Host:   "github.com",
+					},
+					ProjectOwner:   "srl-labs",
+					RepositoryName: "containerlab",
+					GitBranch:      "main",
+					Path:           []string{},
+					FileName:       "mytopo.yml",
+				},
+			},
+			expectedError: nil,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			u, err := url.Parse(tt.input)
+			if err != nil {
+				t.Errorf("failed parsing url provided in test.")
+			}
+			repo, err := ParseGitHubRepoUrl(u)
+
+			if err != nil && tt.expectedError == nil {
+				t.Fatalf("unexpected error: %v", err)
+			}
+
+			if err == nil && tt.expectedError != nil {
+				t.Errorf("expected error: %v, but got nil", tt.expectedError)
+			}
+
+			if err != nil && tt.expectedError != nil {
+				if !errors.Is(err, tt.expectedError) {
+					t.Fatalf("expected error: %v, but got %v", err, tt.expectedError)
+				}
+				// exit the test case as we don't want to compare url structs
+				// since when error is available and matches the expected error
+				// we don't care about the state the struct is in
+				return
+			}
+
+			if diff := cmp.Diff(repo, tt.expectedResult); diff != "" {
+				t.Errorf("got result: = %v, expected %v, diff:\n%s", repo, tt.expectedResult, diff)
+			}
+		})
+	}
+}
+
+func TestParseGitLabRepoUrl(t *testing.T) {
+	tests := []struct {
+		name           string
+		input          string
+		expectedResult *GitLabRepo
+		expectedError  error
+	}{
+		{
+			name:  "bare gitlab url without trailing slash",
+			input: "https://fake.gitlab.com/user/repo-name",
+			expectedResult: &GitLabRepo{
+				GitRepoStruct: GitRepoStruct{
+					URL: url.URL{
+						Scheme: "https",
+						Host:   "fake.gitlab.com",
+					},
+					Path:           nil,
+					ProjectOwner:   "user",
+					RepositoryName: "repo-name",
+				},
+			},
+			expectedError: nil,
+		},
+		{
+			name:  "bare gitlab url with trailing slash",
+			input: "https://fake.gitlab.com/user/repo-name/",
+			expectedResult: &GitLabRepo{
+				GitRepoStruct: GitRepoStruct{
+					URL: url.URL{
+						Scheme: "https",
+						Host:   "fake.gitlab.com",
+					},
+					Path:           nil,
+					ProjectOwner:   "user",
+					RepositoryName: "repo-name",
+				},
+			},
+			expectedError: nil,
+		},
+		{
+			name:  "bare github url with .git suffix",
+			input: "https://fake.gitlab.com/user/repo-name.git",
+			expectedResult: &GitLabRepo{
+				GitRepoStruct: GitRepoStruct{
+					URL: url.URL{
+						Scheme: "https",
+						Host:   "fake.gitlab.com",
+					},
+					Path:           nil,
+					ProjectOwner:   "user",
+					RepositoryName: "repo-name",
+				},
+			},
+			expectedError: nil,
+		},
+		{
+			name:           "invalid url with just org name",
+			input:          "https://fake.gitlab.com/user/",
+			expectedResult: &GitLabRepo{},
+			expectedError:  errInvalidURL,
+		},
+		{
+			name:           "invalid url with no owner and no org",
+			input:          "https:/fake.gitlab.com/",
+			expectedResult: &GitLabRepo{},
+			expectedError:  errInvalidURL,
+		},
+		{
+			name:  "gitlab url with a clab file on the main branch",
+			input: "https://fake.gitlab.com/user/repo-name/-/blob/main/file.clab.yml",
+			expectedResult: &GitLabRepo{
+				GitRepoStruct: GitRepoStruct{
+					URL: url.URL{
+						Scheme: "https",
+						Host:   "fake.gitlab.com",
+					},
+					Path:           []string{},
+					ProjectOwner:   "user",
+					RepositoryName: "repo-name",
+					GitBranch:      "main",
+					FileName:       "file.clab.yml",
+				},
+			},
+			expectedError: nil,
+		},
+		{
+			name:  "gitlab url with a yaml file on the main branch",
+			input: "https://fake.gitlab.com/user/repo-name/-/blob/main/file.yaml",
+			expectedResult: &GitLabRepo{
+				GitRepoStruct: GitRepoStruct{
+					URL: url.URL{
+						Scheme: "https",
+						Host:   "fake.gitlab.com",
+					},
+					ProjectOwner:   "user",
+					RepositoryName: "repo-name",
+					GitBranch:      "main",
+					Path:           []string{},
+					FileName:       "file.yaml",
+				},
+			},
+			expectedError: nil,
+		},
+		{
+			name:           "url with invalid file on the main branch",
+			input:          "https://fake.gitlab.com/user/repo-name/-/blob/main/file.foo",
+			expectedResult: &GitLabRepo{},
+			expectedError:  errInvalidURL,
+		},
+		{
+			name:  "gitlab url with a specified git ref and no file",
+			input: "https://fake.gitlab.com/user/repo-name/-/tree/some-branch",
+			expectedResult: &GitLabRepo{
+				GitRepoStruct: GitRepoStruct{
+					URL: url.URL{
+						Scheme: "https",
+						Host:   "fake.gitlab.com",
+					},
+					ProjectOwner:   "user",
+					RepositoryName: "repo-name",
+					GitBranch:      "some-branch",
+					Path:           []string{},
+				},
+			},
+			expectedError: nil,
+		},
+		{
+			name:  "gitlab url with a specified git ref and no file and trailing slash",
+			input: "https://fake.gitlab.com/user/repo-name/-/tree/some-branch/",
+			expectedResult: &GitLabRepo{
+				GitRepoStruct: GitRepoStruct{
+					URL: url.URL{
+						Scheme: "https",
+						Host:   "fake.gitlab.com",
+					},
+					ProjectOwner:   "user",
+					RepositoryName: "repo-name",
+					GitBranch:      "some-branch",
+					Path:           []string{},
+				},
+			},
+			expectedError: nil,
+		},
+		{
+			name:  "gitlab url with ref to file in subdir",
+			input: "https://fake.gitlab.com/user/containerlab/-/blob/main/lab-examples/srl01/srl01.clab.yml",
+			expectedResult: &GitLabRepo{
+				GitRepoStruct: GitRepoStruct{
+					URL: url.URL{
+						Scheme: "https",
+						Host:   "fake.gitlab.com",
+					},
+					ProjectOwner:   "user",
+					RepositoryName: "containerlab",
+					GitBranch:      "main",
+					Path:           []string{"lab-examples", "srl01"},
+					FileName:       "srl01.clab.yml",
+				},
+			},
+			expectedError: nil,
+		},
+		{
+			name:  "gitlab url with ref to subdir",
+			input: "https://fake.gitlab.com/user/containerlab/-/tree/main/lab-examples/srl01/",
+			expectedResult: &GitLabRepo{
+				GitRepoStruct: GitRepoStruct{
+					URL: url.URL{
+						Scheme: "https",
+						Host:   "fake.gitlab.com",
+					},
+					ProjectOwner:   "user",
+					RepositoryName: "containerlab",
+					GitBranch:      "main",
+					Path:           []string{"lab-examples", "srl01"},
+				},
+			},
+			expectedError: nil,
+		},
+		{
+			name:  "gitlab url with tree ref to repo root",
+			input: "https://fake.gitlab.com/user/containerlab/-/tree/main",
+			expectedResult: &GitLabRepo{
+				GitRepoStruct: GitRepoStruct{
+					URL: url.URL{
+						Scheme: "https",
+						Host:   "fake.gitlab.com",
+					},
+					ProjectOwner:   "user",
+					RepositoryName: "containerlab",
+					GitBranch:      "main",
+					Path:           []string{},
+				},
+			},
+			expectedError: nil,
+		},
+		{
+			name:  "gitlab url with tree ref to file in repo root",
+			input: "https://fake.gitlab.com/user/containerlab/-/blob/main/mytopo.yml",
+			expectedResult: &GitLabRepo{
+				GitRepoStruct: GitRepoStruct{
+					URL: url.URL{
+						Scheme: "https",
+						Host:   "fake.gitlab.com",
+					},
+					ProjectOwner:   "user",
+					RepositoryName: "containerlab",
+					GitBranch:      "main",
+					Path:           []string{},
+					FileName:       "mytopo.yml",
+				},
+			},
+			expectedError: nil,
+		},
+		{
+			name:  "gitlab url with tree ref to file in repo root and query parameters",
+			input: "https://fake.gitlab.com/user/containerlab/-/blob/main/mytopo.yml?foo=bar",
+			expectedResult: &GitLabRepo{
+				GitRepoStruct: GitRepoStruct{
+					URL: url.URL{
+						Scheme: "https",
+						Host:   "fake.gitlab.com",
+					},
+					ProjectOwner:   "user",
+					RepositoryName: "containerlab",
+					GitBranch:      "main",
+					Path:           []string{},
+					FileName:       "mytopo.yml",
+				},
+			},
+			expectedError: nil,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+
+			u, err := url.Parse(tt.input)
+			if err != nil {
+				t.Errorf("failed parsing url provided in test.")
+			}
+			repo, err := ParseGitLabRepoUrl(u)
+
+			if err != nil && tt.expectedError == nil {
+				t.Fatalf("unexpected error: %v", err)
+			}
+
+			if err == nil && tt.expectedError != nil {
+				t.Errorf("expected error: %v, but got nil", tt.expectedError)
+			}
+
+			if err != nil && tt.expectedError != nil {
+				if !errors.Is(err, tt.expectedError) {
+					t.Fatalf("expected error: %v, but got %v", tt.expectedError, err)
+				}
+				// exit the test case as we don't want to compare url structs
+				// since when error is available and matches the expected error
+				// we don't care about the state the struct is in
+				return
+			}
+
+			if diff := cmp.Diff(repo, tt.expectedResult); diff != "" {
+				t.Errorf("got result: = %v, expected %v, diff:\n%s", repo, tt.expectedResult, diff)
+			}
+		})
+	}
+}
diff --git a/git/github_test.go b/git/github_test.go
new file mode 100644
index 00000000..e2bdf4d9
--- /dev/null
+++ b/git/github_test.go
@@ -0,0 +1,269 @@
+package git
+
+import (
+	"errors"
+	"net/url"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+)
+
+// urlFromStr is a helper function to create a url.URL from a string.
+func urlFromStr(s string) *url.URL {
+	u, err := url.Parse(s)
+	if err != nil {
+		panic(err)
+	}
+	return u
+}
+
+func TestNewGitHubRepoFromURL(t *testing.T) {
+	tests := []struct {
+		name    string
+		url     string
+		repo    *GitHubRepo
+		wantErr bool
+	}{
+		{
+			name: "valid github url without trailing slash and with https schema",
+			url:  "https://github.com/hellt/clab-test-repo",
+			repo: &GitHubRepo{
+				GitRepoStruct{
+					ProjectOwner:   "hellt",
+					RepositoryName: "clab-test-repo",
+				},
+			},
+			wantErr: false,
+		},
+		{
+			name: "valid github url with trailing slash and with https schema",
+			url:  "https://github.com/hellt/clab-test-repo/",
+			repo: &GitHubRepo{
+				GitRepoStruct{
+					URL:            urlFromStr("https://github.com/hellt/clab-test-repo/"),
+					CloneURL:       urlFromStr("https://github.com/hellt/clab-test-repo"),
+					ProjectOwner:   "hellt",
+					RepositoryName: "clab-test-repo",
+				},
+			},
+			wantErr: false,
+		},
+		{
+			name: "valid github.dev url without trailing slash and with https schema",
+			url:  "https://github.dev/hellt/clab-test-repo",
+			repo: &GitHubRepo{
+				GitRepoStruct{
+					// github.dev links can be cloned using github.com
+					URL:            urlFromStr("https://github.com/hellt/clab-test-repo"),
+					CloneURL:       urlFromStr("https://github.com/hellt/clab-test-repo"),
+					ProjectOwner:   "hellt",
+					RepositoryName: "clab-test-repo",
+				},
+			},
+			wantErr: false,
+		},
+		{
+			name: "valid github.dev url with trailing slash and with https schema",
+			url:  "https://github.dev/hellt/clab-test-repo/",
+			repo: &GitHubRepo{
+				GitRepoStruct{
+					// github.dev links can be cloned using github.com
+					URL:            urlFromStr("https://github.com/hellt/clab-test-repo/"),
+					CloneURL:       urlFromStr("https://github.com/hellt/clab-test-repo"),
+					ProjectOwner:   "hellt",
+					RepositoryName: "clab-test-repo",
+				},
+			},
+			wantErr: false,
+		},
+		{
+			name:    "invalid github url with just one path element and https schema and trailing slash",
+			url:     "https://github.com/hellt/",
+			wantErr: true,
+		},
+		{
+			name: "valid github url with https schema and with .git suffix",
+			url:  "https://github.com/hellt/clab-test-repo.git",
+			repo: &GitHubRepo{
+				GitRepoStruct{
+					ProjectOwner:   "hellt",
+					RepositoryName: "clab-test-repo",
+				},
+			},
+			wantErr: false,
+		},
+		{
+			name: "github url with a clab file on the main branch",
+			url:  "https://github.com/hellt/clab-test-repo/blob/main/lab1.clab.yml",
+			repo: &GitHubRepo{
+				GitRepoStruct{
+					ProjectOwner:   "hellt",
+					RepositoryName: "clab-test-repo",
+					CloneURL:       urlFromStr("https://github.com/hellt/clab-test-repo"),
+					GitBranch:      "main",
+					FileName:       "lab1.clab.yml",
+				},
+			},
+			wantErr: false,
+		},
+		{
+			name:    "invalid file in a github url with on the main branch",
+			url:     "https://github.com/hellt/clab-test-repo/blob/main/lab1.foo",
+			wantErr: true,
+		},
+		{
+			name: "github url with a clab file on the main branch in a nested dir",
+			url:  "https://github.com/hellt/clab-test-repo/blob/main/dir/lab3.clab.yml",
+			repo: &GitHubRepo{
+				GitRepoStruct{
+					ProjectOwner:   "hellt",
+					RepositoryName: "clab-test-repo",
+					CloneURL:       urlFromStr("https://github.com/hellt/clab-test-repo"),
+					GitBranch:      "main",
+					Path:           []string{"dir"},
+					FileName:       "lab3.clab.yml",
+				},
+			},
+			wantErr: false,
+		},
+		{
+			name: "github url pointing to a branch",
+			url:  "https://github.com/hellt/clab-test-repo/tree/branch1",
+			repo: &GitHubRepo{
+				GitRepoStruct{
+					ProjectOwner:   "hellt",
+					RepositoryName: "clab-test-repo",
+					CloneURL:       urlFromStr("https://github.com/hellt/clab-test-repo"),
+					GitBranch:      "branch1",
+				},
+			},
+			wantErr: false,
+		},
+		{
+			name: "github url pointing to a file in a branch",
+			url:  "https://github.com/hellt/clab-test-repo/blob/branch1/lab2.clab.yml",
+			repo: &GitHubRepo{
+				GitRepoStruct{
+					ProjectOwner:   "hellt",
+					RepositoryName: "clab-test-repo",
+					CloneURL:       urlFromStr("https://github.com/hellt/clab-test-repo"),
+					GitBranch:      "branch1",
+					FileName:       "lab2.clab.yml",
+				},
+			},
+			wantErr: false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			repo, err := NewGitHubRepoFromURL(urlFromStr(tt.url))
+
+			if (err != nil) != tt.wantErr {
+				t.Errorf("ParseGitURL() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+
+			if (err != nil) && errors.Is(err, errInvalidURL) {
+				return
+			}
+
+			// when we do not manipulate url path in the ParseGitURL method
+			// the wantRepo.URL field will be nil, so we need to set it up
+			// to match the original URL
+			if tt.repo.URL == nil {
+				tt.repo.URL = urlFromStr(tt.url)
+			}
+
+			// when we do not manipulate CloneURL in the ParseGitURL method
+			// the wantRepo.CloneURL field will be nil, so we need to set it up
+			// to match the original URL
+			if tt.repo.CloneURL == nil {
+				tt.repo.CloneURL = urlFromStr(tt.url)
+			}
+
+			if diff := cmp.Diff(repo, tt.repo); diff != "" {
+				t.Errorf("TestNewGitLabRepoFromURL() mismatch:\n%s", diff)
+			}
+		})
+	}
+}
+
+func TestIsGitHubURL(t *testing.T) {
+	tests := []struct {
+		name  string
+		input string
+		want  bool
+	}{
+		{
+			name:  "github.com",
+			input: "http://github.com",
+			want:  true,
+		},
+		{
+			name:  "github.com/containers/containerlab/blob/master/README.md",
+			input: "https://github.com/containers/containerlab/blob/master/README.md",
+			want:  true,
+		},
+		{
+			name:  "google.com/containers",
+			input: "http://google.com/containers",
+			want:  false,
+		},
+		{
+			name:  "google.com/containers/containerlab/blob/master/README.md",
+			input: "https://google.com/containers/containerlab/blob/master/README.md",
+			want:  false,
+		},
+		{
+			name:  "gitlab.com/containers",
+			input: "http://gitlab.com/containers",
+			want:  false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if output := IsGitHubURL(urlFromStr(tt.input)); output != tt.want {
+				t.Errorf("Test %q failed: want %v, but got %v", tt.name, tt.want, output)
+			}
+		})
+	}
+}
+
+func TestIsGitHubShortURL(t *testing.T) {
+	tests := []struct {
+		name string
+		url  string
+		want bool
+	}{
+		{
+			name: "Valid Short URL",
+			url:  "user/repo",
+			want: true,
+		},
+		{
+			name: "Invalid Short URL - More than one slash",
+			url:  "user/repo/extra",
+			want: false,
+		},
+		{
+			name: "Invalid Short URL - Starts with http",
+			url:  "http://user/repo",
+			want: false,
+		},
+		{
+			name: "normal url in short form",
+			url:  "srlinux.dev/clab-srl",
+			want: false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := IsGitHubShortURL(tt.url); got != tt.want {
+				t.Errorf("IsGitHubShortURL() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
diff --git a/git/gitlab_test.go b/git/gitlab_test.go
new file mode 100644
index 00000000..dcb5cb69
--- /dev/null
+++ b/git/gitlab_test.go
@@ -0,0 +1,194 @@
+package git
+
+import (
+	"errors"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+)
+
+func TestNewGitLabRepoFromURL(t *testing.T) {
+	tests := []struct {
+		name    string
+		url     string
+		repo    *GitLabRepo
+		wantErr bool
+	}{
+		{
+			name: "valid gitlab url without trailing slash and with https schema",
+			url:  "https://gitlab.com/rdodin/clab-test-repo",
+			repo: &GitLabRepo{
+				GitRepoStruct{
+					ProjectOwner:   "rdodin",
+					RepositoryName: "clab-test-repo",
+				},
+			},
+			wantErr: false,
+		},
+		{
+			name: "valid gitlab url with trailing slash and with https schema",
+			url:  "https://gitlab.com/rdodin/clab-test-repo/",
+			repo: &GitLabRepo{
+				GitRepoStruct{
+					URL:            urlFromStr("https://gitlab.com/rdodin/clab-test-repo/"),
+					CloneURL:       urlFromStr("https://gitlab.com/rdodin/clab-test-repo"),
+					ProjectOwner:   "rdodin",
+					RepositoryName: "clab-test-repo",
+				},
+			},
+			wantErr: false,
+		},
+		{
+			name:    "invalid gitlab url with just one path element and https schema and trailing slash",
+			url:     "https://gitlab.com/rdodin/",
+			wantErr: true,
+		},
+		{
+			name: "valid gitlab url with https schema and with .git suffix",
+			url:  "https://gitlab.com/rdodin/clab-test-repo.git",
+			repo: &GitLabRepo{
+				GitRepoStruct{
+					ProjectOwner:   "rdodin",
+					RepositoryName: "clab-test-repo",
+				},
+			},
+			wantErr: false,
+		},
+		{
+			name: "gitlab url with a clab file on the main branch without path args",
+			url:  "https://gitlab.com/rdodin/clab-test-repo/-/blob/main/lab1.clab.yml",
+			repo: &GitLabRepo{
+				GitRepoStruct{
+					ProjectOwner:   "rdodin",
+					RepositoryName: "clab-test-repo",
+					CloneURL:       urlFromStr("https://gitlab.com/rdodin/clab-test-repo"),
+					GitBranch:      "main",
+					FileName:       "lab1.clab.yml",
+				},
+			},
+			wantErr: false,
+		},
+		{
+			name:    "invalid file in a gitlab url with on the main branch",
+			url:     "https://gitlab.com/rdodin/clab-test-repo/-/blob/main/lab1.foo",
+			wantErr: true,
+		},
+		{
+			name: "gitlab url with a clab file on the main branch in a nested dir without path args",
+			url:  "https://gitlab.com/rdodin/clab-test-repo/-/blob/main/dir/lab3.clab.yml",
+			repo: &GitLabRepo{
+				GitRepoStruct{
+					ProjectOwner:   "rdodin",
+					RepositoryName: "clab-test-repo",
+					CloneURL:       urlFromStr("https://gitlab.com/rdodin/clab-test-repo"),
+					GitBranch:      "main",
+					Path:           []string{"dir"},
+					FileName:       "lab3.clab.yml",
+				},
+			},
+			wantErr: false,
+		},
+		{
+			name: "gitlab url pointing to a branch with a path arg",
+			url:  "https://gitlab.com/rdodin/clab-test-repo/-/tree/branch1?ref_type=heads",
+			repo: &GitLabRepo{
+				GitRepoStruct{
+					ProjectOwner:   "rdodin",
+					RepositoryName: "clab-test-repo",
+					CloneURL:       urlFromStr("https://gitlab.com/rdodin/clab-test-repo"),
+					GitBranch:      "branch1",
+				},
+			},
+			wantErr: false,
+		},
+		{
+			name: "gitlab url pointing to a file in a branch",
+			url:  "https://gitlab.com/rdodin/clab-test-repo/-/blob/branch1/lab2.clab.yml?ref_type=heads",
+			repo: &GitLabRepo{
+				GitRepoStruct{
+					ProjectOwner:   "rdodin",
+					RepositoryName: "clab-test-repo",
+					CloneURL:       urlFromStr("https://gitlab.com/rdodin/clab-test-repo"),
+					GitBranch:      "branch1",
+					FileName:       "lab2.clab.yml",
+				},
+			},
+			wantErr: false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			repo, err := NewGitLabRepoFromURL(urlFromStr(tt.url))
+
+			if (err != nil) != tt.wantErr {
+				t.Errorf("ParseGitURL() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+
+			if (err != nil) && errors.Is(err, errInvalidURL) {
+				return
+			}
+
+			// when we do not manipulate url path in the ParseGitURL method
+			// the wantRepo.URL field will be nil, so we need to set it up
+			// to match the original URL
+			if tt.repo.URL == nil {
+				tt.repo.URL = urlFromStr(tt.url)
+			}
+
+			// when we do not manipulate CloneURL in the ParseGitURL method
+			// the wantRepo.CloneURL field will be nil, so we need to set it up
+			// to match the original URL
+			if tt.repo.CloneURL == nil {
+				tt.repo.CloneURL = urlFromStr(tt.url)
+			}
+
+			if diff := cmp.Diff(repo, tt.repo); diff != "" {
+				t.Errorf("TestNewGitLabRepoFromURL() mismatch:\n%s", diff)
+			}
+		})
+	}
+}
+
+func TestIsGitLabURL(t *testing.T) {
+	tests := []struct {
+		name  string
+		input string
+		want  bool
+	}{
+		{
+			name:  "gitlab.com",
+			input: "http://gitlab.com",
+			want:  true,
+		},
+		{
+			name:  "gitlab.com/containers/containerlab/blob/master/README.md",
+			input: "https://gitlab.com/containers/containerlab/blob/master/README.md",
+			want:  true,
+		},
+		{
+			name:  "google.com/containers",
+			input: "http://google.com/containers",
+			want:  false,
+		},
+		{
+			name:  "google.com/containers/containerlab/blob/master/README.md",
+			input: "https://google.com/containers/containerlab/blob/master/README.md",
+			want:  false,
+		},
+		{
+			name:  "github.com/containers",
+			input: "http://github.com/containers",
+			want:  false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if output := IsGitLabURL(urlFromStr(tt.input)); output != tt.want {
+				t.Errorf("Test %q failed: want %v, but got %v", tt.name, tt.want, output)
+			}
+		})
+	}
+}
diff --git a/links/link_test.go b/links/link_test.go
index bf715119..e89e75c2 100644
--- a/links/link_test.go
+++ b/links/link_test.go
@@ -300,3 +300,114 @@ func TestUnmarshalRawLinksYaml(t *testing.T) {
 		})
 	}
 }
+
+func Test_extractHostNodeInterfaceData(t *testing.T) {
+	type args struct {
+		lb             *LinkBriefRaw
+		specialEPIndex int
+	}
+	tests := []struct {
+		name       string
+		args       args
+		wantHost   string
+		wantHostIf string
+		wantNode   string
+		wantNodeIf string
+		wantErr    bool
+	}{
+		{
+			name: "Valid input",
+			args: args{
+				lb: &LinkBriefRaw{
+					Endpoints: []string{
+						"node1:eth1",
+						"node2:eth2",
+					},
+				},
+				specialEPIndex: 0,
+			},
+			wantHost:   "node1",
+			wantHostIf: "eth1",
+			wantNode:   "node2",
+			wantNodeIf: "eth2",
+			wantErr:    false,
+		},
+		{
+			name: "Valid input other specialindex",
+			args: args{
+				lb: &LinkBriefRaw{
+					Endpoints: []string{
+						"node1:eth1",
+						"node2:eth2",
+					},
+				},
+				specialEPIndex: 1,
+			},
+			wantHost:   "node2",
+			wantHostIf: "eth2",
+			wantNode:   "node1",
+			wantNodeIf: "eth1",
+			wantErr:    false,
+		},
+		{
+			name: "Invalid specialNode",
+			args: args{
+				lb: &LinkBriefRaw{
+					Endpoints: []string{
+						"node1:eth1",
+						"node2eth2",
+					},
+				},
+				specialEPIndex: 1,
+			},
+			wantErr: true,
+		},
+		{
+			name: "Invalid Node",
+			args: args{
+				lb: &LinkBriefRaw{
+					Endpoints: []string{
+						"node1eth1",
+						"node2:eth2",
+					},
+				},
+				specialEPIndex: 1,
+			},
+			wantErr: true,
+		},
+		{
+			name: "Invalid Node too many colons",
+			args: args{
+				lb: &LinkBriefRaw{
+					Endpoints: []string{
+						"node1eth1",
+						"node2:et:h2",
+					},
+				},
+				specialEPIndex: 1,
+			},
+			wantErr: true,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			gotHost, gotHostIf, gotNode, gotNodeIf, err := extractHostNodeInterfaceData(tt.args.lb, tt.args.specialEPIndex)
+			if (err != nil) != tt.wantErr {
+				t.Errorf("extractHostNodeInterfaceData() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+			if gotHost != tt.wantHost {
+				t.Errorf("extractHostNodeInterfaceData() gotHost = %v, want %v", gotHost, tt.wantHost)
+			}
+			if gotHostIf != tt.wantHostIf {
+				t.Errorf("extractHostNodeInterfaceData() gotHostIf = %v, want %v", gotHostIf, tt.wantHostIf)
+			}
+			if gotNode != tt.wantNode {
+				t.Errorf("extractHostNodeInterfaceData() gotNode = %v, want %v", gotNode, tt.wantNode)
+			}
+			if gotNodeIf != tt.wantNodeIf {
+				t.Errorf("extractHostNodeInterfaceData() gotNodeIf = %v, want %v", gotNodeIf, tt.wantNodeIf)
+			}
+		})
+	}
+}
diff --git a/tests/01-smoke/12-cloned-lab.robot b/tests/01-smoke/12-cloned-lab.robot
index 793ab879..71bc6030 100644
--- a/tests/01-smoke/12-cloned-lab.robot
+++ b/tests/01-smoke/12-cloned-lab.robot
@@ -7,14 +7,20 @@ Suite Teardown      Cleanup
 
 
 *** Variables ***
-${lab1-url}     https://github.com/hellt/clab-test-repo
-${lab1-url2}    https://github.com/hellt/clab-test-repo/blob/main/lab1.clab.yml
-${lab2-url}     https://github.com/hellt/clab-test-repo/tree/branch1
-${runtime}      docker
+${lab1-url}             https://github.com/hellt/clab-test-repo
+${lab1-shorturl}        hellt/clab-test-repo
+${lab1-url2}            https://github.com/hellt/clab-test-repo/blob/main/lab1.clab.yml
+${lab2-url}             https://github.com/hellt/clab-test-repo/tree/branch1
+
+${lab1-gitlab-url}      https://github.com/hellt/clab-test-repo
+${lab1-gitlab-url2}     https://github.com/hellt/clab-test-repo/blob/main/lab1.clab.yml
+${lab2-gitlab-url}      https://github.com/hellt/clab-test-repo/tree/branch1
+${http-lab-url}         https://gist.githubusercontent.com/hellt/66a5d8fca7bf526b46adae9008a5e04b/raw/034a542c3fbb17333afd20e6e7d21869fee6aeb5/linux.clab.yml
+${runtime}              docker
 
 
 *** Test Cases ***
-Test lab1
+Test lab1 with Github
     ${output} =    Process.Run Process
     ...    sudo -E ${CLAB_BIN} --runtime ${runtime} deploy -t ${lab1-url}
     ...    shell=True
@@ -29,7 +35,22 @@ Test lab1
 
     Cleanup
 
-Test lab2
+Test lab1 with Gitlab
+    ${output} =    Process.Run Process
+    ...    sudo -E ${CLAB_BIN} --runtime ${runtime} deploy -t ${lab1-gitlab-url}
+    ...    shell=True
+
+    Log    ${output.stdout}
+    Log    ${output.stderr}
+
+    Should Be Equal As Integers    ${output.rc}    0
+
+    # check that node3 was filtered and not present in the lab output
+    Should Contain    ${output.stdout}    clab-lab1-node1
+
+    Cleanup
+
+Test lab2 with Github
     ${output} =    Process.Run Process
     ...    sudo -E ${CLAB_BIN} --runtime ${runtime} deploy -t ${lab1-url2}
     ...    shell=True
@@ -44,7 +65,22 @@ Test lab2
 
     Cleanup
 
-Test lab3
+Test lab2 with Gitlab
+    ${output} =    Process.Run Process
+    ...    sudo -E ${CLAB_BIN} --runtime ${runtime} deploy -t ${lab1-gitlab-url2}
+    ...    shell=True
+
+    Log    ${output.stdout}
+    Log    ${output.stderr}
+
+    Should Be Equal As Integers    ${output.rc}    0
+
+    # check that node3 was filtered and not present in the lab output
+    Should Contain    ${output.stdout}    clab-lab1-node1
+
+    Cleanup
+
+Test lab3 with Github
     ${output} =    Process.Run Process
     ...    sudo -E ${CLAB_BIN} --runtime ${runtime} deploy -t ${lab2-url}
     ...    shell=True
@@ -57,6 +93,53 @@ Test lab3
     # check that node3 was filtered and not present in the lab output
     Should Contain    ${output.stdout}    clab-lab2-node1
 
+    Cleanup
+
+Test lab3 with Gitlab
+    ${output} =    Process.Run Process
+    ...    sudo -E ${CLAB_BIN} --runtime ${runtime} deploy -t ${lab2-gitlab-url}
+    ...    shell=True
+
+    Log    ${output.stdout}
+    Log    ${output.stderr}
+
+    Should Be Equal As Integers    ${output.rc}    0
+
+    # check that node3 was filtered and not present in the lab output
+    Should Contain    ${output.stdout}    clab-lab2-node1
+
+    Cleanup
+
+Test lab1 with short github url
+    ${output} =    Process.Run Process
+    ...    sudo -E ${CLAB_BIN} --runtime ${runtime} deploy -t ${lab1-shorturl}
+    ...    shell=True
+
+    Log    ${output.stdout}
+    Log    ${output.stderr}
+
+    Should Be Equal As Integers    ${output.rc}    0
+
+    # check that node3 was filtered and not present in the lab output
+    Should Contain    ${output.stdout}    clab-lab1-node1
+
+    Cleanup
+
+Test lab1 downloaded from https url
+    ${output} =    Process.Run Process
+    ...    sudo -E ${CLAB_BIN} --runtime ${runtime} deploy -t ${http-lab-url}
+    ...    shell=True
+
+    Log    ${output.stdout}
+    Log    ${output.stderr}
+
+    Should Be Equal As Integers    ${output.rc}    0
+
+    # check that node3 was filtered and not present in the lab output
+    Should Contain    ${output.stdout}    clab-alpine-l1
+
+    Cleanup
+
 
 *** Keywords ***
 Cleanup
diff --git a/tests/rf-run.sh b/tests/rf-run.sh
index d7171a13..3e327323 100755
--- a/tests/rf-run.sh
+++ b/tests/rf-run.sh
@@ -15,7 +15,7 @@ fi
 
 echo "Running tests with containerlab binary at $(which ${CLAB_BIN}) path and selected runtime: $1"
 
-COV_DIR=tests/coverage
+COV_DIR=/tmp/clab-tests/coverage
 
 # coverage output directory
 mkdir -p ${COV_DIR}
diff --git a/utils/file_test.go b/utils/file_test.go
index 1dbd4b58..2e96c454 100644
--- a/utils/file_test.go
+++ b/utils/file_test.go
@@ -84,3 +84,57 @@ func TestFileLines(t *testing.T) {
 		})
 	}
 }
+
+func TestIsHttpURL(t *testing.T) {
+	tests := []struct {
+		name            string
+		url             string
+		allowSchemaless bool
+		want            bool
+	}{
+		{
+			name:            "Valid HTTP URL",
+			url:             "http://example.com",
+			allowSchemaless: false,
+			want:            true,
+		},
+		{
+			name:            "Valid HTTPS URL",
+			url:             "https://example.com",
+			allowSchemaless: false,
+			want:            true,
+		},
+		{
+			name:            "Valid URL without scheme",
+			url:             "srlinux.dev/clab-srl",
+			allowSchemaless: true,
+			want:            true,
+		},
+		{
+			name:            "Valid URL without scheme and schemaless not allowed",
+			url:             "srlinux.dev/clab-srl",
+			allowSchemaless: false,
+			want:            false,
+		},
+		{
+			name:            "Invalid URL",
+			url:             "/foo/bar",
+			allowSchemaless: false,
+			want:            false,
+		},
+		{
+			name:            "stdin symbol '-'",
+			url:             "-",
+			allowSchemaless: false,
+			want:            false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := IsHttpURL(tt.url, tt.allowSchemaless); got != tt.want {
+				t.Errorf("IsHttpUri() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
diff --git a/utils/http_test.go b/utils/http_test.go
deleted file mode 100644
index af9ca1b9..00000000
--- a/utils/http_test.go
+++ /dev/null
@@ -1,193 +0,0 @@
-package utils
-
-import (
-	"errors"
-	"testing"
-
-	"github.com/google/go-cmp/cmp"
-)
-
-func TestIsGitHubURL(t *testing.T) {
-	tests := []struct {
-		name  string
-		input string
-		want  bool
-	}{
-		{
-			name:  "github.com",
-			input: "github.com",
-			want:  true,
-		},
-		{
-			name:  "github.com/containers/containerlab/blob/master/README.md",
-			input: "github.com/containers/containerlab/blob/master/README.md",
-			want:  true,
-		},
-		{
-			name:  "google.com/containers",
-			input: "google.com/containers",
-			want:  false,
-		},
-		{
-			name:  "google.com/containers/containerlab/blob/master/README.md",
-			input: "google.com/containers/containerlab/blob/master/README.md",
-			want:  false,
-		},
-		{
-			name:  "gitlab.com/containers",
-			input: "gitlab.com/containers",
-			want:  false,
-		},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			if output := IsGitHubURL(tt.input); output != tt.want {
-				t.Errorf("Test %q failed: want %v, but got %v", tt.name, tt.want, output)
-			}
-		})
-	}
-}
-
-func TestGithubURLParse(t *testing.T) {
-	tests := []struct {
-		name           string
-		ghURL          string
-		expectedResult *GithubURL
-		expectedError  error
-	}{
-		{
-			name:  "bare github url without trailing slash",
-			ghURL: "https://github.com/srl-labs/repo-name",
-			expectedResult: &GithubURL{
-				URLBase:        "https://github.com",
-				ProjectOwner:   "srl-labs",
-				RepositoryName: "repo-name",
-			},
-			expectedError: nil,
-		},
-		{
-			name:  "bare github url with trailing slash",
-			ghURL: "https://github.com/srl-labs/repo-name/",
-			expectedResult: &GithubURL{
-				URLBase:        "https://github.com",
-				ProjectOwner:   "srl-labs",
-				RepositoryName: "repo-name",
-			},
-			expectedError: nil,
-		},
-		{
-			name:  "bare github.dev url with trailing slash",
-			ghURL: "https://github.dev/srl-labs/repo-name/",
-			expectedResult: &GithubURL{
-				URLBase:        "https://github.com",
-				ProjectOwner:   "srl-labs",
-				RepositoryName: "repo-name",
-			},
-			expectedError: nil,
-		},
-		{
-			name:  "bare github url with .git suffix",
-			ghURL: "https://github.com/srl-labs/repo-name.git",
-			expectedResult: &GithubURL{
-				URLBase:        "https://github.com",
-				ProjectOwner:   "srl-labs",
-				RepositoryName: "repo-name",
-			},
-			expectedError: nil,
-		},
-		{
-			name:           "invalid url with just org name",
-			ghURL:          "https://github.com/srl-labs/",
-			expectedResult: &GithubURL{},
-			expectedError:  errInvalidGithubURL,
-		},
-		{
-			name:           "invalid url with no owner and no org",
-			ghURL:          "https://github.com/",
-			expectedResult: &GithubURL{},
-			expectedError:  errInvalidGithubURL,
-		},
-		{
-			name:  "github url with a clab file on the main branch",
-			ghURL: "https://github.com/srl-labs/repo-name/blob/main/file.clab.yml",
-			expectedResult: &GithubURL{
-				URLBase:        "https://github.com",
-				ProjectOwner:   "srl-labs",
-				RepositoryName: "repo-name",
-				GitBranch:      "main",
-				FileName:       "file.clab.yml",
-			},
-			expectedError: nil,
-		},
-		{
-			name:  "github url with a yaml file on the main branch",
-			ghURL: "https://github.com/srl-labs/repo-name/blob/main/file.yaml",
-			expectedResult: &GithubURL{
-				URLBase:        "https://github.com",
-				ProjectOwner:   "srl-labs",
-				RepositoryName: "repo-name",
-				GitBranch:      "main",
-				FileName:       "file.yaml",
-			},
-			expectedError: nil,
-		},
-		{
-			name:           "utl with invalid file on the main branch",
-			ghURL:          "https://github.com/srl-labs/repo-name/blob/main/file.foo",
-			expectedResult: &GithubURL{},
-			expectedError:  errInvalidGithubURL,
-		},
-		{
-			name:  "github url with a specified git ref and no file",
-			ghURL: "https://github.com/srl-labs/repo-name/tree/some-branch",
-			expectedResult: &GithubURL{
-				URLBase:        "https://github.com",
-				ProjectOwner:   "srl-labs",
-				RepositoryName: "repo-name",
-				GitBranch:      "some-branch",
-			},
-			expectedError: nil,
-		},
-		{
-			name:  "github url with a specified git ref and no file and trailing slash",
-			ghURL: "https://github.com/srl-labs/repo-name/tree/some-branch/",
-			expectedResult: &GithubURL{
-				URLBase:        "https://github.com",
-				ProjectOwner:   "srl-labs",
-				RepositoryName: "repo-name",
-				GitBranch:      "some-branch",
-			},
-			expectedError: nil,
-		},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			u := NewGithubURL()
-			err := u.Parse(tt.ghURL)
-
-			if err != nil && tt.expectedError == nil {
-				t.Fatalf("unexpected error: %v", err)
-			}
-
-			if err == nil && tt.expectedError != nil {
-				t.Errorf("expected error: %v, but got nil", tt.expectedError)
-			}
-
-			if err != nil && tt.expectedError != nil {
-				if !errors.Is(err, tt.expectedError) {
-					t.Fatalf("expected error: %v, but got %v", err, tt.expectedError)
-				}
-				// exit the test case as we don't want to compare url structs
-				// since when error is available and matches the expected error
-				// we don't care about the state the struct is in
-				return
-			}
-
-			if diff := cmp.Diff(u, tt.expectedResult); diff != "" {
-				t.Errorf("got result: = %v, expected %v, diff:\n%s", u, tt.expectedResult, diff)
-			}
-		})
-	}
-}