diff --git a/.github/workflows/actions/install-vdev/action.yml b/.github/workflows/actions/install-vdev/action.yml
new file mode 100644
index 0000000000000..2d57c70bf5858
--- /dev/null
+++ b/.github/workflows/actions/install-vdev/action.yml
@@ -0,0 +1,51 @@
+name: Install vdev
+
+inputs:
+  token:
+    required: true
+
+runs:
+  using: composite
+
+  steps:
+  # default 2.x fails
+  - if: runner.os == 'Windows'
+    uses: ruby/setup-ruby@v1
+    with:
+      ruby-version: '3.1'
+
+  - name: Wait for build
+    uses: lewagon/wait-on-check-action@v1.2.0
+    with:
+      repo-token: ${{ inputs.token }}
+      check-name: Build executable for ${{ runner.os }}
+      ref: ${{ github.sha }}
+      wait-interval: 20
+
+  - name: Try downloading current artifact
+    uses: dawidd6/action-download-artifact@v2
+    with:
+      github_token: ${{ inputs.token }}
+      workflow: build-vdev.yml
+      branch: ${{ github.ref_name }}
+      name: vdev-${{ runner.os }}
+      path: vdev/bin
+
+  - if: github.event_name == 'pull_request' && failure()
+    name: Download latest artifact
+    uses: dawidd6/action-download-artifact@v2
+    with:
+      github_token: ${{ inputs.token }}
+      workflow: build-vdev.yml
+      branch: master
+      name: vdev-${{ runner.os }}
+      path: vdev/bin
+
+  - name: Add binary to PATH
+    run: mv vdev/bin/* "$HOME/.cargo/bin"
+    shell: bash
+
+  - if: runner.os != 'Windows'
+    name: Make binary executable
+    run: chmod +x "$HOME/.cargo/bin/vdev"
+    shell: bash
diff --git a/.github/workflows/build-vdev.yml b/.github/workflows/build-vdev.yml
new file mode 100644
index 0000000000000..9cce2ec10ea75
--- /dev/null
+++ b/.github/workflows/build-vdev.yml
@@ -0,0 +1,43 @@
+name: Build vdev
+
+on:
+  push:
+    branches:
+      - master
+  pull_request:
+    branches:
+      - master
+    paths:
+      - "vdev/**/*"
+
+concurrency:
+  group: ${{ github.workflow }}-${{ github.event.number || github.sha }}
+  cancel-in-progress: true
+
+jobs:
+  exe:
+    # Name must match runner.os https://docs.github.com/en/actions/learn-github-actions/contexts#runner-context
+    name: Build executable for ${{ startsWith(matrix.os, 'macos-') && 'macOS' || startsWith(matrix.os, 'windows-') && 'Windows' || 'Linux' }}
+    runs-on: ${{ matrix.os }}
+    strategy:
+      fail-fast: false
+      matrix:
+        os: [ubuntu-latest, windows-latest, macos-latest]
+
+    steps:
+    - uses: actions/checkout@v3
+
+    - name: Build release binary
+      run: |
+        cd vdev
+        cargo build --release
+
+    - if: runner.os != 'Windows'
+      name: Strip binary
+      run: strip vdev/target/release/vdev
+
+    - uses: actions/upload-artifact@v3
+      with:
+        name: vdev-${{ runner.os }}
+        path: vdev/target/release/${{ runner.os == 'Windows' && 'vdev.exe' || 'vdev' }}
+        if-no-files-found: error
diff --git a/vdev/Cargo.lock b/vdev/Cargo.lock
new file mode 100644
index 0000000000000..759da0aa7bb0f
--- /dev/null
+++ b/vdev/Cargo.lock
@@ -0,0 +1,807 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 3
+
+[[package]]
+name = "ahash"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fcb51a0695d8f838b1ee009b3fbf66bda078cd64590202a864a8f3e8c4315c47"
+dependencies = [
+ "getrandom",
+ "once_cell",
+ "version_check",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.66"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "216261ddc8289130e551ddcd5ce8a064710c0d064a4d2895c67151c92b5443f6"
+
+[[package]]
+name = "async-trait"
+version = "0.1.58"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e805d94e6b5001b651426cf4cd446b1ab5f319d27bab5c644f61de0a804360c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "async_once"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ce4f10ea3abcd6617873bae9f91d1c5332b4a778bd9ce34d0cd517474c1de82"
+
+[[package]]
+name = "atty"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8"
+dependencies = [
+ "hermit-abi",
+ "libc",
+ "winapi",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "cached"
+version = "0.40.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b4147cd94d5fbdc2ab71b11d50a2f45493625576b3bb70257f59eedea69f3d"
+dependencies = [
+ "async-trait",
+ "async_once",
+ "cached_proc_macro",
+ "cached_proc_macro_types",
+ "futures",
+ "hashbrown",
+ "instant",
+ "lazy_static",
+ "once_cell",
+ "thiserror",
+ "tokio",
+]
+
+[[package]]
+name = "cached_proc_macro"
+version = "0.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "751f7f4e7a091545e7f6c65bacc404eaee7e87bfb1f9ece234a1caa173dc16f2"
+dependencies = [
+ "cached_proc_macro_types",
+ "darling",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "cached_proc_macro_types"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a4f925191b4367301851c6d99b09890311d74b0d43f274c0b34c86d308a3663"
+
+[[package]]
+name = "cfg-if"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
+
+[[package]]
+name = "clap"
+version = "4.0.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2148adefda54e14492fb9bddcc600b4344c5d1a3123bd666dcb939c6f0e0e57e"
+dependencies = [
+ "atty",
+ "bitflags",
+ "clap_derive",
+ "clap_lex",
+ "once_cell",
+ "strsim",
+ "termcolor",
+]
+
+[[package]]
+name = "clap-verbosity-flag"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23e2b6c3dcdb73299f48ae05b294da14e2f560b3ed2c09e742269eb1b22af231"
+dependencies = [
+ "clap",
+ "log",
+]
+
+[[package]]
+name = "clap_complete"
+version = "4.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96b0fba905b035a30d25c1b585bf1171690712fbb0ad3ac47214963aa4acc36c"
+dependencies = [
+ "clap",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0177313f9f02afc995627906bbd8967e2be069f5261954222dac78290c2b9014"
+dependencies = [
+ "heck",
+ "proc-macro-error",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "clap_lex"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d4198f73e42b4936b35b5bb248d81d2b595ecb170da0bac7655c54eedfa8da8"
+dependencies = [
+ "os_str_bytes",
+]
+
+[[package]]
+name = "confy"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e37668cb35145dcfaa1931a5f37fde375eeae8068b4c0d2f289da28a270b2d2c"
+dependencies = [
+ "directories",
+ "serde",
+ "thiserror",
+ "toml",
+]
+
+[[package]]
+name = "console"
+version = "0.15.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c050367d967ced717c04b65d8c619d863ef9292ce0c5760028655a2fb298718c"
+dependencies = [
+ "encode_unicode",
+ "lazy_static",
+ "libc",
+ "terminal_size",
+ "unicode-width",
+ "winapi",
+]
+
+[[package]]
+name = "darling"
+version = "0.13.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a01d95850c592940db9b8194bc39f4bc0e89dee5c4265e4b1807c34a9aba453c"
+dependencies = [
+ "darling_core",
+ "darling_macro",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.13.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "859d65a907b6852c9361e3185c862aae7fafd2887876799fa55f5f99dc40d610"
+dependencies = [
+ "fnv",
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "strsim",
+ "syn",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.13.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c972679f83bdf9c42bd905396b6c3588a843a17f0f16dfcfa3e2c5d57441835"
+dependencies = [
+ "darling_core",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "directories"
+version = "4.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f51c5d4ddabd36886dd3e1438cb358cdcb0d7c499cb99cb4ac2e38e18b5cb210"
+dependencies = [
+ "dirs-sys",
+]
+
+[[package]]
+name = "dirs-sys"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6"
+dependencies = [
+ "libc",
+ "redox_users",
+ "winapi",
+]
+
+[[package]]
+name = "dunce"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bd4b30a6560bbd9b4620f4de34c3f14f60848e58a9b7216801afcb4c7b31c3c"
+
+[[package]]
+name = "either"
+version = "1.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90e5c1c8368803113bf0c9584fc495a58b86dc8a29edbf8fe877d21d9507e797"
+
+[[package]]
+name = "encode_unicode"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f"
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "futures"
+version = "0.3.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38390104763dc37a5145a53c29c63c1290b5d316d6086ec32c293f6736051bb0"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ba265a92256105f45b719605a571ffe2d1f0fea3807304b522c1d778f79eed"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04909a7a7e4633ae6c4a9ab280aeb86da1236243a77b694a49eacd659a4bd3ac"
+
+[[package]]
+name = "futures-io"
+version = "0.3.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00f5fb52a06bdcadeb54e8d3671f8888a39697dcb0b81b23b55174030427f4eb"
+
+[[package]]
+name = "futures-sink"
+version = "0.3.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39c15cf1a4aa79df40f1bb462fb39676d0ad9e366c2a33b590d7c66f4f81fcf9"
+
+[[package]]
+name = "futures-task"
+version = "0.3.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ffb393ac5d9a6eaa9d3fdf37ae2776656b706e200c8e16b1bdb227f5198e6ea"
+
+[[package]]
+name = "futures-util"
+version = "0.3.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "197676987abd2f9cadff84926f410af1c183608d36641465df73ae8211dc65d6"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+ "futures-task",
+ "pin-project-lite",
+ "pin-utils",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c05aeb6a22b8f62540c194aac980f2115af067bfe15a0734d7277a768d396b31"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
+dependencies = [
+ "ahash",
+]
+
+[[package]]
+name = "hashlink"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69fe1fcf8b4278d860ad0548329f892a3631fb63f82574df68275f34cdbe0ffa"
+dependencies = [
+ "hashbrown",
+ "serde",
+]
+
+[[package]]
+name = "heck"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2540771e65fc8cb83cd6e8a237f70c319bd5c29f78ed1084ba5d50eeac86f7f9"
+
+[[package]]
+name = "hermit-abi"
+version = "0.1.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "ident_case"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
+
+[[package]]
+name = "indexmap"
+version = "1.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1885e79c1fc4b10f0e172c475f458b7f7b93061064d98c3293e98c5ba0c8b399"
+dependencies = [
+ "autocfg",
+ "hashbrown",
+]
+
+[[package]]
+name = "indicatif"
+version = "0.17.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4295cbb7573c16d310e99e713cf9e75101eb190ab31fccd35f2d2691b4352b19"
+dependencies = [
+ "console",
+ "number_prefix",
+ "portable-atomic",
+ "unicode-segmentation",
+ "unicode-width",
+]
+
+[[package]]
+name = "instant"
+version = "0.1.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "is_ci"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "616cde7c720bb2bb5824a224687d8f77bfd38922027f01d825cd7453be5099fb"
+
+[[package]]
+name = "itertools"
+version = "0.10.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4217ad341ebadf8d8e724e264f13e593e0648f5b3e94b3896a5df283be015ecc"
+
+[[package]]
+name = "lazy_static"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
+
+[[package]]
+name = "libc"
+version = "0.2.137"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc7fcc620a3bff7cdd7a365be3376c97191aeaccc2a603e600951e452615bf89"
+
+[[package]]
+name = "log"
+version = "0.4.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "number_prefix"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
+
+[[package]]
+name = "once_cell"
+version = "1.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "86f0b0d4bf799edbc74508c1e8bf170ff5f41238e5f8225603ca7caaae2b7860"
+
+[[package]]
+name = "os_info"
+version = "3.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4750134fb6a5d49afc80777394ad5d95b04bc12068c6abb92fae8f43817270f"
+dependencies = [
+ "log",
+ "winapi",
+]
+
+[[package]]
+name = "os_str_bytes"
+version = "6.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b5bf27447411e9ee3ff51186bf7a08e16c341efdde93f4d823e8844429bed7e"
+
+[[package]]
+name = "owo-colors"
+version = "3.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f"
+dependencies = [
+ "supports-color",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e0a7ae3ac2f1173085d398531c705756c94a4c56843785df85a60c1a0afac116"
+
+[[package]]
+name = "pin-utils"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
+
+[[package]]
+name = "portable-atomic"
+version = "0.3.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "15eb2c6e362923af47e13c23ca5afb859e83d54452c55b0b9ac763b8f7c1ac16"
+
+[[package]]
+name = "proc-macro-error"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
+dependencies = [
+ "proc-macro-error-attr",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "version_check",
+]
+
+[[package]]
+name = "proc-macro-error-attr"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "version_check",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ea3d908b0e36316caf9e9e2c4625cdde190a7e6f440d794667ed17a1855e725"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbe448f377a7d6961e30f5955f9b8d106c3f5e449d493ee1b125c1d43c2b5179"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "redox_users"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b033d837a7cf162d7993aded9304e30a83213c648b6e389db233191f891e5c2b"
+dependencies = [
+ "getrandom",
+ "redox_syscall",
+ "thiserror",
+]
+
+[[package]]
+name = "ryu"
+version = "1.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4501abdff3ae82a1c1b477a17252eb69cee9e66eb915c1abaa4f44d873df9f09"
+
+[[package]]
+name = "serde"
+version = "1.0.147"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d193d69bae983fc11a79df82342761dfbf28a99fc8d203dca4c3c1b590948965"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.147"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4f1d362ca8fc9c3e3a7484440752472d68a6caa98f1ab81d99b5dfe517cec852"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.88"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e8b3801309262e8184d9687fb697586833e939767aea0dda89f5a8e650e8bd7"
+dependencies = [
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "serde_yaml"
+version = "0.9.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d232d893b10de3eb7258ff01974d6ee20663d8e833263c99409d4b13a0209da"
+dependencies = [
+ "indexmap",
+ "itoa",
+ "ryu",
+ "serde",
+ "unsafe-libyaml",
+]
+
+[[package]]
+name = "strsim"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
+
+[[package]]
+name = "supports-color"
+version = "1.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ba6faf2ca7ee42fdd458f4347ae0a9bd6bcc445ad7cb57ad82b383f18870d6f"
+dependencies = [
+ "atty",
+ "is_ci",
+]
+
+[[package]]
+name = "syn"
+version = "1.0.103"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a864042229133ada95abf3b54fdc62ef5ccabe9515b64717bcb9a1919e59445d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "termcolor"
+version = "1.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bab24d30b911b2376f3a13cc2cd443142f0c81dda04c118693e35b3835757755"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "terminal_size"
+version = "0.1.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "633c1a546cee861a1a6d0dc69ebeca693bf4296661ba7852b9d21d159e0506df"
+dependencies = [
+ "libc",
+ "winapi",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10deb33631e3c9018b9baf9dcbbc4f737320d2b576bac10f6aefa048fa407e3e"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "982d17546b47146b28f7c22e3d08465f6b8903d0ea13c1660d9d84a6e7adcdbb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tokio"
+version = "1.22.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d76ce4a75fb488c605c54bf610f221cea8b0dafb53333c1a67e8ee199dcd2ae3"
+dependencies = [
+ "autocfg",
+ "pin-project-lite",
+ "tokio-macros",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "1.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9724f9a975fb987ef7a3cd9be0350edcbe130698af5b8f7a631e23d42d052484"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "toml"
+version = "0.5.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8d82e1a7758622a465f8cee077614c73484dac5b836c02ff6a40d5d1010324d7"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ceab39d59e4c9499d4e5a8ee0e2735b891bb7308ac83dfb4e80cad195c9f6f3"
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fdbf052a0783de01e944a6ce7a8cb939e295b1e7be835a1112c3b9a7f047a5a"
+
+[[package]]
+name = "unicode-width"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b"
+
+[[package]]
+name = "unsafe-libyaml"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1e5fa573d8ac5f1a856f8d7be41d390ee973daf97c806b2c1a465e4e1406e68"
+
+[[package]]
+name = "vdev"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "atty",
+ "cached",
+ "clap",
+ "clap-verbosity-flag",
+ "clap_complete",
+ "confy",
+ "directories",
+ "dunce",
+ "hashlink",
+ "indicatif",
+ "itertools",
+ "log",
+ "once_cell",
+ "os_info",
+ "owo-colors",
+ "serde",
+ "serde_json",
+ "serde_yaml",
+ "toml",
+]
+
+[[package]]
+name = "version_check"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"
+
+[[package]]
+name = "wasi"
+version = "0.11.0+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-util"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178"
+dependencies = [
+ "winapi",
+]
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
diff --git a/vdev/Cargo.toml b/vdev/Cargo.toml
new file mode 100644
index 0000000000000..faa795f075c2d
--- /dev/null
+++ b/vdev/Cargo.toml
@@ -0,0 +1,35 @@
+[package]
+name = "vdev"
+version = "0.1.0"
+edition = "2021"
+authors = ["Vector Contributors <vector@datadoghq.com>"]
+license = "MPL-2.0"
+readme = "README.md"
+publish = false
+
+[dependencies]
+anyhow = "1.0.66"
+atty = "0.2.14"
+cached = "0.40.0"
+clap = { version = "4.0.18", features = ["derive"] }
+clap-verbosity-flag = "2.0.0"
+clap_complete = "4.0.5"
+confy = "0.5.1"
+directories = "4.0.1"
+# remove this when stabilized https://doc.rust-lang.org/stable/std/path/fn.absolute.html
+dunce = "1.0.3"
+hashlink = { version = "0.8.1", features = ["serde_impl"] }
+indicatif = { version = "0.17.1", features = ["improved_unicode"] }
+itertools = "0.10.5"
+log = "0.4.17"
+once_cell = "1.16.0"
+os_info = { version = "3.5.1", default-features = false }
+# watch https://github.com/epage/anstyle for official interop with Clap
+owo-colors = { version = "3.5.0", features = ["supports-colors"] }
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0.87"
+serde_yaml = "0.9.14"
+toml = "0.5.9"
+
+# https://github.com/rust-lang/cargo/issues/6745#issuecomment-472667516
+[workspace]
diff --git a/vdev/README.md b/vdev/README.md
new file mode 100644
index 0000000000000..14e599e100cb7
--- /dev/null
+++ b/vdev/README.md
@@ -0,0 +1,67 @@
+# vdev
+
+-----
+
+This is the command line tooling for Vector development.
+
+Table of Contents:
+
+- [Installation](#installation)
+- [Configuration](#configuration)
+  - [Repository](#repository)
+  - [Starship](#starship)
+- [CLI](#cli)
+
+## Installation
+
+Run the following command from the root of the Vector repository:
+
+```text
+cargo install -f --path vdev
+```
+
+## Configuration
+
+### Repository
+
+Setting the path to the repository explicitly allows the application to be used at any time no matter the current working directory.
+
+```text
+vdev config set repo .
+```
+
+To test, enter your home directory and then run:
+
+```text
+vdev exec ls
+```
+
+### Starship
+
+A custom command for the [Starship](https://starship.rs) prompt is available.
+
+```toml
+format = """
+...
+${custom.vdev}\
+...
+$line_break\
+...
+$character"""
+
+# <clipped>
+
+[custom.vdev]
+command = "vdev meta starship"
+when = true
+# Windows
+# shell = ["cmd", "/C"]
+# Other
+# shell = ["sh", "--norc"]
+```
+
+## CLI
+
+The CLI uses [Clap](https://github.com/clap-rs/clap) with the `derive` construction mechanism and is stored in the [commands](src/commands) directory.
+
+Every command group/namespace has its own directory with a `cli` module, including the root `vdev` command group. All commands have an `exec` method that provides the actual implementation, which in the case of command groups will be calling sub-commands.
diff --git a/vdev/src/app.rs b/vdev/src/app.rs
new file mode 100644
index 0000000000000..d380f2dc88ce2
--- /dev/null
+++ b/vdev/src/app.rs
@@ -0,0 +1,107 @@
+use std::{borrow::Cow, process::Command, time::Duration};
+
+use anyhow::{bail, Result};
+use indicatif::{ProgressBar, ProgressStyle};
+use log::LevelFilter;
+use once_cell::sync::OnceCell;
+
+use crate::config::Config;
+
+static VERBOSITY: OnceCell<LevelFilter> = OnceCell::new();
+static CONFIG: OnceCell<Config> = OnceCell::new();
+static PATH: OnceCell<String> = OnceCell::new();
+
+pub fn verbosity() -> &'static LevelFilter {
+    VERBOSITY.get().expect("verbosity is not initialized")
+}
+
+pub fn config() -> &'static Config {
+    CONFIG.get().expect("config is not initialized")
+}
+
+pub fn path() -> &'static String {
+    PATH.get().expect("path is not initialized")
+}
+
+/// Overlay some extra helper functions onto `std::process::Command`
+pub trait CommandExt {
+    fn with_path(program: &str) -> Self;
+    fn capture_output(&mut self) -> Result<String>;
+    fn run(&mut self) -> Result<()>;
+    fn wait(&mut self, message: impl Into<Cow<'static, str>>) -> Result<()>;
+}
+
+impl CommandExt for Command {
+    fn with_path(program: &str) -> Self {
+        let mut command = Command::new(program);
+        command.current_dir(path());
+        command
+    }
+
+    fn capture_output(&mut self) -> Result<String> {
+        Ok(String::from_utf8(self.output()?.stdout)?)
+    }
+
+    fn run(&mut self) -> Result<()> {
+        let status = self.status()?;
+        if status.success() {
+            Ok(())
+        } else {
+            bail!(
+                "command: {} {}\nfailed with exit code: {}",
+                self.get_program().to_str().expect("Invalid program name"),
+                self.get_args()
+                    .map(|arg| arg.to_str().expect("Invalid command argument"))
+                    .collect::<Vec<_>>()
+                    .join(" "),
+                status.code().unwrap()
+            )
+        }
+    }
+
+    fn wait(&mut self, message: impl Into<Cow<'static, str>>) -> Result<()> {
+        let progress_bar = get_progress_bar()?;
+        progress_bar.set_message(message);
+
+        let result = self.output();
+        progress_bar.finish_and_clear();
+        let output = match result {
+            Ok(output) => output,
+            Err(_) => bail!("could not run command"),
+        };
+
+        if output.status.success() {
+            Ok(())
+        } else {
+            bail!(
+                "{}\nfailed with exit code: {}",
+                String::from_utf8(output.stdout)?,
+                output.status.code().unwrap()
+            )
+        }
+    }
+}
+
+fn get_progress_bar() -> Result<ProgressBar> {
+    let progress_bar = ProgressBar::new_spinner();
+    progress_bar.enable_steady_tick(Duration::from_millis(125));
+    progress_bar.set_style(
+        ProgressStyle::with_template("{spinner} {msg:.magenta.bold}")?
+            // https://github.com/sindresorhus/cli-spinners/blob/master/spinners.json
+            .tick_strings(&["∙∙∙", "●∙∙", "∙●∙", "∙∙●", "∙∙∙"]),
+    );
+
+    Ok(progress_bar)
+}
+
+pub fn set_global_verbosity(verbosity: LevelFilter) {
+    VERBOSITY.set(verbosity).expect("could not set verbosity");
+}
+
+pub fn set_global_config(config: Config) {
+    CONFIG.set(config).expect("could not set config");
+}
+
+pub fn set_global_path(path: String) {
+    PATH.set(path).expect("could not set path");
+}
diff --git a/vdev/src/commands/build.rs b/vdev/src/commands/build.rs
new file mode 100644
index 0000000000000..c30f04d5f7ebe
--- /dev/null
+++ b/vdev/src/commands/build.rs
@@ -0,0 +1,54 @@
+use std::process::Command;
+
+use anyhow::Result;
+use clap::Args;
+
+use crate::app::CommandExt as _;
+use crate::platform;
+
+/// Build Vector
+#[derive(Args, Debug)]
+#[command()]
+pub struct Cli {
+    /// The build target e.g. x86_64-unknown-linux-musl
+    target: Option<String>,
+
+    /// Build with optimizations
+    #[arg(short, long)]
+    release: bool,
+
+    /// The feature to activate (multiple allowed)
+    #[arg(short = 'F', long)]
+    feature: Vec<String>,
+}
+
+impl Cli {
+    pub fn exec(self) -> Result<()> {
+        let mut command = Command::with_path("cargo");
+        command.args(["build", "--no-default-features"]);
+
+        if self.release {
+            command.arg("--release");
+        }
+
+        command.arg("--features");
+        if !self.feature.is_empty() {
+            command.args([self.feature.join(",")]);
+        } else if cfg!(windows) {
+            command.arg("default-msvc");
+        } else {
+            command.arg("default");
+        };
+
+        if let Some(target) = self.target.as_deref() {
+            command.args(["--target", target]);
+        } else {
+            command.args(["--target", &platform::default_target()]);
+        };
+
+        waiting!("Building Vector");
+        command.run()?;
+
+        Ok(())
+    }
+}
diff --git a/vdev/src/commands/complete.rs b/vdev/src/commands/complete.rs
new file mode 100644
index 0000000000000..e02834c4e6e8a
--- /dev/null
+++ b/vdev/src/commands/complete.rs
@@ -0,0 +1,24 @@
+use anyhow::Result;
+use clap::{Args, CommandFactory};
+use clap_complete::{generate, Shell};
+use std::io;
+
+use super::Cli as RootCli;
+
+/// Display the completion file for a given shell
+#[derive(Args, Debug)]
+#[command()]
+pub struct Cli {
+    #[arg(value_enum)]
+    shell: Shell,
+}
+
+impl Cli {
+    pub fn exec(self) -> Result<()> {
+        let mut cmd = RootCli::command();
+        let bin_name = cmd.get_name().to_string();
+        generate(self.shell, &mut cmd, bin_name, &mut io::stdout());
+
+        Ok(())
+    }
+}
diff --git a/vdev/src/commands/config/find.rs b/vdev/src/commands/config/find.rs
new file mode 100644
index 0000000000000..aac37ec4f9c56
--- /dev/null
+++ b/vdev/src/commands/config/find.rs
@@ -0,0 +1,17 @@
+use anyhow::Result;
+use clap::Args;
+
+use crate::config;
+
+/// Locate the config file
+#[derive(Args, Debug)]
+#[command()]
+pub struct Cli {}
+
+impl Cli {
+    pub fn exec(self) -> Result<()> {
+        display!("{}", config::path()?.display());
+
+        Ok(())
+    }
+}
diff --git a/vdev/src/commands/config/mod.rs b/vdev/src/commands/config/mod.rs
new file mode 100644
index 0000000000000..285d3a9cb0632
--- /dev/null
+++ b/vdev/src/commands/config/mod.rs
@@ -0,0 +1,28 @@
+use anyhow::Result;
+use clap::{Args, Subcommand};
+
+mod find;
+mod set;
+
+/// Manage the vdev config file
+#[derive(Args, Debug)]
+#[command()]
+pub struct Cli {
+    #[command(subcommand)]
+    command: Commands,
+}
+
+#[derive(Subcommand, Debug)]
+enum Commands {
+    Find(find::Cli),
+    Set(set::Cli),
+}
+
+impl Cli {
+    pub fn exec(self) -> Result<()> {
+        match self.command {
+            Commands::Find(cli) => cli.exec(),
+            Commands::Set(cli) => cli.exec(),
+        }
+    }
+}
diff --git a/vdev/src/commands/config/set/mod.rs b/vdev/src/commands/config/set/mod.rs
new file mode 100644
index 0000000000000..828adecdeffa6
--- /dev/null
+++ b/vdev/src/commands/config/set/mod.rs
@@ -0,0 +1,25 @@
+use anyhow::Result;
+use clap::{Args, Subcommand};
+
+mod repo;
+
+/// Modify the config file
+#[derive(Args, Debug)]
+#[command()]
+pub struct Cli {
+    #[command(subcommand)]
+    command: Commands,
+}
+
+#[derive(Subcommand, Debug)]
+enum Commands {
+    Repo(repo::Cli),
+}
+
+impl Cli {
+    pub fn exec(self) -> Result<()> {
+        match self.command {
+            Commands::Repo(cli) => cli.exec(),
+        }
+    }
+}
diff --git a/vdev/src/commands/config/set/org.rs b/vdev/src/commands/config/set/org.rs
new file mode 100644
index 0000000000000..fc6e37fb02c4f
--- /dev/null
+++ b/vdev/src/commands/config/set/org.rs
@@ -0,0 +1,21 @@
+use anyhow::Result;
+use clap::Args;
+
+use crate::{app, config};
+
+/// Set the target Datadog org
+#[derive(Args, Debug)]
+#[command()]
+pub struct Cli {
+    name: String,
+}
+
+impl Cli {
+    pub fn exec(self) -> Result<()> {
+        let mut config = app::config().clone();
+        config.org = self.name.to_string();
+        config::save(config)?;
+
+        Ok(())
+    }
+}
diff --git a/vdev/src/commands/config/set/repo.rs b/vdev/src/commands/config/set/repo.rs
new file mode 100644
index 0000000000000..521ddc84edac5
--- /dev/null
+++ b/vdev/src/commands/config/set/repo.rs
@@ -0,0 +1,23 @@
+use anyhow::Result;
+use clap::Args;
+
+use crate::{app, config, platform};
+
+/// Set the path to the Vector repository
+#[derive(Args, Debug)]
+#[command()]
+pub struct Cli {
+    path: String,
+}
+
+impl Cli {
+    pub fn exec(self) -> Result<()> {
+        let path = platform::canonicalize_path(self.path);
+
+        let mut config = app::config().clone();
+        config.repo = path;
+        config::save(config)?;
+
+        Ok(())
+    }
+}
diff --git a/vdev/src/commands/exec.rs b/vdev/src/commands/exec.rs
new file mode 100644
index 0000000000000..ea6935e4b35d9
--- /dev/null
+++ b/vdev/src/commands/exec.rs
@@ -0,0 +1,26 @@
+use std::process::Command;
+
+use anyhow::Result;
+use clap::Args;
+
+use crate::app::CommandExt as _;
+
+/// Execute a command within the repository
+#[derive(Args, Debug)]
+#[command()]
+pub struct Cli {
+    #[arg(required = true, trailing_var_arg = true, allow_hyphen_values = true)]
+    args: Vec<String>,
+}
+
+impl Cli {
+    pub fn exec(self) -> Result<()> {
+        let mut command = Command::with_path(&self.args[0]);
+        if self.args.len() > 1 {
+            command.args(&self.args[1..]);
+        }
+
+        let status = command.status()?;
+        std::process::exit(status.code().unwrap_or(1));
+    }
+}
diff --git a/vdev/src/commands/integration/mod.rs b/vdev/src/commands/integration/mod.rs
new file mode 100644
index 0000000000000..4d6f5bddaff21
--- /dev/null
+++ b/vdev/src/commands/integration/mod.rs
@@ -0,0 +1,34 @@
+use anyhow::Result;
+use clap::{Args, Subcommand};
+
+mod show;
+mod start;
+mod stop;
+mod test;
+
+/// Manage integrations
+#[derive(Args, Debug)]
+#[command()]
+pub struct Cli {
+    #[command(subcommand)]
+    command: Commands,
+}
+
+#[derive(Subcommand, Debug)]
+enum Commands {
+    Show(show::Cli),
+    Start(start::Cli),
+    Stop(stop::Cli),
+    Test(test::Cli),
+}
+
+impl Cli {
+    pub fn exec(self) -> Result<()> {
+        match self.command {
+            Commands::Show(cli) => cli.exec(),
+            Commands::Start(cli) => cli.exec(),
+            Commands::Stop(cli) => cli.exec(),
+            Commands::Test(cli) => cli.exec(),
+        }
+    }
+}
diff --git a/vdev/src/commands/integration/show.rs b/vdev/src/commands/integration/show.rs
new file mode 100644
index 0000000000000..4735ba64b6092
--- /dev/null
+++ b/vdev/src/commands/integration/show.rs
@@ -0,0 +1,56 @@
+use anyhow::{Context, Result};
+use clap::Args;
+use std::path::PathBuf;
+
+use crate::app;
+use crate::testing::{config::IntegrationTestConfig, state};
+
+/// Show information about integrations
+#[derive(Args, Debug)]
+#[command()]
+pub struct Cli {
+    /// The desired integration
+    integration: Option<String>,
+}
+
+impl Cli {
+    pub fn exec(self) -> Result<()> {
+        match self.integration {
+            None => {
+                let mut entries = vec![];
+                let root_dir: PathBuf = [app::path(), "scripts", "integration"].iter().collect();
+                for entry in root_dir
+                    .read_dir()
+                    .with_context(|| format!("failed to read directory {}", root_dir.display()))?
+                {
+                    let entry = entry?;
+                    if entry.path().is_dir() {
+                        entries.push(entry.file_name().into_string().unwrap());
+                    }
+                }
+                entries.sort();
+
+                for integration in &entries {
+                    display!("{integration}");
+                }
+            }
+            Some(integration) => {
+                let (_test_dir, config) = IntegrationTestConfig::load(&integration)?;
+                let envs_dir = state::envs_dir(&integration);
+                let active_envs = state::active_envs(&envs_dir)?;
+
+                display!("Test args: {}", config.args.join(" "));
+
+                display!("Environments:");
+                for environment in config.environments().keys() {
+                    if active_envs.contains(environment) {
+                        display!("  {} (active)", environment);
+                    } else {
+                        display!("  {}", environment);
+                    }
+                }
+            }
+        }
+        Ok(())
+    }
+}
diff --git a/vdev/src/commands/integration/start.rs b/vdev/src/commands/integration/start.rs
new file mode 100644
index 0000000000000..029f74fc62aba
--- /dev/null
+++ b/vdev/src/commands/integration/start.rs
@@ -0,0 +1,54 @@
+use std::process::Command;
+
+use anyhow::{bail, Result};
+use clap::Args;
+
+use crate::app::CommandExt as _;
+use crate::testing::{config::IntegrationTestConfig, runner::*, state};
+
+/// Start an environment
+#[derive(Args, Debug)]
+#[command()]
+pub struct Cli {
+    /// The desired integration
+    integration: String,
+
+    /// The desired environment
+    environment: String,
+}
+
+impl Cli {
+    pub fn exec(self) -> Result<()> {
+        let (test_dir, config) = IntegrationTestConfig::load(&self.integration)?;
+
+        let envs_dir = state::envs_dir(&self.integration);
+        let runner = IntegrationTestRunner::new(self.integration)?;
+        runner.ensure_network()?;
+
+        let mut command = Command::new("cargo");
+        command.current_dir(&test_dir);
+        command.env(NETWORK_ENV_VAR, runner.network_name());
+        command.args(["run", "--quiet", "--", "start"]);
+
+        let environments = config.environments();
+        let json = match environments.get(&self.environment) {
+            Some(config) => serde_json::to_string(config)?,
+            None => bail!("unknown environment: {}", self.environment),
+        };
+        command.arg(&json);
+
+        if state::env_exists(&envs_dir, &self.environment) {
+            bail!("environment is already up");
+        }
+
+        if let Some(env_vars) = config.env {
+            command.envs(env_vars);
+        }
+
+        waiting!("Starting environment {}", &self.environment);
+        command.run()?;
+
+        state::save_env(&envs_dir, &self.environment, &json)?;
+        Ok(())
+    }
+}
diff --git a/vdev/src/commands/integration/stop.rs b/vdev/src/commands/integration/stop.rs
new file mode 100644
index 0000000000000..7aa8d63389682
--- /dev/null
+++ b/vdev/src/commands/integration/stop.rs
@@ -0,0 +1,61 @@
+use anyhow::{bail, Result};
+use clap::Args;
+use std::process::Command;
+
+use crate::app::CommandExt as _;
+use crate::testing::{config::IntegrationTestConfig, runner::*, state};
+
+/// Stop an environment
+#[derive(Args, Debug)]
+#[command()]
+pub struct Cli {
+    /// The desired integration
+    integration: String,
+
+    /// The desired environment
+    environment: String,
+
+    /// Use the currently defined configuration if the environment is not up
+    #[arg(short, long)]
+    force: bool,
+}
+
+impl Cli {
+    pub fn exec(self) -> Result<()> {
+        let (test_dir, config) = IntegrationTestConfig::load(&self.integration)?;
+        let envs_dir = state::envs_dir(&self.integration);
+        let runner = IntegrationTestRunner::new(self.integration.clone())?;
+
+        let mut command = Command::new("cargo");
+        command.current_dir(&test_dir);
+        command.env(NETWORK_ENV_VAR, runner.network_name());
+        command.args(["run", "--quiet", "--", "stop"]);
+
+        if state::env_exists(&envs_dir, &self.environment) {
+            command.arg(state::read_env_config(&envs_dir, &self.environment)?);
+        } else if self.force {
+            let environments = config.environments();
+            if let Some(config) = environments.get(&self.environment) {
+                command.arg(serde_json::to_string(config)?);
+            } else {
+                bail!("unknown environment: {}", self.environment);
+            }
+        } else {
+            bail!("environment is not up");
+        }
+
+        if let Some(env_vars) = config.env {
+            command.envs(env_vars);
+        }
+
+        waiting!("Stopping environment {}", &self.environment);
+        command.run()?;
+
+        state::remove_env(&envs_dir, &self.environment)?;
+        if state::active_envs(&envs_dir)?.is_empty() {
+            runner.stop()?;
+        }
+
+        Ok(())
+    }
+}
diff --git a/vdev/src/commands/integration/test.rs b/vdev/src/commands/integration/test.rs
new file mode 100644
index 0000000000000..0109f9e6659bc
--- /dev/null
+++ b/vdev/src/commands/integration/test.rs
@@ -0,0 +1,107 @@
+use anyhow::{bail, Result};
+use clap::Args;
+use std::collections::BTreeMap;
+use std::process::Command;
+
+use crate::app::CommandExt as _;
+use crate::testing::{config::IntegrationTestConfig, runner::*, state};
+
+/// Execute tests
+#[derive(Args, Debug)]
+#[command()]
+pub struct Cli {
+    /// The desired integration
+    integration: String,
+
+    /// The desired environment
+    environment: Option<String>,
+
+    /// Extra test command arguments
+    args: Option<Vec<String>>,
+}
+
+impl Cli {
+    pub fn exec(self) -> Result<()> {
+        let (test_dir, config) = IntegrationTestConfig::load(&self.integration)?;
+        let runner = IntegrationTestRunner::new(self.integration.clone())?;
+        let envs_dir = state::envs_dir(&self.integration);
+        let envs = config.environments();
+
+        let env_vars: BTreeMap<_, _> = config
+            .env
+            .clone()
+            .map_or(BTreeMap::default(), |map| map.into_iter().collect());
+
+        let mut args: Vec<_> = config.args.into_iter().collect();
+        if let Some(configured_args) = self.args {
+            args.extend(configured_args);
+        }
+
+        if let Some(environment) = &self.environment {
+            if !state::env_exists(&envs_dir, environment) {
+                bail!("environment {environment} is not up");
+            }
+
+            return runner.test(&env_vars, &args);
+        }
+
+        runner.ensure_network()?;
+
+        let active_envs = state::active_envs(&envs_dir)?;
+        for (env_name, env_config) in envs {
+            if !(active_envs.is_empty() || active_envs.contains(&env_name)) {
+                continue;
+            }
+
+            let env_active = state::env_exists(&envs_dir, &env_name);
+            if !env_active {
+                let mut command = Command::new("cargo");
+                command.current_dir(&test_dir);
+                command.env(NETWORK_ENV_VAR, runner.network_name());
+                command.args(["run", "--quiet", "--", "start"]);
+
+                let json = serde_json::to_string(&env_config)?;
+                command.arg(&json);
+
+                if let Some(env_vars) = &config.env {
+                    command.envs(env_vars);
+                }
+
+                waiting!("Starting environment {}", env_name);
+                command.run()?;
+
+                state::save_env(&envs_dir, &env_name, &json)?;
+            }
+
+            runner.test(&env_vars, &args)?;
+
+            if !env_active {
+                let mut command = Command::new("cargo");
+                command.current_dir(&test_dir);
+                command.env(NETWORK_ENV_VAR, runner.network_name());
+                command.args([
+                    "run",
+                    "--quiet",
+                    "--",
+                    "stop",
+                    &state::read_env_config(&envs_dir, &env_name)?,
+                ]);
+
+                if let Some(env_vars) = &config.env {
+                    command.envs(env_vars);
+                }
+
+                waiting!("Stopping environment {}", env_name);
+                command.run()?;
+
+                state::remove_env(&envs_dir, &env_name)?;
+            }
+        }
+
+        if active_envs.is_empty() {
+            runner.stop()?;
+        }
+
+        Ok(())
+    }
+}
diff --git a/vdev/src/commands/meta/mod.rs b/vdev/src/commands/meta/mod.rs
new file mode 100644
index 0000000000000..2aa4498785a38
--- /dev/null
+++ b/vdev/src/commands/meta/mod.rs
@@ -0,0 +1,25 @@
+use anyhow::Result;
+use clap::{Args, Subcommand};
+
+mod starship;
+
+/// Collection of useful utilities
+#[derive(Args, Debug)]
+#[command()]
+pub struct Cli {
+    #[command(subcommand)]
+    command: Commands,
+}
+
+#[derive(Subcommand, Debug)]
+enum Commands {
+    Starship(starship::Cli),
+}
+
+impl Cli {
+    pub fn exec(self) -> Result<()> {
+        match self.command {
+            Commands::Starship(cli) => cli.exec(),
+        }
+    }
+}
diff --git a/vdev/src/commands/meta/starship.rs b/vdev/src/commands/meta/starship.rs
new file mode 100644
index 0000000000000..cb009e0500a23
--- /dev/null
+++ b/vdev/src/commands/meta/starship.rs
@@ -0,0 +1,40 @@
+use anyhow::Result;
+use clap::Args;
+use std::fs::File;
+use std::io::{prelude::*, BufReader};
+use std::path::PathBuf;
+
+use crate::app;
+
+const VERSION_START: &str = "version = ";
+
+/// Custom Starship prompt plugin
+#[derive(Args, Debug)]
+#[command(hide = true)]
+pub struct Cli {}
+
+impl Cli {
+    pub fn exec(self) -> Result<()> {
+        let mut contexts = vec![];
+
+        let path: PathBuf = [app::path(), "Cargo.toml"].iter().collect();
+        if let Ok(file) = File::open(path) {
+            let reader = BufReader::new(file);
+
+            for line in reader.lines() {
+                let line = line?;
+                if line.starts_with(VERSION_START) {
+                    contexts.push(format!(
+                        "version: {}",
+                        &line[VERSION_START.len() + 1..line.len() - 1]
+                    ));
+                    break;
+                }
+            }
+        };
+
+        display!("vector{{ {} }}", contexts.join(", "));
+
+        Ok(())
+    }
+}
diff --git a/vdev/src/commands/mod.rs b/vdev/src/commands/mod.rs
new file mode 100644
index 0000000000000..48ac7b126b3db
--- /dev/null
+++ b/vdev/src/commands/mod.rs
@@ -0,0 +1,55 @@
+use anyhow::Result;
+use clap::{Parser, Subcommand};
+use clap_verbosity_flag::{InfoLevel, Verbosity};
+
+mod build;
+mod complete;
+mod config;
+mod exec;
+mod integration;
+mod meta;
+mod status;
+mod test;
+
+/// Vector's unified dev tool
+#[derive(Parser, Debug)]
+#[command(
+    version,
+    bin_name = "vdev",
+    infer_subcommands = true,
+    disable_help_subcommand = true
+)]
+pub struct Cli {
+    #[clap(flatten)]
+    pub verbose: Verbosity<InfoLevel>,
+
+    #[command(subcommand)]
+    command: Commands,
+}
+
+#[derive(Subcommand, Debug)]
+enum Commands {
+    Build(build::Cli),
+    Complete(complete::Cli),
+    Config(config::Cli),
+    Exec(exec::Cli),
+    Integration(integration::Cli),
+    Meta(meta::Cli),
+    Status(status::Cli),
+    Test(test::Cli),
+}
+
+impl Cli {
+    pub fn exec(self) -> Result<()> {
+        match self.command {
+            Commands::Build(cli) => cli.exec(),
+            Commands::Complete(cli) => cli.exec(),
+            Commands::Config(cli) => cli.exec(),
+            Commands::Exec(cli) => cli.exec(),
+            Commands::Integration(cli) => cli.exec(),
+            Commands::Meta(cli) => cli.exec(),
+            Commands::Status(cli) => cli.exec(),
+            Commands::Test(cli) => cli.exec(),
+        }
+    }
+}
diff --git a/vdev/src/commands/status.rs b/vdev/src/commands/status.rs
new file mode 100644
index 0000000000000..ab191ce870c1a
--- /dev/null
+++ b/vdev/src/commands/status.rs
@@ -0,0 +1,18 @@
+use anyhow::Result;
+use clap::Args;
+
+use crate::git;
+
+/// Show information about the current environment
+#[derive(Args, Debug)]
+#[command()]
+pub struct Cli {}
+
+impl Cli {
+    pub fn exec(self) -> Result<()> {
+        display!("Branch: {}", git::current_branch()?);
+        display!("Changed files: {}", git::changed_files()?.len());
+
+        Ok(())
+    }
+}
diff --git a/vdev/src/commands/test.rs b/vdev/src/commands/test.rs
new file mode 100644
index 0000000000000..92871811efc70
--- /dev/null
+++ b/vdev/src/commands/test.rs
@@ -0,0 +1,54 @@
+use anyhow::Result;
+use clap::Args;
+use std::collections::BTreeMap;
+
+use crate::testing::{config::RustToolchainConfig, runner::get_agent_test_runner};
+
+/// Execute tests
+#[derive(Args, Debug)]
+#[command()]
+pub struct Cli {
+    /// Extra test command arguments
+    args: Option<Vec<String>>,
+
+    /// Whether to run tests in a container
+    #[arg(short = 'C', long)]
+    container: bool,
+
+    /// Environment variables in the form KEY[=VALUE]
+    #[arg(short, long)]
+    env: Option<Vec<String>>,
+}
+
+impl Cli {
+    pub fn exec(self) -> Result<()> {
+        let toolchain_config = RustToolchainConfig::parse()?;
+        let runner = get_agent_test_runner(self.container, toolchain_config.channel);
+
+        let mut env_vars = BTreeMap::new();
+        if let Some(extra_env_vars) = &self.env {
+            for entry in extra_env_vars {
+                if let Some((key, value)) = entry.split_once('=') {
+                    env_vars.insert(key.to_string(), value.to_string());
+                } else {
+                    env_vars.insert(entry.to_string(), String::new());
+                }
+            }
+        }
+
+        let mut args = vec!["--workspace".to_string()];
+        if let Some(extra_args) = &self.args {
+            args.extend(extra_args.clone());
+
+            if !(self.container || extra_args.contains(&"--features".to_string())) {
+                if cfg!(windows) {
+                    args.extend(["--features".to_string(), "default-msvc".to_string()]);
+                } else {
+                    args.extend(["--features".to_string(), "default".to_string()]);
+                }
+            }
+        }
+
+        runner.test(&env_vars, &args)
+    }
+}
diff --git a/vdev/src/config.rs b/vdev/src/config.rs
new file mode 100644
index 0000000000000..4bfc84e149cbc
--- /dev/null
+++ b/vdev/src/config.rs
@@ -0,0 +1,24 @@
+use anyhow::{Context, Result};
+use serde::{Deserialize, Serialize};
+use std::path::PathBuf;
+
+const APP_NAME: &str = "vdev";
+const FILE_STEM: &str = "config";
+
+#[derive(Serialize, Deserialize, Clone, Debug, Default)]
+pub struct Config {
+    pub repo: String,
+}
+
+pub fn path() -> Result<PathBuf> {
+    confy::get_configuration_file_path(APP_NAME, FILE_STEM)
+        .with_context(|| "unable to find the config file")
+}
+
+pub fn load() -> Result<Config> {
+    confy::load(APP_NAME, FILE_STEM).with_context(|| "unable to load config")
+}
+
+pub fn save(config: Config) -> Result<()> {
+    confy::store(APP_NAME, FILE_STEM, config).with_context(|| "unable to save config")
+}
diff --git a/vdev/src/git.rs b/vdev/src/git.rs
new file mode 100644
index 0000000000000..1c05b4384eb41
--- /dev/null
+++ b/vdev/src/git.rs
@@ -0,0 +1,56 @@
+use std::{collections::HashSet, process::Command};
+
+use anyhow::Result;
+
+use crate::app::CommandExt as _;
+
+pub fn current_branch() -> Result<String> {
+    let output = capture_output(&["rev-parse", "--abbrev-ref", "HEAD"])?;
+    Ok(output.trim_end().to_string())
+}
+
+pub fn changed_files() -> Result<Vec<String>> {
+    let mut files = HashSet::new();
+
+    // Committed e.g.:
+    // A   relative/path/to/file.added
+    // M   relative/path/to/file.modified
+    let output = capture_output(&["diff", "--name-status", "origin/master..."])?;
+    for line in output.lines() {
+        if !is_warning_line(line) {
+            if let Some((_, path)) = line.split_once('\t') {
+                files.insert(path.to_string());
+            }
+        }
+    }
+
+    // Tracked
+    let output = capture_output(&["diff", "--name-only", "HEAD"])?;
+    for line in output.lines() {
+        if !is_warning_line(line) {
+            files.insert(line.to_string());
+        }
+    }
+
+    // Untracked
+    let output = capture_output(&["ls-files", "--others", "--exclude-standard"])?;
+    for line in output.lines() {
+        files.insert(line.to_string());
+    }
+
+    let mut sorted = Vec::from_iter(files);
+    sorted.sort();
+
+    Ok(sorted)
+}
+
+fn capture_output(args: &[&str]) -> Result<String> {
+    let mut command = Command::with_path("git");
+    command.args(args);
+
+    command.capture_output()
+}
+
+fn is_warning_line(line: &str) -> bool {
+    line.starts_with("warning: ") || line.contains("original line endings")
+}
diff --git a/vdev/src/macros.rs b/vdev/src/macros.rs
new file mode 100644
index 0000000000000..a48bfb377aea8
--- /dev/null
+++ b/vdev/src/macros.rs
@@ -0,0 +1,51 @@
+macro_rules! display {
+    ($($arg:tt)*) => {{
+        use owo_colors::OwoColorize;
+        println!(
+            "{}",
+            format!($($arg)*)
+                // Simply bold rather than bright white for terminals with white backgrounds
+                .if_supports_color(owo_colors::Stream::Stdout, |text| text.bold())
+        );
+    }};
+}
+
+#[allow(unused_macros)]
+macro_rules! critical {
+    ($($arg:tt)*) => {{
+        use owo_colors::OwoColorize;
+        eprintln!(
+            "{}",
+            format!($($arg)*)
+                .if_supports_color(owo_colors::Stream::Stderr, |text| text.bright_red())
+        );
+    }};
+}
+
+macro_rules! define_display_macro {
+    // https://github.com/rust-lang/rust/issues/35853#issuecomment-415993963
+    // https://github.com/rust-lang/rust/issues/83527#issuecomment-1281176235
+    ($name:ident, $level:ident, $style:ident, $d:tt) => (
+        #[allow(unused_macros)]
+        macro_rules! $name {
+            ($d($d arg:tt)*) => {{
+                use owo_colors::OwoColorize;
+                if log::Level::$level <= *$crate::app::verbosity() {
+                    eprintln!(
+                        "{}",
+                        format!($d($d arg)*)
+                            .if_supports_color(owo_colors::Stream::Stderr, |text| text.$style())
+                    );
+                }
+            }};
+        }
+    );
+}
+
+define_display_macro!(trace, Trace, underline, $);
+define_display_macro!(debug, Debug, italic, $);
+define_display_macro!(info, Info, bold, $);
+define_display_macro!(success, Info, bright_cyan, $);
+define_display_macro!(waiting, Info, bright_magenta, $);
+define_display_macro!(warn, Warn, bright_yellow, $);
+define_display_macro!(error, Error, bright_red, $);
diff --git a/vdev/src/main.rs b/vdev/src/main.rs
new file mode 100644
index 0000000000000..a1bf9caf8f879
--- /dev/null
+++ b/vdev/src/main.rs
@@ -0,0 +1,41 @@
+#![deny(clippy::pedantic)]
+#![allow(
+    clippy::module_name_repetitions,
+    clippy::unused_self,
+    clippy::wildcard_imports,
+    clippy::unnecessary_wraps
+)]
+
+#[macro_use]
+mod macros;
+mod app;
+mod commands;
+mod config;
+mod git;
+mod platform;
+mod testing;
+
+use anyhow::Result;
+use clap::Parser;
+use std::env;
+
+use commands::Cli;
+
+fn main() -> Result<()> {
+    let cli = Cli::parse();
+
+    app::set_global_verbosity(cli.verbose.log_level_filter());
+    app::set_global_config(config::load()?);
+
+    let path = if app::config().repo.is_empty() {
+        env::current_dir()
+            .expect("Could not determine current directory")
+            .display()
+            .to_string()
+    } else {
+        app::config().repo.clone()
+    };
+    app::set_global_path(path);
+
+    cli.exec()
+}
diff --git a/vdev/src/platform.rs b/vdev/src/platform.rs
new file mode 100644
index 0000000000000..1e9c1754f0766
--- /dev/null
+++ b/vdev/src/platform.rs
@@ -0,0 +1,34 @@
+use cached::proc_macro::once;
+use directories::ProjectDirs;
+
+use std::env::consts::ARCH;
+use std::path::{Path, PathBuf};
+
+pub fn canonicalize_path(path: impl AsRef<Path>) -> String {
+    let path = path.as_ref();
+    dunce::canonicalize(path)
+        .unwrap_or_else(|err| panic!("Could not canonicalize path {path:?}: {err}"))
+        .display()
+        .to_string()
+}
+
+#[once]
+pub fn data_dir() -> PathBuf {
+    _project_dirs().data_local_dir().to_path_buf()
+}
+
+#[once]
+pub fn default_target() -> String {
+    if cfg!(windows) {
+        format!("{ARCH}-pc-windows-msvc")
+    } else if cfg!(macos) {
+        format!("{ARCH}-apple-darwin")
+    } else {
+        format!("{ARCH}-unknown-linux-gnu")
+    }
+}
+
+#[once]
+fn _project_dirs() -> ProjectDirs {
+    ProjectDirs::from("", "vector", "vdev").expect("Could not determine the project directory")
+}
diff --git a/vdev/src/testing/config.rs b/vdev/src/testing/config.rs
new file mode 100644
index 0000000000000..548bfc50c4406
--- /dev/null
+++ b/vdev/src/testing/config.rs
@@ -0,0 +1,104 @@
+use anyhow::{bail, Context, Result};
+use hashlink::LinkedHashMap;
+use itertools::{self, Itertools};
+use serde::Deserialize;
+
+use std::collections::BTreeMap;
+use std::fs;
+use std::path::{Path, PathBuf};
+
+use crate::app;
+
+const FILE_NAME: &str = "test.yaml";
+
+#[derive(Deserialize, Debug)]
+pub struct RustToolchainRootConfig {
+    pub toolchain: RustToolchainConfig,
+}
+
+#[derive(Deserialize, Debug)]
+pub struct RustToolchainConfig {
+    pub channel: String,
+}
+
+impl RustToolchainConfig {
+    pub fn parse() -> Result<Self> {
+        let repo_path = app::path();
+        let config_file: PathBuf = [repo_path, "rust-toolchain.toml"].iter().collect();
+        let contents = fs::read_to_string(&config_file)
+            .with_context(|| format!("failed to read {}", config_file.display()))?;
+        let config: RustToolchainRootConfig = toml::from_str(&contents)
+            .with_context(|| format!("failed to parse {}", config_file.display()))?;
+
+        Ok(config.toolchain)
+    }
+}
+
+#[derive(Deserialize, Clone, Debug)]
+pub struct IntegrationTestConfig {
+    pub args: Vec<String>,
+    pub env: Option<BTreeMap<String, String>>,
+    matrix: Vec<LinkedHashMap<String, Vec<String>>>,
+}
+
+impl IntegrationTestConfig {
+    fn parse_file(config_file: &Path) -> Result<Self> {
+        let contents = fs::read_to_string(config_file)
+            .with_context(|| format!("failed to read {}", config_file.display()))?;
+        let config: IntegrationTestConfig = serde_yaml::from_str(&contents).with_context(|| {
+            format!(
+                "failed to parse integration test configuration file {}",
+                config_file.display()
+            )
+        })?;
+
+        Ok(config)
+    }
+
+    pub fn environments(&self) -> LinkedHashMap<String, LinkedHashMap<String, String>> {
+        let mut environments = LinkedHashMap::new();
+
+        for matrix in &self.matrix {
+            for product in matrix.values().multi_cartesian_product() {
+                let mut config = LinkedHashMap::new();
+                for (variable, &value) in matrix.keys().zip(product.iter()) {
+                    config.insert(variable.clone(), value.clone());
+                }
+
+                environments.insert(product.iter().join("-"), config);
+            }
+        }
+
+        environments
+    }
+
+    pub fn load(integration: &str) -> Result<(PathBuf, Self)> {
+        let test_dir: PathBuf = [app::path(), "scripts", "integration", integration]
+            .iter()
+            .collect();
+        if !test_dir.is_dir() {
+            bail!("unknown integration: {}", integration);
+        }
+
+        let config = Self::parse_file(&test_dir.join(FILE_NAME))?;
+        Ok((test_dir, config))
+    }
+
+    #[allow(dead_code)]
+    pub fn collect_all(root: &str) -> Result<BTreeMap<String, Self>> {
+        let mut configs = BTreeMap::new();
+        let tests_dir: PathBuf = [root, "scripts", "integration"].iter().collect();
+        for entry in tests_dir.read_dir()? {
+            let entry = entry?;
+            if !entry.path().is_dir() {
+                continue;
+            }
+
+            let config_file: PathBuf = [entry.path().to_str().unwrap(), FILE_NAME].iter().collect();
+            let config = Self::parse_file(&config_file)?;
+            configs.insert(entry.file_name().into_string().unwrap(), config);
+        }
+
+        Ok(configs)
+    }
+}
diff --git a/vdev/src/testing/mod.rs b/vdev/src/testing/mod.rs
new file mode 100644
index 0000000000000..fa895888889d4
--- /dev/null
+++ b/vdev/src/testing/mod.rs
@@ -0,0 +1,3 @@
+pub mod config;
+pub mod runner;
+pub mod state;
diff --git a/vdev/src/testing/runner.rs b/vdev/src/testing/runner.rs
new file mode 100644
index 0000000000000..015094203f93e
--- /dev/null
+++ b/vdev/src/testing/runner.rs
@@ -0,0 +1,358 @@
+use std::collections::{BTreeMap, HashSet};
+use std::path::PathBuf;
+use std::process::Command;
+
+use anyhow::Result;
+use atty::Stream;
+
+use super::config::RustToolchainConfig;
+use crate::app::{self, CommandExt as _};
+
+pub const NETWORK_ENV_VAR: &str = "VECTOR_NETWORK";
+const MOUNT_PATH: &str = "/home/vector";
+const TARGET_PATH: &str = "/home/target";
+const VOLUME_TARGET: &str = "vector_target";
+const VOLUME_CARGO_GIT: &str = "vector_cargo_git";
+const VOLUME_CARGO_REGISTRY: &str = "vector_cargo_registry";
+const TEST_COMMAND: &[&str] = &[
+    "cargo",
+    "nextest",
+    "run",
+    "--no-fail-fast",
+    "--no-default-features",
+];
+
+enum RunnerState {
+    Running,
+    Restarting,
+    Created,
+    Exited,
+    Paused,
+    Dead,
+    Missing,
+    Unknown,
+}
+
+pub fn get_agent_test_runner(container: bool, rust_version: String) -> Box<dyn TestRunner> {
+    if container {
+        Box::new(DockerTestRunner::new(rust_version))
+    } else {
+        Box::new(LocalTestRunner::new())
+    }
+}
+
+pub trait TestRunner {
+    fn test(&self, env_vars: &BTreeMap<String, String>, args: &[String]) -> Result<()>;
+}
+
+pub trait ContainerTestRunnerBase: TestRunner {
+    fn container_name(&self) -> String;
+
+    fn image_name(&self) -> String;
+
+    fn network_name(&self) -> String {
+        "host".to_string()
+    }
+
+    fn stop(&self) -> Result<()> {
+        let mut command = Command::new("docker");
+        command.args(["stop", "--time", "0", &self.container_name()]);
+
+        command.wait(format!("Stopping container {}", self.container_name()))
+    }
+}
+
+trait ContainerTestRunner: ContainerTestRunnerBase {
+    fn get_rust_version(&self) -> &str;
+
+    fn state(&self) -> Result<RunnerState> {
+        let mut command = Command::new("docker");
+        command.args(["ps", "-a", "--format", "{{.Names}} {{.State}}"]);
+
+        for line in command.capture_output()?.lines() {
+            if let Some((name, state)) = line.split_once(' ') {
+                if name == self.container_name() {
+                    return Ok(match state {
+                        "created" => RunnerState::Created,
+                        "dead" => RunnerState::Dead,
+                        "exited" => RunnerState::Exited,
+                        "paused" => RunnerState::Paused,
+                        "restarting" => RunnerState::Restarting,
+                        "running" => RunnerState::Running,
+                        _ => RunnerState::Unknown,
+                    });
+                }
+            }
+        }
+
+        Ok(RunnerState::Missing)
+    }
+
+    fn verify_state(&self) -> Result<()> {
+        match self.state()? {
+            RunnerState::Running | RunnerState::Restarting => (),
+            RunnerState::Created | RunnerState::Exited => self.start()?,
+            RunnerState::Paused => self.unpause()?,
+            RunnerState::Dead | RunnerState::Unknown => {
+                self.remove()?;
+                self.create()?;
+                self.start()?;
+            }
+            RunnerState::Missing => {
+                self.build()?;
+                self.ensure_volumes()?;
+                self.create()?;
+                self.start()?;
+            }
+        }
+
+        Ok(())
+    }
+
+    fn ensure_volumes(&self) -> Result<()> {
+        let mut command = Command::new("docker");
+        command.args(["volume", "ls", "--format", "{{.Name}}"]);
+
+        let mut volumes = HashSet::new();
+        volumes.insert(VOLUME_TARGET);
+        volumes.insert(VOLUME_CARGO_GIT);
+        volumes.insert(VOLUME_CARGO_REGISTRY);
+        for volume in command.capture_output()?.lines() {
+            volumes.take(volume);
+        }
+
+        for volume in &volumes {
+            let mut command = Command::new("docker");
+            command.args(["volume", "create", volume]);
+
+            command.wait(format!("Creating volume {volume}"))?;
+        }
+
+        Ok(())
+    }
+
+    fn build(&self) -> Result<()> {
+        let dockerfile: PathBuf = [app::path(), "scripts", "integration", "Dockerfile"]
+            .iter()
+            .collect();
+        let mut command = Command::new("docker");
+        command.current_dir(app::path());
+        command.arg("build");
+        if atty::is(Stream::Stdout) {
+            command.args(["--progress", "tty"]);
+        }
+        command.args([
+            "--pull",
+            "--tag",
+            &self.image_name(),
+            "--file",
+            dockerfile.to_str().unwrap(),
+            "--build-arg",
+            &format!("RUST_VERSION={}", self.get_rust_version()),
+            ".",
+        ]);
+
+        waiting!("Building image {}", self.image_name());
+        command.run()
+    }
+
+    fn start(&self) -> Result<()> {
+        let mut command = Command::new("docker");
+        command.args(["start", &self.container_name()]);
+
+        command.wait(format!("Starting container {}", self.container_name()))
+    }
+
+    fn remove(&self) -> Result<()> {
+        let mut command = Command::new("docker");
+        command.args(["rm", &self.container_name()]);
+
+        command.wait(format!("Removing container {}", self.container_name()))
+    }
+
+    fn unpause(&self) -> Result<()> {
+        let mut command = Command::new("docker");
+        command.args(["unpause", &self.container_name()]);
+
+        command.wait(format!("Unpausing container {}", self.container_name()))
+    }
+
+    fn create(&self) -> Result<()> {
+        let mut command = Command::new("docker");
+        command.arg("create");
+        command.args([
+            "--name",
+            &self.container_name(),
+            "--network",
+            &self.network_name(),
+            "--workdir",
+            MOUNT_PATH,
+            "--volume",
+            &format!("{}:{MOUNT_PATH}", app::path()),
+            "--volume",
+            &format!("{VOLUME_TARGET}:{TARGET_PATH}"),
+            "--volume",
+            &format!("{VOLUME_CARGO_GIT}:/usr/local/cargo/git"),
+            "--volume",
+            &format!("{VOLUME_CARGO_REGISTRY}:/usr/local/cargo/registry"),
+            &self.image_name(),
+            "/bin/sleep",
+            "infinity",
+        ]);
+
+        command.wait(format!("Creating container {}", self.container_name()))
+    }
+}
+
+pub struct IntegrationTestRunner {
+    integration: String,
+    rust_version: String,
+}
+
+impl IntegrationTestRunner {
+    pub fn new(integration: String) -> Result<Self> {
+        let rust_version = RustToolchainConfig::parse()?.channel;
+        Ok(Self {
+            integration,
+            rust_version,
+        })
+    }
+
+    pub fn ensure_network(&self) -> Result<()> {
+        let mut command = Command::new("docker");
+        command.args(["network", "ls", "--format", "{{.Name}}"]);
+
+        if command
+            .capture_output()?
+            .lines()
+            .any(|network| network == self.network_name())
+        {
+            return Ok(());
+        }
+
+        let mut command = Command::new("docker");
+        command.args(["network", "create", &self.network_name()]);
+
+        command.wait("Creating network")
+    }
+}
+
+impl TestRunner for IntegrationTestRunner {
+    fn test(&self, env_vars: &BTreeMap<String, String>, args: &[String]) -> Result<()> {
+        self.verify_state()?;
+
+        let mut command = Command::new("docker");
+        command.arg("exec");
+        if atty::is(Stream::Stdout) {
+            command.arg("--tty");
+        }
+
+        command.args(["--env", &format!("CARGO_BUILD_TARGET_DIR={TARGET_PATH}")]);
+        for (key, value) in env_vars {
+            command.env(key, value);
+            command.args(["--env", key]);
+        }
+
+        command.arg(&self.container_name());
+        command.args(TEST_COMMAND);
+        command.args(args);
+
+        command.run()
+    }
+}
+
+impl ContainerTestRunnerBase for IntegrationTestRunner {
+    fn network_name(&self) -> String {
+        format!("vector-integration-tests-{}", self.integration)
+    }
+
+    fn container_name(&self) -> String {
+        format!(
+            "vector-test-runner-{}-{}",
+            self.integration, self.rust_version
+        )
+    }
+
+    fn image_name(&self) -> String {
+        format!("{}:latest", self.container_name())
+    }
+}
+
+impl ContainerTestRunner for IntegrationTestRunner {
+    fn get_rust_version(&self) -> &str {
+        &self.rust_version
+    }
+}
+
+pub struct DockerTestRunner {
+    rust_version: String,
+}
+
+impl DockerTestRunner {
+    pub fn new(rust_version: String) -> Self {
+        Self { rust_version }
+    }
+}
+
+impl TestRunner for DockerTestRunner {
+    fn test(&self, env_vars: &BTreeMap<String, String>, args: &[String]) -> Result<()> {
+        self.verify_state()?;
+
+        let mut command = Command::new("docker");
+        command.arg("exec");
+        if atty::is(Stream::Stdout) {
+            command.arg("--tty");
+        }
+
+        command.args(["--env", &format!("CARGO_BUILD_TARGET_DIR={TARGET_PATH}")]);
+        for (key, value) in env_vars {
+            command.env(key, value);
+            command.args(["--env", key]);
+        }
+
+        command.arg(&self.container_name());
+        command.args(TEST_COMMAND);
+        command.args(args);
+
+        command.run()
+    }
+}
+
+impl ContainerTestRunnerBase for DockerTestRunner {
+    fn container_name(&self) -> String {
+        format!("vector-test-runner-{}", self.rust_version)
+    }
+
+    fn image_name(&self) -> String {
+        // The upstream container we publish artifacts to on a successful master build.
+        "docker.io/timberio/vector-dev:sha-3eadc96742a33754a5859203b58249f6a806972a".to_string()
+    }
+}
+
+impl ContainerTestRunner for DockerTestRunner {
+    fn get_rust_version(&self) -> &str {
+        &self.rust_version
+    }
+}
+
+pub struct LocalTestRunner {}
+
+impl LocalTestRunner {
+    pub fn new() -> Self {
+        Self {}
+    }
+}
+
+impl TestRunner for LocalTestRunner {
+    fn test(&self, env_vars: &BTreeMap<String, String>, args: &[String]) -> Result<()> {
+        let mut command = Command::new(TEST_COMMAND[0]);
+        command.args(&TEST_COMMAND[1..]);
+        command.args(args);
+
+        for (key, value) in env_vars {
+            command.env(key, value);
+        }
+
+        command.run()
+    }
+}
diff --git a/vdev/src/testing/state.rs b/vdev/src/testing/state.rs
new file mode 100644
index 0000000000000..29ecfb260ad00
--- /dev/null
+++ b/vdev/src/testing/state.rs
@@ -0,0 +1,75 @@
+use std::collections::HashSet;
+use std::fs;
+use std::path::{Path, PathBuf};
+
+use anyhow::{Context, Result};
+
+use crate::platform;
+
+const CONFIG_FILE: &str = "config.json";
+
+pub fn envs_dir(integration: &str) -> PathBuf {
+    [
+        &platform::data_dir(),
+        Path::new("integration/envs"),
+        Path::new(integration),
+    ]
+    .iter()
+    .collect()
+}
+
+pub fn env_exists(envs_dir: &Path, environment: &str) -> bool {
+    let dir: PathBuf = [envs_dir, Path::new(environment)].iter().collect();
+    dir.is_dir()
+}
+
+pub fn active_envs(envs_dir: &Path) -> Result<HashSet<String>> {
+    let mut environments = HashSet::new();
+    if !envs_dir.is_dir() {
+        return Ok(environments);
+    }
+
+    for entry in envs_dir
+        .read_dir()
+        .with_context(|| format!("failed to read directory {}", envs_dir.display()))?
+    {
+        let entry = entry
+            .with_context(|| format!("failed to read directory entry {}", envs_dir.display()))?;
+        if entry.path().is_dir() {
+            environments.insert(entry.file_name().into_string().unwrap());
+        }
+    }
+
+    Ok(environments)
+}
+
+pub fn save_env(envs_dir: &Path, environment: &str, config: &str) -> Result<()> {
+    let mut path = envs_dir.join(environment);
+    if !path.is_dir() {
+        fs::create_dir_all(&path)
+            .with_context(|| format!("failed to create directory {}", path.display()))?;
+    }
+
+    path.push(CONFIG_FILE);
+    fs::write(&path, config).with_context(|| format!("failed to write file {}", path.display()))?;
+
+    Ok(())
+}
+
+pub fn read_env_config(envs_dir: &Path, environment: &str) -> Result<String> {
+    let mut config_file = envs_dir.join(environment);
+    config_file.push(CONFIG_FILE);
+
+    fs::read_to_string(&config_file)
+        .with_context(|| format!("failed to write file {}", config_file.display()))
+}
+
+pub fn remove_env(envs_dir: &Path, environment: &str) -> Result<()> {
+    let env_path = envs_dir.join(environment);
+    if env_path.is_dir() {
+        fs::remove_dir_all(&env_path)
+            .with_context(|| format!("failed to remove directory {env_path:?}"))?;
+    }
+
+    Ok(())
+}