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: 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: 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: airflowctl connections [-h] COMMAND ...</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 Connections 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 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)">  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)">    create         Perform create 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)">    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)">                   Perform create_defaults 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)">    delete         Perform delete 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)">    get            Perform get 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)">    list           Perform list 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)">    test           Perform test 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)">    update         Perform update 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)">  -h, --help       show this help message and 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: airflowctl connections [-h] COMMAND ...</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 Connections 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 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)">  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)">    create         Perform create 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)">    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)">                   Perform create_defaults 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)">    delete         Perform delete 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)">    export         Export all 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)">    get            Perform get 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)">    import         Import 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)">    list           Perform list 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)">    test           Perform test 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)">    update         Perform update 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)">  -h, --help       show this help message and 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