This is an automated email from the ASF dual-hosted git repository.

bugraoz pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 3bf19a7d95c feat(airflowctl): Add Connections Import/Export from/to 
file (#52584)
3bf19a7d95c is described below

commit 3bf19a7d95ca8b5685efa1ad2a0b13b67318a660
Author: Bugra Ozturk <[email protected]>
AuthorDate: Tue Jul 1 19:47:28 2025 +0200

    feat(airflowctl): Add Connections Import/Export from/to file (#52584)
---
 airflow-ctl/docs/images/command_hashes.txt         |   2 +-
 airflow-ctl/docs/images/output_connections.svg     |  92 ++++++------
 airflow-ctl/src/airflowctl/ctl/cli_config.py       |  72 +++++++---
 .../airflowctl/ctl/commands/connection_command.py  |  93 ++++++++++++
 .../ctl/commands/test_connections_command.py       | 160 +++++++++++++++++++++
 5 files changed, 355 insertions(+), 64 deletions(-)

diff --git a/airflow-ctl/docs/images/command_hashes.txt 
b/airflow-ctl/docs/images/command_hashes.txt
index 868e9a10fba..84cab0f38ec 100644
--- a/airflow-ctl/docs/images/command_hashes.txt
+++ b/airflow-ctl/docs/images/command_hashes.txt
@@ -3,7 +3,7 @@ assets:b3ae2b933e54528bf486ff28e887804d
 auth:f396d4bce90215599dde6ad0a8f30f29
 backfills:725109470cd2613de8cc8af022fb54bc
 config:cb175bedf29e8a2c2c6a2ebd13d770a7
-connections:3614621ec6ef503b46c69bf5aaf3018b
+connections:44e4da38aa214ccab4a1414a0c8967bb
 dag:f4a1936ebf330773001d6d04f65c3249
 dagrun:8381fea6a9119b9ebba1b39261ac68a4
 jobs:7f8680afff230eb9940bc7fca727bd52
diff --git a/airflow-ctl/docs/images/output_connections.svg 
b/airflow-ctl/docs/images/output_connections.svg
index 3d88d794cb1..2fc625d0108 100644
--- a/airflow-ctl/docs/images/output_connections.svg
+++ b/airflow-ctl/docs/images/output_connections.svg
@@ -1,4 +1,4 @@
-<svg class="rich-terminal" viewBox="0 0 994 464.79999999999995" 
xmlns="http://www.w3.org/2000/svg";>
+<svg class="rich-terminal" viewBox="0 0 994 513.5999999999999" 
xmlns="http://www.w3.org/2000/svg";>
     <!-- Generated with Rich https://www.textualize.io -->
     <style>
 
@@ -19,103 +19,111 @@
         font-weight: 700;
     }
 
-    .terminal-560929528-matrix {
+    .terminal-3301982893-matrix {
         font-family: Fira Code, monospace;
         font-size: 20px;
         line-height: 24.4px;
         font-variant-east-asian: full-width;
     }
 
-    .terminal-560929528-title {
+    .terminal-3301982893-title {
         font-size: 18px;
         font-weight: bold;
         font-family: arial;
     }
 
-    .terminal-560929528-r1 { fill: #c5c8c6 }
+    .terminal-3301982893-r1 { fill: #c5c8c6 }
     </style>
 
     <defs>
-    <clipPath id="terminal-560929528-clip-terminal">
-      <rect x="0" y="0" width="975.0" height="413.79999999999995" />
+    <clipPath id="terminal-3301982893-clip-terminal">
+      <rect x="0" y="0" width="975.0" height="462.59999999999997" />
     </clipPath>
-    <clipPath id="terminal-560929528-line-0">
+    <clipPath id="terminal-3301982893-line-0">
     <rect x="0" y="1.5" width="976" height="24.65"/>
             </clipPath>
-<clipPath id="terminal-560929528-line-1">
+<clipPath id="terminal-3301982893-line-1">
     <rect x="0" y="25.9" width="976" height="24.65"/>
             </clipPath>
-<clipPath id="terminal-560929528-line-2">
+<clipPath id="terminal-3301982893-line-2">
     <rect x="0" y="50.3" width="976" height="24.65"/>
             </clipPath>
-<clipPath id="terminal-560929528-line-3">
+<clipPath id="terminal-3301982893-line-3">
     <rect x="0" y="74.7" width="976" height="24.65"/>
             </clipPath>
-<clipPath id="terminal-560929528-line-4">
+<clipPath id="terminal-3301982893-line-4">
     <rect x="0" y="99.1" width="976" height="24.65"/>
             </clipPath>
-<clipPath id="terminal-560929528-line-5">
+<clipPath id="terminal-3301982893-line-5">
     <rect x="0" y="123.5" width="976" height="24.65"/>
             </clipPath>
-<clipPath id="terminal-560929528-line-6">
+<clipPath id="terminal-3301982893-line-6">
     <rect x="0" y="147.9" width="976" height="24.65"/>
             </clipPath>
-<clipPath id="terminal-560929528-line-7">
+<clipPath id="terminal-3301982893-line-7">
     <rect x="0" y="172.3" width="976" height="24.65"/>
             </clipPath>
-<clipPath id="terminal-560929528-line-8">
+<clipPath id="terminal-3301982893-line-8">
     <rect x="0" y="196.7" width="976" height="24.65"/>
             </clipPath>
-<clipPath id="terminal-560929528-line-9">
+<clipPath id="terminal-3301982893-line-9">
     <rect x="0" y="221.1" width="976" height="24.65"/>
             </clipPath>
-<clipPath id="terminal-560929528-line-10">
+<clipPath id="terminal-3301982893-line-10">
     <rect x="0" y="245.5" width="976" height="24.65"/>
             </clipPath>
-<clipPath id="terminal-560929528-line-11">
+<clipPath id="terminal-3301982893-line-11">
     <rect x="0" y="269.9" width="976" height="24.65"/>
             </clipPath>
-<clipPath id="terminal-560929528-line-12">
+<clipPath id="terminal-3301982893-line-12">
     <rect x="0" y="294.3" width="976" height="24.65"/>
             </clipPath>
-<clipPath id="terminal-560929528-line-13">
+<clipPath id="terminal-3301982893-line-13">
     <rect x="0" y="318.7" width="976" height="24.65"/>
             </clipPath>
-<clipPath id="terminal-560929528-line-14">
+<clipPath id="terminal-3301982893-line-14">
     <rect x="0" y="343.1" width="976" height="24.65"/>
             </clipPath>
-<clipPath id="terminal-560929528-line-15">
+<clipPath id="terminal-3301982893-line-15">
     <rect x="0" y="367.5" width="976" height="24.65"/>
             </clipPath>
+<clipPath id="terminal-3301982893-line-16">
+    <rect x="0" y="391.9" width="976" height="24.65"/>
+            </clipPath>
+<clipPath id="terminal-3301982893-line-17">
+    <rect x="0" y="416.3" width="976" height="24.65"/>
+            </clipPath>
     </defs>
 
-    <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" 
x="1" y="1" width="992" height="462.8" rx="8"/><text 
class="terminal-560929528-title" fill="#c5c8c6" text-anchor="middle" x="496" 
y="27">Command:&#160;connections</text>
+    <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" 
x="1" y="1" width="992" height="511.6" rx="8"/><text 
class="terminal-3301982893-title" fill="#c5c8c6" text-anchor="middle" x="496" 
y="27">Command:&#160;connections</text>
             <g transform="translate(26,22)">
             <circle cx="0" cy="0" r="7" fill="#ff5f57"/>
             <circle cx="22" cy="0" r="7" fill="#febc2e"/>
             <circle cx="44" cy="0" r="7" fill="#28c840"/>
             </g>
         
-    <g transform="translate(9, 41)" 
clip-path="url(#terminal-560929528-clip-terminal)">
+    <g transform="translate(9, 41)" 
clip-path="url(#terminal-3301982893-clip-terminal)">
     
-    <g class="terminal-560929528-matrix">
-    <text class="terminal-560929528-r1" x="0" y="20" textLength="561.2" 
clip-path="url(#terminal-560929528-line-0)">Usage:&#160;airflowctl&#160;connections&#160;[-h]&#160;COMMAND&#160;...</text><text
 class="terminal-560929528-r1" x="976" y="20" textLength="12.2" 
clip-path="url(#terminal-560929528-line-0)">
-</text><text class="terminal-560929528-r1" x="976" y="44.4" textLength="12.2" 
clip-path="url(#terminal-560929528-line-1)">
-</text><text class="terminal-560929528-r1" x="0" y="68.8" textLength="366" 
clip-path="url(#terminal-560929528-line-2)">Perform&#160;Connections&#160;operations</text><text
 class="terminal-560929528-r1" x="976" y="68.8" textLength="12.2" 
clip-path="url(#terminal-560929528-line-2)">
-</text><text class="terminal-560929528-r1" x="976" y="93.2" textLength="12.2" 
clip-path="url(#terminal-560929528-line-3)">
-</text><text class="terminal-560929528-r1" x="0" y="117.6" textLength="256.2" 
clip-path="url(#terminal-560929528-line-4)">Positional&#160;Arguments:</text><text
 class="terminal-560929528-r1" x="976" y="117.6" textLength="12.2" 
clip-path="url(#terminal-560929528-line-4)">
-</text><text class="terminal-560929528-r1" x="0" y="142" textLength="109.8" 
clip-path="url(#terminal-560929528-line-5)">&#160;&#160;COMMAND</text><text 
class="terminal-560929528-r1" x="976" y="142" textLength="12.2" 
clip-path="url(#terminal-560929528-line-5)">
-</text><text class="terminal-560929528-r1" x="0" y="166.4" textLength="524.6" 
clip-path="url(#terminal-560929528-line-6)">&#160;&#160;&#160;&#160;create&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;Perform&#160;create&#160;operation</text><text
 class="terminal-560929528-r1" x="976" y="166.4" textLength="12.2" 
clip-path="url(#terminal-560929528-line-6)">
-</text><text class="terminal-560929528-r1" x="0" y="190.8" textLength="231.8" 
clip-path="url(#terminal-560929528-line-7)">&#160;&#160;&#160;&#160;create-defaults</text><text
 class="terminal-560929528-r1" x="976" y="190.8" textLength="12.2" 
clip-path="url(#terminal-560929528-line-7)">
-</text><text class="terminal-560929528-r1" x="0" y="215.2" textLength="634.4" 
clip-path="url(#terminal-560929528-line-8)">&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;Perform&#160;create_defaults&#160;operation</text><text
 class="terminal-560929528-r1" x="976" y="215.2" textLength="12.2" 
clip-path="url(#terminal-560929528-line-8)">
-</text><text class="terminal-560929528-r1" x="0" y="239.6" textLength="524.6" 
clip-path="url(#terminal-560929528-line-9)">&#160;&#160;&#160;&#160;delete&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;Perform&#160;delete&#160;operation</text><text
 class="terminal-560929528-r1" x="976" y="239.6" textLength="12.2" 
clip-path="url(#terminal-560929528-line-9)">
-</text><text class="terminal-560929528-r1" x="0" y="264" textLength="488" 
clip-path="url(#terminal-560929528-line-10)">&#160;&#160;&#160;&#160;get&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;Perform&#160;get&#160;operation</text><text
 class="terminal-560929528-r1" x="976" y="264" textLength="12.2" 
clip-path="url(#terminal-560929528-line-10)">
-</text><text class="terminal-560929528-r1" x="0" y="288.4" textLength="500.2" 
clip-path="url(#terminal-560929528-line-11)">&#160;&#160;&#160;&#160;list&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;Perform&#160;list&#160;operation</text><text
 class="terminal-560929528-r1" x="976" y="288.4" textLength="12.2" 
clip-path="url(#terminal-560929528-line-11)">
-</text><text class="terminal-560929528-r1" x="0" y="312.8" textLength="500.2" 
clip-path="url(#terminal-560929528-line-12)">&#160;&#160;&#160;&#160;test&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;Perform&#160;test&#160;operation</text><text
 class="terminal-560929528-r1" x="976" y="312.8" textLength="12.2" 
clip-path="url(#terminal-560929528-line-12)">
-</text><text class="terminal-560929528-r1" x="0" y="337.2" textLength="524.6" 
clip-path="url(#terminal-560929528-line-13)">&#160;&#160;&#160;&#160;update&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;Perform&#160;update&#160;operation</text><text
 class="terminal-560929528-r1" x="976" y="337.2" textLength="12.2" 
clip-path="url(#terminal-560929528-line-13)">
-</text><text class="terminal-560929528-r1" x="976" y="361.6" textLength="12.2" 
clip-path="url(#terminal-560929528-line-14)">
-</text><text class="terminal-560929528-r1" x="0" y="386" textLength="97.6" 
clip-path="url(#terminal-560929528-line-15)">Options:</text><text 
class="terminal-560929528-r1" x="976" y="386" textLength="12.2" 
clip-path="url(#terminal-560929528-line-15)">
-</text><text class="terminal-560929528-r1" x="0" y="410.4" textLength="610" 
clip-path="url(#terminal-560929528-line-16)">&#160;&#160;-h,&#160;--help&#160;&#160;&#160;&#160;&#160;&#160;&#160;show&#160;this&#160;help&#160;message&#160;and&#160;exit</text><text
 class="terminal-560929528-r1" x="976" y="410.4" textLength="12.2" 
clip-path="url(#terminal-560929528-line-16)">
+    <g class="terminal-3301982893-matrix">
+    <text class="terminal-3301982893-r1" x="0" y="20" textLength="561.2" 
clip-path="url(#terminal-3301982893-line-0)">Usage:&#160;airflowctl&#160;connections&#160;[-h]&#160;COMMAND&#160;...</text><text
 class="terminal-3301982893-r1" x="976" y="20" textLength="12.2" 
clip-path="url(#terminal-3301982893-line-0)">
+</text><text class="terminal-3301982893-r1" x="976" y="44.4" textLength="12.2" 
clip-path="url(#terminal-3301982893-line-1)">
+</text><text class="terminal-3301982893-r1" x="0" y="68.8" textLength="366" 
clip-path="url(#terminal-3301982893-line-2)">Perform&#160;Connections&#160;operations</text><text
 class="terminal-3301982893-r1" x="976" y="68.8" textLength="12.2" 
clip-path="url(#terminal-3301982893-line-2)">
+</text><text class="terminal-3301982893-r1" x="976" y="93.2" textLength="12.2" 
clip-path="url(#terminal-3301982893-line-3)">
+</text><text class="terminal-3301982893-r1" x="0" y="117.6" textLength="256.2" 
clip-path="url(#terminal-3301982893-line-4)">Positional&#160;Arguments:</text><text
 class="terminal-3301982893-r1" x="976" y="117.6" textLength="12.2" 
clip-path="url(#terminal-3301982893-line-4)">
+</text><text class="terminal-3301982893-r1" x="0" y="142" textLength="109.8" 
clip-path="url(#terminal-3301982893-line-5)">&#160;&#160;COMMAND</text><text 
class="terminal-3301982893-r1" x="976" y="142" textLength="12.2" 
clip-path="url(#terminal-3301982893-line-5)">
+</text><text class="terminal-3301982893-r1" x="0" y="166.4" textLength="524.6" 
clip-path="url(#terminal-3301982893-line-6)">&#160;&#160;&#160;&#160;create&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;Perform&#160;create&#160;operation</text><text
 class="terminal-3301982893-r1" x="976" y="166.4" textLength="12.2" 
clip-path="url(#terminal-3301982893-line-6)">
+</text><text class="terminal-3301982893-r1" x="0" y="190.8" textLength="231.8" 
clip-path="url(#terminal-3301982893-line-7)">&#160;&#160;&#160;&#160;create-defaults</text><text
 class="terminal-3301982893-r1" x="976" y="190.8" textLength="12.2" 
clip-path="url(#terminal-3301982893-line-7)">
+</text><text class="terminal-3301982893-r1" x="0" y="215.2" textLength="634.4" 
clip-path="url(#terminal-3301982893-line-8)">&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;Perform&#160;create_defaults&#160;operation</text><text
 class="terminal-3301982893-r1" x="976" y="215.2" textLength="12.2" 
clip-path="url(#terminal-3301982893-line-8)">
+</text><text class="terminal-3301982893-r1" x="0" y="239.6" textLength="524.6" 
clip-path="url(#terminal-3301982893-line-9)">&#160;&#160;&#160;&#160;delete&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;Perform&#160;delete&#160;operation</text><text
 class="terminal-3301982893-r1" x="976" y="239.6" textLength="12.2" 
clip-path="url(#terminal-3301982893-line-9)">
+</text><text class="terminal-3301982893-r1" x="0" y="264" textLength="500.2" 
clip-path="url(#terminal-3301982893-line-10)">&#160;&#160;&#160;&#160;export&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;Export&#160;all&#160;connections</text><text
 class="terminal-3301982893-r1" x="976" y="264" textLength="12.2" 
clip-path="url(#terminal-3301982893-line-10)">
+</text><text class="terminal-3301982893-r1" x="0" y="288.4" textLength="488" 
clip-path="url(#terminal-3301982893-line-11)">&#160;&#160;&#160;&#160;get&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;Perform&#160;get&#160;operation</text><text
 class="terminal-3301982893-r1" x="976" y="288.4" textLength="12.2" 
clip-path="url(#terminal-3301982893-line-11)">
+</text><text class="terminal-3301982893-r1" x="0" y="312.8" textLength="451.4" 
clip-path="url(#terminal-3301982893-line-12)">&#160;&#160;&#160;&#160;import&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;Import&#160;connections</text><text
 class="terminal-3301982893-r1" x="976" y="312.8" textLength="12.2" 
clip-path="url(#terminal-3301982893-line-12)">
+</text><text class="terminal-3301982893-r1" x="0" y="337.2" textLength="500.2" 
clip-path="url(#terminal-3301982893-line-13)">&#160;&#160;&#160;&#160;list&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;Perform&#160;list&#160;operation</text><text
 class="terminal-3301982893-r1" x="976" y="337.2" textLength="12.2" 
clip-path="url(#terminal-3301982893-line-13)">
+</text><text class="terminal-3301982893-r1" x="0" y="361.6" textLength="500.2" 
clip-path="url(#terminal-3301982893-line-14)">&#160;&#160;&#160;&#160;test&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;Perform&#160;test&#160;operation</text><text
 class="terminal-3301982893-r1" x="976" y="361.6" textLength="12.2" 
clip-path="url(#terminal-3301982893-line-14)">
+</text><text class="terminal-3301982893-r1" x="0" y="386" textLength="524.6" 
clip-path="url(#terminal-3301982893-line-15)">&#160;&#160;&#160;&#160;update&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;Perform&#160;update&#160;operation</text><text
 class="terminal-3301982893-r1" x="976" y="386" textLength="12.2" 
clip-path="url(#terminal-3301982893-line-15)">
+</text><text class="terminal-3301982893-r1" x="976" y="410.4" 
textLength="12.2" clip-path="url(#terminal-3301982893-line-16)">
+</text><text class="terminal-3301982893-r1" x="0" y="434.8" textLength="97.6" 
clip-path="url(#terminal-3301982893-line-17)">Options:</text><text 
class="terminal-3301982893-r1" x="976" y="434.8" textLength="12.2" 
clip-path="url(#terminal-3301982893-line-17)">
+</text><text class="terminal-3301982893-r1" x="0" y="459.2" textLength="610" 
clip-path="url(#terminal-3301982893-line-18)">&#160;&#160;-h,&#160;--help&#160;&#160;&#160;&#160;&#160;&#160;&#160;show&#160;this&#160;help&#160;message&#160;and&#160;exit</text><text
 class="terminal-3301982893-r1" x="976" y="459.2" textLength="12.2" 
clip-path="url(#terminal-3301982893-line-18)">
 </text>
     </g>
     </g>
diff --git a/airflow-ctl/src/airflowctl/ctl/cli_config.py 
b/airflow-ctl/src/airflowctl/ctl/cli_config.py
index 71bff72125b..2515099f126 100644
--- a/airflow-ctl/src/airflowctl/ctl/cli_config.py
+++ b/airflow-ctl/src/airflowctl/ctl/cli_config.py
@@ -164,6 +164,14 @@ class Password(argparse.Action):
         setattr(namespace, self.dest, values)
 
 
+# Common Positional Arguments
+ARG_FILE = Arg(
+    flags=("file",),
+    metavar="FILEPATH",
+    help="File path to read from or write to. "
+    "For import commands, it is a file to read from. For export commands, it 
is a file to write to.",
+)
+
 # Authentication arguments
 ARG_AUTH_URL = Arg(
     flags=("--api-url",),
@@ -198,6 +206,8 @@ ARG_AUTH_PASSWORD = Arg(
     action=Password,
     nargs="?",
 )
+
+# Variable Commands Args
 ARG_VARIABLE_IMPORT = Arg(
     flags=("file",),
     metavar="file",
@@ -662,6 +672,37 @@ AUTH_COMMANDS = (
     ),
 )
 
+CONFIG_COMMANDS = (
+    ActionCommand(
+        name="lint",
+        help="Lint options for the configuration changes while migrating from 
Airflow 2 to Airflow 3",
+        description="Lint options for the configuration changes while 
migrating from Airflow 2 to Airflow 3",
+        func=lazy_load_command("airflowctl.ctl.commands.config_command.lint"),
+        args=(
+            ARG_CONFIG_SECTION,
+            ARG_CONFIG_OPTION,
+            ARG_CONFIG_IGNORE_SECTION,
+            ARG_CONFIG_IGNORE_OPTION,
+            ARG_CONFIG_VERBOSE,
+        ),
+    ),
+)
+
+CONNECTION_COMMANDS = (
+    ActionCommand(
+        name="import",
+        help="Import connections",
+        
func=lazy_load_command("airflowctl.ctl.commands.connection_command.import_"),
+        args=(Arg(flags=("file",), metavar="FILEPATH", help="Connections JSON 
file"),),
+    ),
+    ActionCommand(
+        name="export",
+        help="Export all connections",
+        
func=lazy_load_command("airflowctl.ctl.commands.connection_command.export"),
+        args=(ARG_FILE,),
+    ),
+)
+
 POOL_COMMANDS = (
     ActionCommand(
         name="import",
@@ -680,22 +721,6 @@ POOL_COMMANDS = (
     ),
 )
 
-CONFIG_COMMANDS = (
-    ActionCommand(
-        name="lint",
-        help="Lint options for the configuration changes while migrating from 
Airflow 2 to Airflow 3",
-        description="Lint options for the configuration changes while 
migrating from Airflow 2 to Airflow 3",
-        func=lazy_load_command("airflowctl.ctl.commands.config_command.lint"),
-        args=(
-            ARG_CONFIG_SECTION,
-            ARG_CONFIG_OPTION,
-            ARG_CONFIG_IGNORE_SECTION,
-            ARG_CONFIG_IGNORE_OPTION,
-            ARG_CONFIG_VERBOSE,
-        ),
-    ),
-)
-
 VARIABLE_COMMANDS = (
     ActionCommand(
         name="import",
@@ -718,6 +743,16 @@ core_commands: list[CLICommand] = [
         "Either pass token from environment variable/parameter or pass 
username and password.",
         subcommands=AUTH_COMMANDS,
     ),
+    GroupCommand(
+        name="config",
+        help="View, lint and update configurations.",
+        subcommands=CONFIG_COMMANDS,
+    ),
+    GroupCommand(
+        name="connections",
+        help="Manage Airflow connections",
+        subcommands=CONNECTION_COMMANDS,
+    ),
     GroupCommand(
         name="pools",
         help="Manage Airflow pools",
@@ -735,11 +770,6 @@ core_commands: list[CLICommand] = [
         help="Manage Airflow variables",
         subcommands=VARIABLE_COMMANDS,
     ),
-    GroupCommand(
-        name="config",
-        help="View, lint and update configurations.",
-        subcommands=CONFIG_COMMANDS,
-    ),
 ]
 # Add generated group commands
 core_commands = merge_commands(
diff --git a/airflow-ctl/src/airflowctl/ctl/commands/connection_command.py 
b/airflow-ctl/src/airflowctl/ctl/commands/connection_command.py
new file mode 100644
index 00000000000..d659571e8d6
--- /dev/null
+++ b/airflow-ctl/src/airflowctl/ctl/commands/connection_command.py
@@ -0,0 +1,93 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import rich
+
+from airflowctl.api.client import NEW_API_CLIENT, ClientKind, 
provide_api_client
+from airflowctl.api.datamodels.generated import (
+    BulkActionOnExistence,
+    BulkBodyConnectionBody,
+    BulkCreateActionConnectionBody,
+    ConnectionBody,
+)
+
+
+@provide_api_client(kind=ClientKind.CLI)
+def import_(args, api_client=NEW_API_CLIENT) -> None:
+    """Import connections from file."""
+    filepath = Path(args.file)
+    current_path = Path.cwd()
+    filepath = current_path / filepath if not filepath.is_absolute() else 
filepath
+    if not filepath.exists():
+        raise SystemExit(f"Missing connections file {args.file}")
+
+    with open(filepath) as file:
+        try:
+            connections_json = json.loads(file.read())
+        except Exception as e:
+            raise SystemExit(f"Error reading connections file {args.file}: 
{e}")
+    try:
+        connections_data = {
+            k: ConnectionBody(
+                connection_id=k,
+                conn_type=v.get("conn_type"),
+                host=v.get("host"),
+                login=v.get("login"),
+                password=v.get("password"),
+                port=v.get("port"),
+                extra=v.get("extra", {}),
+                description=v.get("description", ""),
+            )
+            for k, v in connections_json.items()
+        }
+        connection_create_action = BulkCreateActionConnectionBody(
+            action="create",
+            entities=list(connections_data.values()),
+            action_on_existence=BulkActionOnExistence("fail"),
+        )
+        response = 
api_client.connections.bulk(BulkBodyConnectionBody(actions=[connection_create_action]))
+        if response.create.errors:
+            rich.print(f"[red]Failed to import connections: 
{response.create.errors}[/red]")
+            raise SystemExit
+        rich.print(f"[green]Successfully imported {response.create.success} 
connection(s)[/green]")
+    except Exception as e:
+        rich.print(f"[red]Failed to import connections: {e}[/red]")
+        raise SystemExit
+
+
+@provide_api_client(kind=ClientKind.CLI)
+def export(args, api_client=NEW_API_CLIENT) -> None:
+    """Export connections to a file."""
+    filepath = args.file
+    try:
+        connections = api_client.connections.list()
+        connection_dict = {}
+        for conn in connections.connections:
+            connection_dict[conn.connection_id] = conn.model_dump()
+        with open(Path(args.file), "w") as var_file:
+            json.dump(connection_dict, var_file, sort_keys=True, indent=4)
+        rich.print(
+            f"[green]Export successful! {connections.total_entries} 
connections(s) to {filepath}[/green]"
+        )
+    except Exception as e:
+        rich.print(f"[red]Failed to export connections: {e}[/red]")
+        raise SystemExit(1)
diff --git 
a/airflow-ctl/tests/airflow_ctl/ctl/commands/test_connections_command.py 
b/airflow-ctl/tests/airflow_ctl/ctl/commands/test_connections_command.py
new file mode 100644
index 00000000000..062e333a2e3
--- /dev/null
+++ b/airflow-ctl/tests/airflow_ctl/ctl/commands/test_connections_command.py
@@ -0,0 +1,160 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import json
+import os
+
+import pytest
+
+from airflowctl.api.client import ClientKind
+from airflowctl.api.datamodels.generated import (
+    BulkActionResponse,
+    BulkResponse,
+    ConnectionCollectionResponse,
+    ConnectionResponse,
+)
+from airflowctl.ctl import cli_parser
+from airflowctl.ctl.commands import connection_command
+
+
+class TestCliConnectionCommands:
+    connection_id = "test_connection"
+    export_file_name = "exported_json.json"
+    parser = cli_parser.get_parser()
+    connection_collection_response = ConnectionCollectionResponse(
+        connections=[
+            ConnectionResponse(
+                connection_id=connection_id,
+                conn_type="test_type",
+                host="test_host",
+                login="test_login",
+                password="test_password",
+                port=1234,
+                extra="{}",
+                description="Test connection description",
+            )
+        ],
+        total_entries=1,
+    )
+    bulk_response_success = BulkResponse(
+        create=BulkActionResponse(success=[connection_id], errors=[]), 
update=None, delete=None
+    )
+    bulk_response_error = BulkResponse(
+        create=BulkActionResponse(
+            success=[],
+            errors=[
+                {
+                    "error": f"The connection with these connection_ids: 
{{'{connection_id}'}} already exist.",
+                    "status_code": 409,
+                }
+            ],
+        ),
+        update=None,
+        delete=None,
+    )
+
+    def test_import_success(self, api_client_maker, tmp_path, monkeypatch):
+        api_client = api_client_maker(
+            path="/api/v2/connections",
+            response_json=self.bulk_response_success.model_dump(),
+            expected_http_status_code=200,
+            kind=ClientKind.CLI,
+        )
+
+        monkeypatch.chdir(tmp_path)
+        expected_json_path = tmp_path / self.export_file_name
+        connection_file = {
+            self.connection_id: {
+                "conn_type": "test_type",
+                "host": "test_host",
+                "login": "test_login",
+                "password": "test_password",
+                "port": 1234,
+                "extra": "{}",
+                "description": "Test connection description",
+                "connection_id": self.connection_id,
+            }
+        }
+
+        expected_json_path.write_text(json.dumps(connection_file))
+        connection_command.import_(
+            self.parser.parse_args(["connections", "import", 
expected_json_path.as_posix()]),
+            api_client=api_client,
+        )
+
+    def test_import_error(self, api_client_maker, tmp_path, monkeypatch):
+        api_client = api_client_maker(
+            path="/api/v2/connections",
+            response_json=self.bulk_response_error.model_dump(),
+            expected_http_status_code=200,
+            kind=ClientKind.CLI,
+        )
+
+        monkeypatch.chdir(tmp_path)
+        expected_json_path = tmp_path / self.export_file_name
+        connection_file = {
+            self.connection_id: {
+                "conn_type": "test_type",
+                "host": "test_host",
+                "login": "test_login",
+                "password": "test_password",
+                "port": 1234,
+                "extra": "{}",
+                "description": "Test connection description",
+                "connection_id": self.connection_id,
+            }
+        }
+
+        expected_json_path.write_text(json.dumps(connection_file))
+        with pytest.raises(SystemExit):
+            connection_command.import_(
+                self.parser.parse_args(["connections", "import", 
expected_json_path.as_posix()]),
+                api_client=api_client,
+            )
+
+    def test_export(self, api_client_maker, tmp_path, monkeypatch):
+        api_client = api_client_maker(
+            path="/api/v2/connections",
+            response_json=self.connection_collection_response.model_dump(),
+            expected_http_status_code=200,
+            kind=ClientKind.CLI,
+        )
+
+        monkeypatch.chdir(tmp_path)
+        expected_json_path = (tmp_path / self.export_file_name).as_posix()
+        connection_command.export(
+            self.parser.parse_args(["connections", "export", 
expected_json_path]),
+            api_client=api_client,
+        )
+        assert os.path.exists(tmp_path / self.export_file_name)
+
+        connection_file = {
+            self.connection_id: {
+                "conn_type": "test_type",
+                "host": "test_host",
+                "login": "test_login",
+                "password": "test_password",
+                "port": 1234,
+                "extra": "{}",
+                "description": "Test connection description",
+                "connection_id": self.connection_id,
+                "schema_": None,
+            }
+        }
+        with open(expected_json_path) as f:
+            assert json.load(f) == connection_file

Reply via email to