diff --git a/cmd/root.go b/cmd/root.go index 8108bb730..30f4ef49d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,30 +1,22 @@ package cmd import ( - "errors" "fmt" - "net/http" "os" - "strings" - "time" - - "github.com/astronomer/astro-cli/cmd/registry" - "github.com/sirupsen/logrus" airflowclient "github.com/astronomer/astro-cli/airflow-client" astrocore "github.com/astronomer/astro-cli/astro-client-core" astroiamcore "github.com/astronomer/astro-cli/astro-client-iam-core" astroplatformcore "github.com/astronomer/astro-cli/astro-client-platform-core" cloudCmd "github.com/astronomer/astro-cli/cmd/cloud" + "github.com/astronomer/astro-cli/cmd/registry" softwareCmd "github.com/astronomer/astro-cli/cmd/software" - "github.com/astronomer/astro-cli/config" + "github.com/astronomer/astro-cli/cmd/utils" "github.com/astronomer/astro-cli/context" "github.com/astronomer/astro-cli/houston" "github.com/astronomer/astro-cli/pkg/ansi" "github.com/astronomer/astro-cli/pkg/httputil" - "github.com/astronomer/astro-cli/version" - - "github.com/google/go-github/v48/github" + "github.com/sirupsen/logrus" "github.com/spf13/cobra" ) @@ -73,37 +65,10 @@ func NewRootCmd() *cobra.Command { \__\/\__\/ \_____\/ \__\/ \_\/ \_\/ \_____\/ \_____\/ \_____\/\________\/ Welcome to the Astro CLI, the modern command line interface for data orchestration. You can use it for Astro, Astronomer Software, or Local Development.`, - PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - // Check for latest version - if config.CFG.UpgradeMessage.GetBool() { - // create github client with 3 second timeout, setting an aggressive timeout since its not mandatory to get a response in each command execution - githubClient := github.NewClient(&http.Client{Timeout: 3 * time.Second}) - // compare current version to latest - err = version.CompareVersions(githubClient, "astronomer", "astro-cli") - if err != nil { - softwareCmd.InitDebugLogs = append(softwareCmd.InitDebugLogs, "Error comparing CLI versions: "+err.Error()) - } - } - if isCloudCtx { - err = cloudCmd.Setup(cmd, platformCoreClient, astroCoreClient) - if err != nil { - if strings.Contains(err.Error(), "token is invalid or malformed") { - return errors.New("API Token is invalid or malformed") //nolint - } - if strings.Contains(err.Error(), "the API token given has expired") { - return errors.New("API Token is expired") //nolint - } - softwareCmd.InitDebugLogs = append(softwareCmd.InitDebugLogs, "Error during cmd setup: "+err.Error()) - } - } - // common PersistentPreRunE component between software & cloud - // setting up log verbosity and dumping debug logs collected during CLI-initialization - if err := softwareCmd.SetUpLogs(os.Stdout, verboseLevel); err != nil { - return err - } - softwareCmd.PrintDebugLogs() - return nil - }, + PersistentPreRunE: utils.ChainRunEs( + SetupLoggingPersistentPreRunE, + CreateRootPersistentPreRunE(astroCoreClient, platformCoreClient), + ), } rootCmd.AddCommand( diff --git a/cmd/root_hooks.go b/cmd/root_hooks.go new file mode 100644 index 000000000..640ebc540 --- /dev/null +++ b/cmd/root_hooks.go @@ -0,0 +1,56 @@ +package cmd + +import ( + "errors" + "net/http" + "os" + "strings" + "time" + + astrocore "github.com/astronomer/astro-cli/astro-client-core" + astroplatformcore "github.com/astronomer/astro-cli/astro-client-platform-core" + cloudCmd "github.com/astronomer/astro-cli/cmd/cloud" + softwareCmd "github.com/astronomer/astro-cli/cmd/software" + "github.com/astronomer/astro-cli/config" + "github.com/astronomer/astro-cli/context" + "github.com/astronomer/astro-cli/version" + "github.com/google/go-github/v48/github" + "github.com/spf13/cobra" +) + +// SetupLoggingPersistentPreRunE is a pre-run hook shared between software & cloud +// setting up log verbosity. +func SetupLoggingPersistentPreRunE(_ *cobra.Command, _ []string) error { + return softwareCmd.SetUpLogs(os.Stdout, verboseLevel) +} + +// CreateRootPersistentPreRunE takes clients as arguments and returns a cobra +// pre-run hook that sets up the context and checks for the latest version. +func CreateRootPersistentPreRunE(astroCoreClient astrocore.CoreClient, platformCoreClient astroplatformcore.CoreClient) func(cmd *cobra.Command, args []string) error { + return func(cmd *cobra.Command, args []string) error { + // Check for latest version + if config.CFG.UpgradeMessage.GetBool() { + // create github client with 3 second timeout, setting an aggressive timeout since its not mandatory to get a response in each command execution + githubClient := github.NewClient(&http.Client{Timeout: 3 * time.Second}) + // compare current version to latest + err := version.CompareVersions(githubClient, "astronomer", "astro-cli") + if err != nil { + softwareCmd.InitDebugLogs = append(softwareCmd.InitDebugLogs, "Error comparing CLI versions: "+err.Error()) + } + } + if context.IsCloudContext() { + err := cloudCmd.Setup(cmd, platformCoreClient, astroCoreClient) + if err != nil { + if strings.Contains(err.Error(), "token is invalid or malformed") { + return errors.New("API Token is invalid or malformed") //nolint + } + if strings.Contains(err.Error(), "the API token given has expired") { + return errors.New("API Token is expired") //nolint + } + softwareCmd.InitDebugLogs = append(softwareCmd.InitDebugLogs, "Error during cmd setup: "+err.Error()) + } + } + softwareCmd.PrintDebugLogs() + return nil + } +} diff --git a/cmd/software/deploy.go b/cmd/software/deploy.go index e8af2fb41..7abbdaeb2 100644 --- a/cmd/software/deploy.go +++ b/cmd/software/deploy.go @@ -27,6 +27,7 @@ var ( isDagOnlyDeploy bool description string isImageOnlyDeploy bool + imageName string ErrBothDagsOnlyAndImageOnlySet = errors.New("cannot use both --dags and --image together. Run 'astro deploy' to update both your image and dags") ) @@ -61,6 +62,7 @@ func NewDeployCmd() *cobra.Command { cmd.Flags().StringVar(&workspaceID, "workspace-id", "", "workspace assigned to deployment") cmd.Flags().StringVar(&description, "description", "", "Improve traceability by attaching a description to a code deploy. If you don't provide a description, the system automatically assigns a default description based on the deploy type.") cmd.Flags().BoolVarP(&isImageOnlyDeploy, "image", "", false, "Push only an image to your Astro Deployment. This only works for Dag-only, Git-sync-based and NFS-based deployments.") + cmd.Flags().StringVarP(&imageName, "image-name", "i", "", "Name of the custom image(present locally) to deploy") if !context.IsCloudContext() && houston.VerifyVersionMatch(houstonVersion, houston.VersionRestrictions{GTE: "0.34.0"}) { cmd.Flags().BoolVarP(&isDagOnlyDeploy, "dags", "d", false, "Push only DAGs to your Deployment") @@ -120,7 +122,7 @@ func deployAirflow(cmd *cobra.Command, args []string) error { } // Since we prompt the user to enter the deploymentID in come cases for DeployAirflowImage, reusing the same deploymentID for DagsOnlyDeploy - deploymentID, err = DeployAirflowImage(houstonClient, config.WorkingPath, deploymentID, ws, byoRegistryDomain, ignoreCacheDeploy, byoRegistryEnabled, forcePrompt, description, isImageOnlyDeploy) + deploymentID, err = DeployAirflowImage(houstonClient, config.WorkingPath, deploymentID, ws, byoRegistryDomain, ignoreCacheDeploy, byoRegistryEnabled, forcePrompt, description, isImageOnlyDeploy, imageName) if err != nil { return err } diff --git a/cmd/software/deploy_test.go b/cmd/software/deploy_test.go index 816ba88ec..2ae169904 100644 --- a/cmd/software/deploy_test.go +++ b/cmd/software/deploy_test.go @@ -27,7 +27,7 @@ func (s *Suite) TestDeploy() { EnsureProjectDir = func(cmd *cobra.Command, args []string) error { return nil } - DeployAirflowImage = func(houstonClient houston.ClientInterface, path, deploymentID, wsID, byoRegistryDomain string, ignoreCacheDeploy, byoRegistryEnabled, prompt bool, description string, isImageOnlyDeploy bool) (string, error) { + DeployAirflowImage = func(houstonClient houston.ClientInterface, path, deploymentID, wsID, byoRegistryDomain string, ignoreCacheDeploy, byoRegistryEnabled, prompt bool, description string, isImageOnlyDeploy bool, imageName string) (string, error) { if description == "" { return deploymentID, fmt.Errorf("description should not be empty") } @@ -52,7 +52,7 @@ func (s *Suite) TestDeploy() { s.NoError(err) // Test when the default description is used - DeployAirflowImage = func(houstonClient houston.ClientInterface, path, deploymentID, wsID, byoRegistryDomain string, ignoreCacheDeploy, byoRegistryEnabled, prompt bool, description string, isImageOnlyDeploy bool) (string, error) { + DeployAirflowImage = func(houstonClient houston.ClientInterface, path, deploymentID, wsID, byoRegistryDomain string, ignoreCacheDeploy, byoRegistryEnabled, prompt bool, description string, isImageOnlyDeploy bool, imageName string) (string, error) { expectedDesc := "Deployed via " if description != expectedDesc { return deploymentID, fmt.Errorf("expected description to be '%s', but got '%s'", expectedDesc, description) @@ -67,14 +67,14 @@ func (s *Suite) TestDeploy() { DagsOnlyDeploy = deploy.DagsOnlyDeploy s.Run("error should be returned for astro deploy, if DeployAirflowImage throws error", func() { - DeployAirflowImage = func(houstonClient houston.ClientInterface, path, deploymentID, wsID, byoRegistryDomain string, ignoreCacheDeploy, byoRegistryEnabled, prompt bool, description string, isImageOnlyDeploy bool) (string, error) { + DeployAirflowImage = func(houstonClient houston.ClientInterface, path, deploymentID, wsID, byoRegistryDomain string, ignoreCacheDeploy, byoRegistryEnabled, prompt bool, description string, isImageOnlyDeploy bool, imageName string) (string, error) { return deploymentID, deploy.ErrNoWorkspaceID } err := execDeployCmd([]string{"-f"}...) s.ErrorIs(err, deploy.ErrNoWorkspaceID) - DeployAirflowImage = func(houstonClient houston.ClientInterface, path, deploymentID, wsID, byoRegistryDomain string, ignoreCacheDeploy, byoRegistryEnabled, prompt bool, description string, isImageOnlyDeploy bool) (string, error) { + DeployAirflowImage = func(houstonClient houston.ClientInterface, path, deploymentID, wsID, byoRegistryDomain string, ignoreCacheDeploy, byoRegistryEnabled, prompt bool, description string, isImageOnlyDeploy bool, imageName string) (string, error) { return deploymentID, nil } }) @@ -104,7 +104,7 @@ func (s *Suite) TestDeploy() { }) s.Run("Test for the flag --image for image deployment", func() { - DeployAirflowImage = func(houstonClient houston.ClientInterface, path, deploymentID, wsID, byoRegistryDomain string, ignoreCacheDeploy, byoRegistryEnabled, prompt bool, description string, isImageOnlyDeploy bool) (string, error) { + DeployAirflowImage = func(houstonClient houston.ClientInterface, path, deploymentID, wsID, byoRegistryDomain string, ignoreCacheDeploy, byoRegistryEnabled, prompt bool, description string, isImageOnlyDeploy bool, imageName string) (string, error) { return deploymentID, deploy.ErrDeploymentTypeIncorrectForImageOnly } err := execDeployCmd([]string{"test-deployment-id", "--image", "--force"}...) @@ -112,7 +112,7 @@ func (s *Suite) TestDeploy() { }) s.Run("Test for the flag --image for dags-only deployment", func() { - DeployAirflowImage = func(houstonClient houston.ClientInterface, path, deploymentID, wsID, byoRegistryDomain string, ignoreCacheDeploy, byoRegistryEnabled, prompt bool, description string, isImageOnlyDeploy bool) (string, error) { + DeployAirflowImage = func(houstonClient houston.ClientInterface, path, deploymentID, wsID, byoRegistryDomain string, ignoreCacheDeploy, byoRegistryEnabled, prompt bool, description string, isImageOnlyDeploy bool, imageName string) (string, error) { return deploymentID, nil } // This function is not called since --image is passed @@ -123,6 +123,22 @@ func (s *Suite) TestDeploy() { s.ErrorIs(err, nil) }) + s.Run("Test for the flag --image-name", func() { + var capturedImageName string + DeployAirflowImage = func(houstonClient houston.ClientInterface, path, deploymentID, wsID, byoRegistryDomain string, ignoreCacheDeploy, byoRegistryEnabled, prompt bool, description string, isImageOnlyDeploy bool, imageName string) (string, error) { + capturedImageName = imageName // Capture the imageName + return deploymentID, nil + } + DagsOnlyDeploy = func(houstonClient houston.ClientInterface, appConfig *houston.AppConfig, wsID, deploymentID, dagsParentPath string, dagDeployURL *string, cleanUpFiles bool, description string) error { + return nil + } + testImageName := "test-image-name" // Set the expected image name + err := execDeployCmd([]string{"test-deployment-id", "--image-name=" + testImageName, "--force", "--workspace-id=" + mockWorkspace.ID}...) + + s.ErrorIs(err, nil) + s.Equal(testImageName, capturedImageName, "The imageName passed to DeployAirflowImage is incorrect") + }) + s.Run("error should be returned if BYORegistryEnabled is true but BYORegistryDomain is empty", func() { appConfig = &houston.AppConfig{ BYORegistryDomain: "", diff --git a/cmd/utils/utils.go b/cmd/utils/utils.go index 44b4aa572..e07ad247c 100644 --- a/cmd/utils/utils.go +++ b/cmd/utils/utils.go @@ -7,6 +7,20 @@ import ( "github.com/spf13/cobra" ) +type RunE func(cmd *cobra.Command, args []string) error + +// ChainRunEs chains multiple RunE functions together for cleaner composition. +func ChainRunEs(runEs ...RunE) RunE { + return func(cmd *cobra.Command, args []string) error { + for _, runE := range runEs { + if err := runE(cmd, args); err != nil { + return err + } + } + return nil + } +} + func EnsureProjectDir(cmd *cobra.Command, args []string) error { isProjectDir, err := config.IsProjectDir(config.WorkingPath) if err != nil { diff --git a/cmd/utils/utils_test.go b/cmd/utils/utils_test.go index 073468fca..15efc0871 100644 --- a/cmd/utils/utils_test.go +++ b/cmd/utils/utils_test.go @@ -1,6 +1,7 @@ package utils import ( + "errors" "testing" "github.com/astronomer/astro-cli/config" @@ -46,3 +47,42 @@ func TestGetDefaultDeployDescription(t *testing.T) { descriptionWithDags := GetDefaultDeployDescription(true) assert.Equal(t, "Deployed via ", descriptionWithDags) } + +func TestChainRunEsExecutesAllFunctionsSuccessfully(t *testing.T) { + runE1 := func(cmd *cobra.Command, args []string) error { + return nil + } + runE2 := func(cmd *cobra.Command, args []string) error { + return nil + } + chain := ChainRunEs(runE1, runE2) + err := chain(&cobra.Command{}, []string{}) + assert.NoError(t, err) +} + +func TestChainRunEsReturnsErrorIfAnyFunctionFails(t *testing.T) { + runE1 := func(cmd *cobra.Command, args []string) error { + return nil + } + runE2 := func(cmd *cobra.Command, args []string) error { + return errors.New("error in runE2") + } + chain := ChainRunEs(runE1, runE2) + err := chain(&cobra.Command{}, []string{}) + assert.Error(t, err) + assert.Equal(t, "error in runE2", err.Error()) +} + +func TestChainRunEsStopsExecutionAfterError(t *testing.T) { + runE1 := func(cmd *cobra.Command, args []string) error { + return errors.New("error in runE1") + } + runE2 := func(cmd *cobra.Command, args []string) error { + t.FailNow() // This should not be called + return nil + } + chain := ChainRunEs(runE1, runE2) + err := chain(&cobra.Command{}, []string{}) + assert.Error(t, err) + assert.Equal(t, "error in runE1", err.Error()) +} diff --git a/integration-test/__init__.py b/integration-test/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/integration-test/dev_test.py b/integration-test/dev_test.py new file mode 100644 index 000000000..ca676193a --- /dev/null +++ b/integration-test/dev_test.py @@ -0,0 +1,268 @@ +import os +import time +import subprocess +import shutil +import tempfile +import docker +import pytest +import yaml +from datetime import datetime + +ASTRO = os.path.abspath("../astro") +AIRFLOW_COMPONENT = ["postgres", "webserver", "scheduler", "triggerer"] +VAR_KEY = "foo" +VAR_VALUE = "bar" + + +@pytest.fixture(scope="module") +def temp_dir(): + # Create a temporary directory + temp_dir = tempfile.mkdtemp() + yield temp_dir + + # Remove directory after tests + shutil.rmtree(temp_dir) + + +@pytest.fixture(scope="module") +def docker_client(): + # Initialize Docker client + return docker.from_env() + + +def get_container_status(components, client): + running_containers = client.containers.list() + client.containers + + container_status = {} + for container in running_containers: + for component in components: + if component in container.name: + container_status[container.name] = container.status + + for component in components: + if not any(component in name for name in container_status.keys()): + raise AssertionError( + f"No containers found for airflow component '{component}'" + ) + + return container_status + + +def get_container_start_time(container_name, client): + try: + container = client.containers.get(container_name) + start_time = container.attrs["State"]["StartedAt"] + if "." in start_time: + # Truncate to microseconds if nanoseconds are present + start_time = ( + start_time.split(".")[0] + "." + start_time.split(".")[1][:6] + "Z" + ) + return datetime.strptime(start_time, "%Y-%m-%dT%H:%M:%S.%fZ") + except docker.errors.NotFound: + pytest.fail(f"Container '{container_name}' not found.") + except Exception as e: + pytest.fail(f"Failed to get start time for '{container_name}': {e}") + + +def test_dev_init(temp_dir): + # Change to temp directory + os.chdir(temp_dir) + + # Run `astro dev init` command + result = subprocess.run( + [ASTRO, "dev", "init"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + assert result.returncode == 0 + + # Validate directories and files + expected_dirs = ["dags", "include", "plugins"] + for dir_name in expected_dirs: + assert os.path.isdir(os.path.join(temp_dir, dir_name)) + + expected_files = ["Dockerfile", "requirements.txt"] + for file_name in expected_files: + assert os.path.isfile(os.path.join(temp_dir, file_name)) + + +def test_dev_start(docker_client): + # Run `astro dev start` command + result = subprocess.run( + [ASTRO, "dev", "start"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + assert result.returncode == 0 + time.sleep(5) + + # Validate airflow containers are up and running + container_status = get_container_status(AIRFLOW_COMPONENT, docker_client) + for name, status in container_status.items(): + assert status == "running", f"Container '{name}' is not running as expected." + + +def test_dev_ps(): + # Run `astro dev ps` command + result = subprocess.run( + [ASTRO, "dev", "ps"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True + ) + + assert result.returncode == 0 + + # Validate airflow conrtainers are listed in output + output = result.stdout + for component in AIRFLOW_COMPONENT: + assert any( + component in line for line in output.splitlines() + ), f"Container '{component}' not listed in output." + + +def test_dev_logs(): + # Run `astro dev logs scheduler` command + result = subprocess.run( + [ASTRO, "dev", "logs", "scheduler"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=True, + ) + + output = result.stdout + assert result.returncode == 0 + # Validate that scheduler logs + assert "Starting the scheduler" in output + + +def test_dev_parse(): + # Run `astro dev parse` command + result = subprocess.run( + [ASTRO, "dev", "parse"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=True, + ) + + output = result.stdout + assert result.returncode == 0 + # Validate dag has been parsed successfully + assert "no errors detected in your DAGs" in output + + +def test_dev_run(): + # Run `astro dev run variables set foo bar` command + result = subprocess.run( + [ASTRO, "dev", "run", "variables", "set", VAR_KEY, VAR_VALUE], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=True, + ) + + output = result.stdout + assert result.returncode == 0 + # Validate that variable has been created + assert f"Variable {VAR_KEY} created" in output + + +def test_dev_restart(docker_client): + # Get initial container start times + pre_restart_times = {} + for component in AIRFLOW_COMPONENT: + matching_containers = [ + container.name + for container in docker_client.containers.list() + if component in container.name + ] + assert ( + matching_containers + ), f"No containers found for airflow component '{component}'." + pre_restart_times[component] = [ + get_container_start_time(name, docker_client) + for name in matching_containers + ] + + # Run the `astro dev restart` command + result = subprocess.run( + [ASTRO, "dev", "restart"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=True, + ) + assert result.returncode == 0 + + # Get post-restart container start times + post_restart_times = {} + for component in AIRFLOW_COMPONENT: + matching_containers = [ + container.name + for container in docker_client.containers.list() + if component in container.name + ] + assert ( + matching_containers + ), f"No containers found for airflow component '{component}'." + post_restart_times[component] = [ + get_container_start_time(name, docker_client) + for name in matching_containers + ] + + # Compare pre-restart and post-restart container start times + for component in AIRFLOW_COMPONENT: + for pre_time, post_time in zip( + pre_restart_times[component], post_restart_times[component] + ): + assert ( + post_time > pre_time + ), f"Container of airflow component '{component}' was not restarted." + + +def test_dev_export(): + # Run `astro dev object export -v` command + result = subprocess.run( + [ASTRO, "dev", "object", "export", "-v"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=True, + ) + + output = result.stdout + assert result.returncode == 0 + # Validate variables are exported and listed in airflow_setting.yaml file + assert "successfully exported variables" in output + + with open("airflow_settings.yaml", "r") as file: + data = yaml.safe_load(file) + af_data = data.get("airflow", {}) + variables = af_data.get("variables", []) + target_variable = next( + (item for item in variables if item.get("variable_name") == VAR_KEY), None + ) + assert ( + target_variable.get("variable_value") == VAR_VALUE + ), f"Expected value for `{VAR_KEY}' is '{VAR_VALUE}', but got '{target_variable.get('variable_value')}'." + + +def test_dev_kill(docker_client): + # Run `astro dev kill` command + result = subprocess.run( + [ASTRO, "dev", "kill"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + assert result.returncode == 0 + time.sleep(5) + + # Validate airflow containers are stopped + running_containers = docker_client.containers.list() + assert len(running_containers) == 0 diff --git a/integration-test/poetry.lock b/integration-test/poetry.lock new file mode 100644 index 000000000..560082a65 --- /dev/null +++ b/integration-test/poetry.lock @@ -0,0 +1,419 @@ +# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. + +[[package]] +name = "certifi" +version = "2024.8.30" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +files = [ + {file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"}, + {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.0" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-win32.whl", hash = "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-win32.whl", hash = "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-win32.whl", hash = "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:dbe03226baf438ac4fda9e2d0715022fd579cb641c4cf639fa40d53b2fe6f3e2"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd9a8bd8900e65504a305bf8ae6fa9fbc66de94178c420791d0293702fce2df7"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8831399554b92b72af5932cdbbd4ddc55c55f631bb13ff8fe4e6536a06c5c51"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a14969b8691f7998e74663b77b4c36c0337cb1df552da83d5c9004a93afdb574"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcaf7c1524c0542ee2fc82cc8ec337f7a9f7edee2532421ab200d2b920fc97cf"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:425c5f215d0eecee9a56cdb703203dda90423247421bf0d67125add85d0c4455"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:d5b054862739d276e09928de37c79ddeec42a6e1bfc55863be96a36ba22926f6"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:f3e73a4255342d4eb26ef6df01e3962e73aa29baa3124a8e824c5d3364a65748"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:2f6c34da58ea9c1a9515621f4d9ac379871a8f21168ba1b5e09d74250de5ad62"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:f09cb5a7bbe1ecae6e87901a2eb23e0256bb524a79ccc53eb0b7629fbe7677c4"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:0099d79bdfcf5c1f0c2c72f91516702ebf8b0b8ddd8905f97a8aecf49712c621"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-win32.whl", hash = "sha256:9c98230f5042f4945f957d006edccc2af1e03ed5e37ce7c373f00a5a4daa6149"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:62f60aebecfc7f4b82e3f639a7d1433a20ec32824db2199a11ad4f5e146ef5ee"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:af73657b7a68211996527dbfeffbb0864e043d270580c5aef06dc4b659a4b578"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cab5d0b79d987c67f3b9e9c53f54a61360422a5a0bc075f43cab5621d530c3b6"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9289fd5dddcf57bab41d044f1756550f9e7cf0c8e373b8cdf0ce8773dc4bd417"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b493a043635eb376e50eedf7818f2f322eabbaa974e948bd8bdd29eb7ef2a51"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fa2566ca27d67c86569e8c85297aaf413ffab85a8960500f12ea34ff98e4c41"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8e538f46104c815be19c975572d74afb53f29650ea2025bbfaef359d2de2f7f"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fd30dc99682dc2c603c2b315bded2799019cea829f8bf57dc6b61efde6611c8"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2006769bd1640bdf4d5641c69a3d63b71b81445473cac5ded39740a226fa88ab"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:dc15e99b2d8a656f8e666854404f1ba54765871104e50c8e9813af8a7db07f12"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:ab2e5bef076f5a235c3774b4f4028a680432cded7cad37bba0fd90d64b187d19"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:4ec9dd88a5b71abfc74e9df5ebe7921c35cbb3b641181a531ca65cdb5e8e4dea"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:43193c5cda5d612f247172016c4bb71251c784d7a4d9314677186a838ad34858"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:aa693779a8b50cd97570e5a0f343538a8dbd3e496fa5dcb87e29406ad0299654"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-win32.whl", hash = "sha256:7706f5850360ac01d80c89bcef1640683cc12ed87f42579dab6c5d3ed6888613"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:c3e446d253bd88f6377260d07c895816ebf33ffffd56c1c792b13bff9c3e1ade"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:980b4f289d1d90ca5efcf07958d3eb38ed9c0b7676bf2831a54d4f66f9c27dfa"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f28f891ccd15c514a0981f3b9db9aa23d62fe1a99997512b0491d2ed323d229a"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8aacce6e2e1edcb6ac625fb0f8c3a9570ccc7bfba1f63419b3769ccf6a00ed0"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd7af3717683bea4c87acd8c0d3d5b44d56120b26fd3f8a692bdd2d5260c620a"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ff2ed8194587faf56555927b3aa10e6fb69d931e33953943bc4f837dfee2242"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e91f541a85298cf35433bf66f3fab2a4a2cff05c127eeca4af174f6d497f0d4b"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309a7de0a0ff3040acaebb35ec45d18db4b28232f21998851cfa709eeff49d62"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:285e96d9d53422efc0d7a17c60e59f37fbf3dfa942073f666db4ac71e8d726d0"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5d447056e2ca60382d460a604b6302d8db69476fd2015c81e7c35417cfabe4cd"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:20587d20f557fe189b7947d8e7ec5afa110ccf72a3128d61a2a387c3313f46be"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:130272c698667a982a5d0e626851ceff662565379baf0ff2cc58067b81d4f11d"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ab22fbd9765e6954bc0bcff24c25ff71dcbfdb185fcdaca49e81bac68fe724d3"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7782afc9b6b42200f7362858f9e73b1f8316afb276d316336c0ec3bd73312742"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-win32.whl", hash = "sha256:2de62e8801ddfff069cd5c504ce3bc9672b23266597d4e4f50eda28846c322f2"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:95c3c157765b031331dd4db3c775e58deaee050a3042fcad72cbc4189d7c8dca"}, + {file = "charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079"}, + {file = "charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e"}, +] + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "docker" +version = "7.1.0" +description = "A Python library for the Docker Engine API." +optional = false +python-versions = ">=3.8" +files = [ + {file = "docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0"}, + {file = "docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c"}, +] + +[package.dependencies] +pywin32 = {version = ">=304", markers = "sys_platform == \"win32\""} +requests = ">=2.26.0" +urllib3 = ">=1.26.0" + +[package.extras] +dev = ["coverage (==7.2.7)", "pytest (==7.4.2)", "pytest-cov (==4.1.0)", "pytest-timeout (==2.1.0)", "ruff (==0.1.8)"] +docs = ["myst-parser (==0.18.0)", "sphinx (==5.1.1)"] +ssh = ["paramiko (>=2.4.3)"] +websockets = ["websocket-client (>=1.3.0)"] + +[[package]] +name = "exceptiongroup" +version = "1.2.2" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +files = [ + {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, + {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, +] + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "idna" +version = "3.10" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.6" +files = [ + {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, + {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, +] + +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + +[[package]] +name = "packaging" +version = "24.2" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, + {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "pytest" +version = "8.3.4" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6"}, + {file = "pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=1.5,<2" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pywin32" +version = "308" +description = "Python for Window Extensions" +optional = false +python-versions = "*" +files = [ + {file = "pywin32-308-cp310-cp310-win32.whl", hash = "sha256:796ff4426437896550d2981b9c2ac0ffd75238ad9ea2d3bfa67a1abd546d262e"}, + {file = "pywin32-308-cp310-cp310-win_amd64.whl", hash = "sha256:4fc888c59b3c0bef905ce7eb7e2106a07712015ea1c8234b703a088d46110e8e"}, + {file = "pywin32-308-cp310-cp310-win_arm64.whl", hash = "sha256:a5ab5381813b40f264fa3495b98af850098f814a25a63589a8e9eb12560f450c"}, + {file = "pywin32-308-cp311-cp311-win32.whl", hash = "sha256:5d8c8015b24a7d6855b1550d8e660d8daa09983c80e5daf89a273e5c6fb5095a"}, + {file = "pywin32-308-cp311-cp311-win_amd64.whl", hash = "sha256:575621b90f0dc2695fec346b2d6302faebd4f0f45c05ea29404cefe35d89442b"}, + {file = "pywin32-308-cp311-cp311-win_arm64.whl", hash = "sha256:100a5442b7332070983c4cd03f2e906a5648a5104b8a7f50175f7906efd16bb6"}, + {file = "pywin32-308-cp312-cp312-win32.whl", hash = "sha256:587f3e19696f4bf96fde9d8a57cec74a57021ad5f204c9e627e15c33ff568897"}, + {file = "pywin32-308-cp312-cp312-win_amd64.whl", hash = "sha256:00b3e11ef09ede56c6a43c71f2d31857cf7c54b0ab6e78ac659497abd2834f47"}, + {file = "pywin32-308-cp312-cp312-win_arm64.whl", hash = "sha256:9b4de86c8d909aed15b7011182c8cab38c8850de36e6afb1f0db22b8959e3091"}, + {file = "pywin32-308-cp313-cp313-win32.whl", hash = "sha256:1c44539a37a5b7b21d02ab34e6a4d314e0788f1690d65b48e9b0b89f31abbbed"}, + {file = "pywin32-308-cp313-cp313-win_amd64.whl", hash = "sha256:fd380990e792eaf6827fcb7e187b2b4b1cede0585e3d0c9e84201ec27b9905e4"}, + {file = "pywin32-308-cp313-cp313-win_arm64.whl", hash = "sha256:ef313c46d4c18dfb82a2431e3051ac8f112ccee1a34f29c263c583c568db63cd"}, + {file = "pywin32-308-cp37-cp37m-win32.whl", hash = "sha256:1f696ab352a2ddd63bd07430080dd598e6369152ea13a25ebcdd2f503a38f1ff"}, + {file = "pywin32-308-cp37-cp37m-win_amd64.whl", hash = "sha256:13dcb914ed4347019fbec6697a01a0aec61019c1046c2b905410d197856326a6"}, + {file = "pywin32-308-cp38-cp38-win32.whl", hash = "sha256:5794e764ebcabf4ff08c555b31bd348c9025929371763b2183172ff4708152f0"}, + {file = "pywin32-308-cp38-cp38-win_amd64.whl", hash = "sha256:3b92622e29d651c6b783e368ba7d6722b1634b8e70bd376fd7610fe1992e19de"}, + {file = "pywin32-308-cp39-cp39-win32.whl", hash = "sha256:7873ca4dc60ab3287919881a7d4f88baee4a6e639aa6962de25a98ba6b193341"}, + {file = "pywin32-308-cp39-cp39-win_amd64.whl", hash = "sha256:71b3322d949b4cc20776436a9c9ba0eeedcbc9c650daa536df63f0ff111bb920"}, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, + {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, + {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, + {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, + {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, + {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, + {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, + {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, + {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, + {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, + {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, + {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, + {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, + {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, + {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, + {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, + {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, +] + +[[package]] +name = "requests" +version = "2.32.3" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.8" +files = [ + {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, + {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "tomli" +version = "2.2.1" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.8" +files = [ + {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, + {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, + {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, + {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, + {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, + {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, + {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, + {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, + {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, + {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, +] + +[[package]] +name = "urllib3" +version = "2.2.3" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.8" +files = [ + {file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"}, + {file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"}, +] + +[package.extras] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + +[metadata] +lock-version = "2.0" +python-versions = "^3.10" +content-hash = "5a018c3e5191515b8e748910ec1d8ef785ae5eea69f2a296d3a2cfdb53e66795" diff --git a/integration-test/pyproject.toml b/integration-test/pyproject.toml new file mode 100644 index 000000000..37386c921 --- /dev/null +++ b/integration-test/pyproject.toml @@ -0,0 +1,18 @@ +[tool.poetry] +name = "integration-test" +version = "0.1.0" +description = "Astro CLI integration Tests" +authors = ["Your Name "] +readme = "README.md" +package-mode = false + +[tool.poetry.dependencies] +python = "^3.10" +pytest = "8.3.4" +docker = "7.1.0" +pyyaml = "6.0.2" + + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/software/deploy/deploy.go b/software/deploy/deploy.go index 01e185d87..845027571 100644 --- a/software/deploy/deploy.go +++ b/software/deploy/deploy.go @@ -30,8 +30,6 @@ var ( gzipFile = fileutil.GzipFile getDeploymentIDForCurrentCommandVar = getDeploymentIDForCurrentCommand - - deployLabels = []string{"io.astronomer.skip.revision=true"} ) var ( @@ -45,6 +43,8 @@ var ( ErrEmptyDagFolderUserCancelledOperation = errors.New("no DAGs found in the dags folder. User canceled the operation") ErrBYORegistryDomainNotSet = errors.New("Custom registry host is not set in config. It can be set at astronomer.houston.config.deployments.registry.protectedCustomRegistry.updateRegistry.host") //nolint ErrDeploymentTypeIncorrectForImageOnly = errors.New("--image only works for Dag-only, Git-sync-based and NFS-based deployments") + WarningInvalidImageNameMsg = "WARNING! The image in your Dockerfile '%s' is not based on Astro Runtime and is not supported. Change your Dockerfile with an image that pulls from 'quay.io/astronomer/astro-runtime' to proceed.\n" + ErrNoRuntimeLabelOnCustomImage = errors.New("the image should have label io.astronomer.docker.runtime.version") ) const ( @@ -58,9 +58,10 @@ const ( warningInvalidNameTag = "WARNING! You are about to push an image using the '%s' tag. This is not recommended.\nPlease use one of the following tags: %s.\nAre you sure you want to continue?" warningInvalidNameTagEmptyRecommendations = "WARNING! You are about to push an image using the '%s' tag. This is not recommended.\nAre you sure you want to continue?" - registryDomainPrefix = "registry." - runtimeImageLabel = "io.astronomer.docker.runtime.version" - airflowImageLabel = "io.astronomer.docker.airflow.version" + registryDomainPrefix = "registry." + runtimeImageLabel = "io.astronomer.docker.runtime.version" + airflowImageLabel = "io.astronomer.docker.airflow.version" + composeSkipImageBuildingPromptMsg = "Skipping building image since --image-name flag is used..." ) var tab = printutil.Table{ @@ -69,7 +70,7 @@ var tab = printutil.Table{ Header: []string{"#", "LABEL", "DEPLOYMENT NAME", "WORKSPACE", "DEPLOYMENT ID"}, } -func Airflow(houstonClient houston.ClientInterface, path, deploymentID, wsID, byoRegistryDomain string, ignoreCacheDeploy, byoRegistryEnabled, prompt bool, description string, isImageOnlyDeploy bool) (string, error) { +func Airflow(houstonClient houston.ClientInterface, path, deploymentID, wsID, byoRegistryDomain string, ignoreCacheDeploy, byoRegistryEnabled, prompt bool, description string, isImageOnlyDeploy bool, imageName string) (string, error) { deploymentID, deployments, err := getDeploymentIDForCurrentCommand(houstonClient, wsID, deploymentID, prompt) if err != nil { return deploymentID, err @@ -107,7 +108,7 @@ func Airflow(houstonClient houston.ClientInterface, path, deploymentID, wsID, by fmt.Printf(houstonDeploymentPrompt, releaseName) // Build the image to deploy - err = buildPushDockerImage(houstonClient, &c, deploymentInfo, releaseName, path, nextTag, cloudDomain, byoRegistryDomain, ignoreCacheDeploy, byoRegistryEnabled, description) + err = buildPushDockerImage(houstonClient, &c, deploymentInfo, releaseName, path, nextTag, cloudDomain, byoRegistryDomain, ignoreCacheDeploy, byoRegistryEnabled, description, imageName) if err != nil { return deploymentID, err } @@ -129,24 +130,7 @@ func deploymentExists(deploymentID string, deployments []houston.Deployment) boo return false } -func buildPushDockerImage(houstonClient houston.ClientInterface, c *config.Context, deploymentInfo *houston.Deployment, name, path, nextTag, cloudDomain, byoRegistryDomain string, ignoreCacheDeploy, byoRegistryEnabled bool, description string) error { - // Build our image - fmt.Println(imageBuildingPrompt) - - // parse dockerfile - cmds, err := docker.ParseFile(filepath.Join(path, dockerfile)) - if err != nil { - return fmt.Errorf("failed to parse dockerfile: %s: %w", filepath.Join(path, dockerfile), err) - } - - image, tag := docker.GetImageTagFromParsedFile(cmds) - if config.CFG.ShowWarnings.GetBool() && !validAirflowImageRepo(image) && !validRuntimeImageRepo(image) { - i, _ := input.Confirm(fmt.Sprintf(warningInvalidImageName, image)) - if !i { - fmt.Println("Canceling deploy...") - os.Exit(1) - } - } +func validateRuntimeVersion(houstonClient houston.ClientInterface, tag string, deploymentInfo *houston.Deployment) error { // Get valid image tags for platform using Deployment Info request deploymentConfig, err := houston.Call(houstonClient.GetDeploymentConfig)(nil) if err != nil { @@ -175,25 +159,10 @@ func buildPushDockerImage(houstonClient houston.ClientInterface, c *config.Conte os.Exit(1) } } - imageName := airflow.ImageName(name, "latest") - - imageHandler := imageHandlerInit(imageName) - - if description != "" { - deployLabels = append(deployLabels, "io.astronomer.deploy.revision.description="+description) - } - - buildConfig := types.ImageBuildConfig{ - Path: config.WorkingPath, - NoCache: ignoreCacheDeploy, - TargetPlatforms: deployImagePlatformSupport, - Labels: deployLabels, - } + return nil +} - err = imageHandler.Build("", "", buildConfig) - if err != nil { - return err - } +func pushDockerImage(byoRegistryEnabled bool, byoRegistryDomain, name, nextTag, cloudDomain string, imageHandler airflow.ImageHandler, houstonClient houston.ClientInterface, c *config.Context) error { var registry, remoteImage, token string if byoRegistryEnabled { registry = byoRegistryDomain @@ -203,7 +172,7 @@ func buildPushDockerImage(houstonClient houston.ClientInterface, c *config.Conte remoteImage = fmt.Sprintf("%s/%s", registry, airflow.ImageName(name, nextTag)) token = c.Token } - err = imageHandler.Push(remoteImage, "", token) + err := imageHandler.Push(remoteImage, "", token) if err != nil { return err } @@ -222,10 +191,83 @@ func buildPushDockerImage(houstonClient houston.ClientInterface, c *config.Conte _, err := houston.Call(houstonClient.UpdateDeploymentImage)(req) return err } - return nil } +func buildDockerImageForCustomImage(imageHandler airflow.ImageHandler, customImageName string, deploymentInfo *houston.Deployment, houstonClient houston.ClientInterface) error { + fmt.Println(composeSkipImageBuildingPromptMsg) + err := imageHandler.TagLocalImage(customImageName) + if err != nil { + return err + } + runtimeLabel, err := imageHandler.GetLabel("", airflow.RuntimeImageLabel) + if err != nil { + fmt.Println("unable get runtime version from image") + return err + } + if runtimeLabel == "" { + return ErrNoRuntimeLabelOnCustomImage + } + err = validateRuntimeVersion(houstonClient, runtimeLabel, deploymentInfo) + return err +} + +func buildDockerImageFromWorkingDir(path string, imageHandler airflow.ImageHandler, houstonClient houston.ClientInterface, deploymentInfo *houston.Deployment, ignoreCacheDeploy bool, description string) error { + // all these checks inside Dockerfile should happen only when no image-name is provided + // parse dockerfile + cmds, err := docker.ParseFile(filepath.Join(path, dockerfile)) + if err != nil { + return fmt.Errorf("failed to parse dockerfile: %s: %w", filepath.Join(path, dockerfile), err) + } + + image, tag := docker.GetImageTagFromParsedFile(cmds) + if config.CFG.ShowWarnings.GetBool() && !validAirflowImageRepo(image) && !validRuntimeImageRepo(image) { + i, _ := input.Confirm(fmt.Sprintf(warningInvalidImageName, image)) + if !i { + fmt.Println("Canceling deploy...") + os.Exit(1) + } + } + // Get valid image tags for platform using Deployment Info request + err = validateRuntimeVersion(houstonClient, tag, deploymentInfo) + if err != nil { + return err + } + // Build our image + fmt.Println(imageBuildingPrompt) + deployLabels := []string{"io.astronomer.skip.revision=true"} + if description != "" { + deployLabels = append(deployLabels, "io.astronomer.deploy.revision.description="+description) + } + buildConfig := types.ImageBuildConfig{ + Path: config.WorkingPath, + NoCache: ignoreCacheDeploy, + TargetPlatforms: deployImagePlatformSupport, + Output: true, + Labels: deployLabels, + } + + err = imageHandler.Build("", "", buildConfig) + return err +} + +func buildDockerImage(ignoreCacheDeploy bool, deploymentInfo *houston.Deployment, customImageName, path string, imageHandler airflow.ImageHandler, houstonClient houston.ClientInterface, description string) error { + if customImageName == "" { + return buildDockerImageFromWorkingDir(path, imageHandler, houstonClient, deploymentInfo, ignoreCacheDeploy, description) + } + return buildDockerImageForCustomImage(imageHandler, customImageName, deploymentInfo, houstonClient) +} + +func buildPushDockerImage(houstonClient houston.ClientInterface, c *config.Context, deploymentInfo *houston.Deployment, name, path, nextTag, cloudDomain, byoRegistryDomain string, ignoreCacheDeploy, byoRegistryEnabled bool, description, customImageName string) error { + imageName := airflow.ImageName(name, "latest") + imageHandler := imageHandlerInit(imageName) + err := buildDockerImage(ignoreCacheDeploy, deploymentInfo, customImageName, path, imageHandler, houstonClient, description) + if err != nil { + return err + } + return pushDockerImage(byoRegistryEnabled, byoRegistryDomain, name, nextTag, cloudDomain, imageHandler, houstonClient, c) +} + func validAirflowImageRepo(image string) bool { validDockerfileBaseImages := map[string]bool{ "quay.io/astronomer/ap-airflow": true, diff --git a/software/deploy/deploy_test.go b/software/deploy/deploy_test.go index 3f1777f2e..9bf286b92 100644 --- a/software/deploy/deploy_test.go +++ b/software/deploy/deploy_test.go @@ -65,6 +65,10 @@ var mockAirflowImageList = []houston.AirflowImage{ type Suite struct { suite.Suite + fsForDockerConfig afero.Fs + fsForLocalConfig afero.Fs + mockImageHandler *mocks.ImageHandler + houstonMock *houston_mocks.ClientInterface } func TestDeploy(t *testing.T) { @@ -99,6 +103,42 @@ func (s *Suite) TestValidRuntimeImageRepo() { s.False(validRuntimeImageRepo("personal-repo/ap-airflow")) } +func (s *Suite) SetupSuite() { + // Common setup logic for the test suite + s.fsForLocalConfig = afero.NewMemMapFs() + afero.WriteFile(s.fsForLocalConfig, config.HomeConfigFile, testUtil.NewTestConfig("localhost"), 0o777) + + s.fsForDockerConfig = afero.NewMemMapFs() + afero.WriteFile(s.fsForLocalConfig, config.HomeConfigFile, testUtil.NewTestConfig("docker"), 0o777) +} + +func (s *Suite) SetupTest() { + s.mockImageHandler = new(mocks.ImageHandler) + imageHandlerInit = func(image string) airflow.ImageHandler { + return s.mockImageHandler + } + s.houstonMock = new(houston_mocks.ClientInterface) +} + +func (s *Suite) TearDownSuite() { + // Cleanup logic, if any (e.g., clearing mocks) + s.mockImageHandler = nil + s.houstonMock = nil + s.fsForDockerConfig = nil + s.fsForLocalConfig = nil + imageHandlerInit = airflow.ImageHandlerInit +} + +func (s *Suite) TearDownSubTest() { + s.houstonMock.AssertExpectations(s.T()) + s.mockImageHandler.AssertExpectations(s.T()) +} + +func (s *Suite) TearDownTest() { + s.houstonMock.AssertExpectations(s.T()) + s.mockImageHandler.AssertExpectations(s.T()) +} + func (s *Suite) TestBuildPushDockerImageSuccessWithTagWarning() { fs := afero.NewMemMapFs() configYaml := testUtil.NewTestConfig("docker") @@ -124,7 +164,7 @@ func (s *Suite) TestBuildPushDockerImageSuccessWithTagWarning() { houstonMock.On("GetRuntimeReleases", "").Return(houston.RuntimeReleases{}, nil) houstonMock.On("GetDeploymentConfig", nil).Return(mockedDeploymentConfig, nil) - err := buildPushDockerImage(houstonMock, &config.Context{}, mockDeployment, "test", "./testfiles/", "test", "test", "", false, false, description) + err := buildPushDockerImage(houstonMock, &config.Context{}, mockDeployment, "test", "./testfiles/", "test", "test", "", false, false, description, "") s.NoError(err) mockImageHandler.AssertExpectations(s.T()) houstonMock.AssertExpectations(s.T()) @@ -155,7 +195,7 @@ func (s *Suite) TestBuildPushDockerImageSuccessWithImageRepoWarning() { houstonMock.On("GetDeploymentConfig", nil).Return(mockedDeploymentConfig, nil) houstonMock.On("GetRuntimeReleases", "").Return(houston.RuntimeReleases{}, nil) - err := buildPushDockerImage(houstonMock, &config.Context{}, mockDeployment, "test", "./testfiles/", "test", "test", "", false, false, description) + err := buildPushDockerImage(houstonMock, &config.Context{}, mockDeployment, "test", "./testfiles/", "test", "test", "", false, false, description, "") s.NoError(err) mockImageHandler.AssertExpectations(s.T()) houstonMock.AssertExpectations(s.T()) @@ -202,7 +242,7 @@ func (s *Suite) TestBuildPushDockerImageSuccessWithBYORegistry() { houstonMock.On("GetRuntimeReleases", "").Return(houston.RuntimeReleases{}, nil) houstonMock.On("UpdateDeploymentImage", houston.UpdateDeploymentImageRequest{ReleaseName: "test", Image: "test.registry.io:test-test", AirflowVersion: "1.10.12", RuntimeVersion: ""}).Return(nil, nil) - err := buildPushDockerImage(houstonMock, &config.Context{}, mockDeployment, "test", "./testfiles/", "test", "test", "test.registry.io", false, true, description) + err := buildPushDockerImage(houstonMock, &config.Context{}, mockDeployment, "test", "./testfiles/", "test", "test", "test.registry.io", false, true, description, "") s.NoError(err) expectedLabel := deployRevisionDescriptionLabel + "=" + description @@ -235,7 +275,7 @@ func (s *Suite) TestBuildPushDockerImageSuccessWithBYORegistry() { } config.CFG.ShaAsTag.SetHomeString("true") defer config.CFG.ShaAsTag.SetHomeString("false") - err = buildPushDockerImage(houstonMock, &config.Context{}, mockDeployment, "test", "./testfiles/", "test", "test", "test.registry.io", false, true, description) + err = buildPushDockerImage(houstonMock, &config.Context{}, mockDeployment, "test", "./testfiles/", "test", "test", "test.registry.io", false, true, description, "") s.NoError(err) expectedLabel = deployRevisionDescriptionLabel + "=" + description assert.Contains(s.T(), capturedBuildConfig.Labels, expectedLabel) @@ -246,7 +286,7 @@ func (s *Suite) TestBuildPushDockerImageSuccessWithBYORegistry() { func (s *Suite) TestBuildPushDockerImageFailure() { // invalid dockerfile test dockerfile = "Dockerfile.invalid" - err := buildPushDockerImage(nil, &config.Context{}, mockDeployment, "test", "./testfiles/", "test", "test", "", false, false, description) + err := buildPushDockerImage(nil, &config.Context{}, mockDeployment, "test", "./testfiles/", "test", "test", "", false, false, description, "") s.EqualError(err, "failed to parse dockerfile: testfiles/Dockerfile.invalid: when using JSON array syntax, arrays must be comprised of strings only") dockerfile = "Dockerfile" @@ -262,7 +302,7 @@ func (s *Suite) TestBuildPushDockerImageFailure() { houstonMock.On("GetDeploymentConfig", nil).Return(nil, errMockHouston).Once() houstonMock.On("GetRuntimeReleases", "").Return(houston.RuntimeReleases{}, nil) // houston GetDeploymentConfig call failure - err = buildPushDockerImage(houstonMock, &config.Context{}, mockDeployment, "test", "./testfiles/", "test", "test", "", false, false, description) + err = buildPushDockerImage(houstonMock, &config.Context{}, mockDeployment, "test", "./testfiles/", "test", "test", "", false, false, description, "") s.Error(err, errMockHouston) houstonMock.On("GetDeploymentConfig", nil).Return(mockedDeploymentConfig, nil).Twice() @@ -274,7 +314,7 @@ func (s *Suite) TestBuildPushDockerImageFailure() { } // build error test case - err = buildPushDockerImage(houstonMock, &config.Context{}, mockDeployment, "test", "./testfiles/", "test", "test", "", false, false, description) + err = buildPushDockerImage(houstonMock, &config.Context{}, mockDeployment, "test", "./testfiles/", "test", "test", "", false, false, description, "") s.Error(err, errSomeContainerIssue.Error()) mockImageHandler.AssertExpectations(s.T()) @@ -286,7 +326,7 @@ func (s *Suite) TestBuildPushDockerImageFailure() { } // push error test case - err = buildPushDockerImage(houstonMock, &config.Context{}, mockDeployment, "test", "./testfiles/", "test", "test", "", false, false, description) + err = buildPushDockerImage(houstonMock, &config.Context{}, mockDeployment, "test", "./testfiles/", "test", "test", "", false, false, description, "") s.Error(err, errSomeContainerIssue.Error()) mockImageHandler.AssertExpectations(s.T()) houstonMock.AssertExpectations(s.T()) @@ -323,14 +363,14 @@ func (s *Suite) TestGetAirflowUILinkFailure() { func (s *Suite) TestAirflowFailure() { // No workspace ID test case - _, err := Airflow(nil, "", "", "", "", false, false, false, description, false) + _, err := Airflow(nil, "", "", "", "", false, false, false, description, false, "") s.ErrorIs(err, ErrNoWorkspaceID) // houston GetWorkspace failure case houstonMock := new(houston_mocks.ClientInterface) houstonMock.On("GetWorkspace", mock.Anything).Return(nil, errMockHouston).Once() - _, err = Airflow(houstonMock, "", "", "test-workspace-id", "", false, false, false, description, false) + _, err = Airflow(houstonMock, "", "", "test-workspace-id", "", false, false, false, description, false, "") s.ErrorIs(err, errMockHouston) houstonMock.AssertExpectations(s.T()) @@ -338,7 +378,7 @@ func (s *Suite) TestAirflowFailure() { houstonMock.On("GetWorkspace", mock.Anything).Return(&houston.Workspace{}, nil) houstonMock.On("ListDeployments", mock.Anything).Return(nil, errMockHouston).Once() - _, err = Airflow(houstonMock, "", "", "test-workspace-id", "", false, false, false, description, false) + _, err = Airflow(houstonMock, "", "", "test-workspace-id", "", false, false, false, description, false, "") s.ErrorIs(err, errMockHouston) houstonMock.AssertExpectations(s.T()) @@ -352,35 +392,35 @@ func (s *Suite) TestAirflowFailure() { // config GetCurrentContext failure case config.ResetCurrentContext() - _, err = Airflow(houstonMock, "", "", "test-workspace-id", "", false, false, false, description, false) + _, err = Airflow(houstonMock, "", "", "test-workspace-id", "", false, false, false, description, false, "") s.EqualError(err, "no context set, have you authenticated to Astro or Astronomer Software? Run astro login and try again") context.Switch("localhost") // Invalid deployment name case - _, err = Airflow(houstonMock, "", "test-deployment-id", "test-workspace-id", "", false, false, false, description, false) + _, err = Airflow(houstonMock, "", "test-deployment-id", "test-workspace-id", "", false, false, false, description, false, "") s.ErrorIs(err, errInvalidDeploymentID) // No deployment in the current workspace case - _, err = Airflow(houstonMock, "", "", "test-workspace-id", "", false, false, false, description, false) + _, err = Airflow(houstonMock, "", "", "test-workspace-id", "", false, false, false, description, false, "") s.ErrorIs(err, errDeploymentNotFound) houstonMock.AssertExpectations(s.T()) // Invalid deployment selection case houstonMock.On("ListDeployments", mock.Anything).Return([]houston.Deployment{{ID: "test-deployment-id"}}, nil) - _, err = Airflow(houstonMock, "", "", "test-workspace-id", "", false, false, false, description, false) + _, err = Airflow(houstonMock, "", "", "test-workspace-id", "", false, false, false, description, false, "") s.ErrorIs(err, errInvalidDeploymentSelected) // return error When houston get deployment throws an error houstonMock.On("ListDeployments", mock.Anything).Return([]houston.Deployment{{ID: "test-deployment-id"}}, nil) houstonMock.On("GetDeployment", mock.Anything).Return(nil, errMockHouston).Once() - _, err = Airflow(houstonMock, "", "test-deployment-id", "test-workspace-id", "", false, false, false, description, false) + _, err = Airflow(houstonMock, "", "test-deployment-id", "test-workspace-id", "", false, false, false, description, false, "") s.Equal(err.Error(), "failed to get deployment info: "+errMockHouston.Error()) // buildPushDockerImage failure case houstonMock.On("GetDeployment", "test-deployment-id").Return(&houston.Deployment{}, nil) dockerfile = "Dockerfile.invalid" - _, err = Airflow(houstonMock, "./testfiles/", "test-deployment-id", "test-workspace-id", "", false, false, false, description, false) + _, err = Airflow(houstonMock, "./testfiles/", "test-deployment-id", "test-workspace-id", "", false, false, false, description, false, "") dockerfile = "Dockerfile" s.Error(err) s.Contains(err.Error(), "failed to parse dockerfile") @@ -413,7 +453,7 @@ func (s *Suite) TestAirflowSuccess() { houstonMock.On("GetDeployment", mock.Anything).Return(&houston.Deployment{}, nil).Once() houstonMock.On("GetRuntimeReleases", "").Return(mockRuntimeReleases, nil) - _, err := Airflow(houstonMock, "./testfiles/", "test-deployment-id", "test-workspace-id", "", false, false, false, description, false) + _, err := Airflow(houstonMock, "./testfiles/", "test-deployment-id", "test-workspace-id", "", false, false, false, description, false, "") s.NoError(err) houstonMock.AssertExpectations(s.T()) } @@ -452,26 +492,79 @@ func (s *Suite) TestAirflowSuccessForImageOnly() { houstonMock.On("GetDeployment", mock.Anything).Return(deployment, nil).Once() houstonMock.On("GetRuntimeReleases", "").Return(mockRuntimeReleases, nil) - _, err := Airflow(houstonMock, "./testfiles/", "test-deployment-id", "test-workspace-id", "", false, false, false, description, true) + _, err := Airflow(houstonMock, "./testfiles/", "test-deployment-id", "test-workspace-id", "", false, false, false, description, true, "") s.NoError(err) houstonMock.AssertExpectations(s.T()) + mockImageHandler.AssertExpectations(s.T()) } -func (s *Suite) TestAirflowFailureForImageOnly() { - fs := afero.NewMemMapFs() - configYaml := testUtil.NewTestConfig("localhost") - afero.WriteFile(fs, config.HomeConfigFile, configYaml, 0o777) - config.InitConfig(fs) +func (s *Suite) TestAirflowSuccessForImageName() { + config.InitConfig(s.fsForLocalConfig) + customImageName := "test-image-name" + imageHandlerInit = func(image string) airflow.ImageHandler { + s.mockImageHandler.On("Push", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) + s.mockImageHandler.On("TagLocalImage", customImageName).Return(nil) + s.mockImageHandler.On("GetLabel", "", airflow.RuntimeImageLabel).Return("test", nil) + return s.mockImageHandler + } - mockImageHandler := new(mocks.ImageHandler) + mockedDeploymentConfig := &houston.DeploymentConfig{ + AirflowImages: mockAirflowImageList, + } + mockRuntimeReleases := houston.RuntimeReleases{ + houston.RuntimeRelease{Version: "4.2.4", AirflowVersion: "2.2.5"}, + houston.RuntimeRelease{Version: "4.2.5", AirflowVersion: "2.2.5"}, + } + s.houstonMock.On("GetWorkspace", mock.Anything).Return(&houston.Workspace{}, nil).Once() + s.houstonMock.On("ListDeployments", mock.Anything).Return([]houston.Deployment{{ID: "test-deployment-id"}}, nil).Once() + s.houstonMock.On("GetDeploymentConfig", nil).Return(mockedDeploymentConfig, nil).Once() + dagDeployment := &houston.DagDeploymentConfig{ + Type: "dag-only", + } + deployment := &houston.Deployment{ + DagDeployment: *dagDeployment, + } + + s.houstonMock.On("GetDeployment", mock.Anything).Return(deployment, nil).Once() + s.houstonMock.On("GetRuntimeReleases", "").Return(mockRuntimeReleases, nil) + + _, err := Airflow(s.houstonMock, "./testfiles/", "test-deployment-id", "test-workspace-id", "", false, false, false, description, true, customImageName) + s.NoError(err) +} + +func (s *Suite) TestAirflowFailForImageNameWhenImageHasNoRuntimeLabel() { + config.InitConfig(s.fsForLocalConfig) + customImageName := "test-image-name" imageHandlerInit = func(image string) airflow.ImageHandler { - mockImageHandler.On("Build", mock.Anything, mock.Anything, mock.Anything).Return(nil) - mockImageHandler.On("Push", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) - return mockImageHandler + s.mockImageHandler.On("TagLocalImage", customImageName).Return(nil) + s.mockImageHandler.On("GetLabel", "", airflow.RuntimeImageLabel).Return("", nil) + return s.mockImageHandler } - houstonMock := new(houston_mocks.ClientInterface) - houstonMock.On("GetWorkspace", mock.Anything).Return(&houston.Workspace{}, nil).Once() - houstonMock.On("ListDeployments", mock.Anything).Return([]houston.Deployment{{ID: "test-deployment-id"}}, nil).Once() + + s.houstonMock.On("GetWorkspace", mock.Anything).Return(&houston.Workspace{}, nil).Once() + s.houstonMock.On("ListDeployments", mock.Anything).Return([]houston.Deployment{{ID: "test-deployment-id"}}, nil).Once() + dagDeployment := &houston.DagDeploymentConfig{ + Type: "dag-only", + } + deployment := &houston.Deployment{ + DagDeployment: *dagDeployment, + } + + s.houstonMock.On("GetDeployment", mock.Anything).Return(deployment, nil).Once() + + _, err := Airflow(s.houstonMock, "./testfiles/", "test-deployment-id", "test-workspace-id", "", false, false, false, description, true, customImageName) + s.Error(err, ErrNoRuntimeLabelOnCustomImage) +} + +func (s *Suite) TestAirflowFailureForImageOnly() { + config.InitConfig(s.fsForLocalConfig) + imageHandlerInit = func(image string) airflow.ImageHandler { + s.mockImageHandler.On("Build", mock.Anything, mock.Anything, mock.Anything).Return(nil) + s.mockImageHandler.On("Push", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) + return s.mockImageHandler + } + s.houstonMock.On("GetWorkspace", mock.Anything).Return(&houston.Workspace{}, nil).Once() + s.houstonMock.On("ListDeployments", mock.Anything).Return([]houston.Deployment{{ID: "test-deployment-id"}}, nil).Once() dagDeployment := &houston.DagDeploymentConfig{ Type: "image", } @@ -479,11 +572,10 @@ func (s *Suite) TestAirflowFailureForImageOnly() { DagDeployment: *dagDeployment, } - houstonMock.On("GetDeployment", mock.Anything).Return(deployment, nil).Once() + s.houstonMock.On("GetDeployment", mock.Anything).Return(deployment, nil).Once() - _, err := Airflow(houstonMock, "./testfiles/", "test-deployment-id", "test-workspace-id", "", false, false, false, description, true) + _, err := Airflow(s.houstonMock, "./testfiles/", "test-deployment-id", "test-workspace-id", "", false, false, false, description, true, "") s.Error(err, ErrDeploymentTypeIncorrectForImageOnly) - houstonMock.AssertExpectations(s.T()) } func (s *Suite) TestDeployDagsOnlyFailure() {