diff --git a/src/fastapi_cloud_cli/api/__init__.py b/src/fastapi_cloud_cli/api/__init__.py index 2f79c73..b84a325 100644 --- a/src/fastapi_cloud_cli/api/__init__.py +++ b/src/fastapi_cloud_cli/api/__init__.py @@ -16,5 +16,9 @@ from ._models import CustomDomainStatus as CustomDomainStatus from ._models import Deployment as Deployment from ._models import DeploymentStatus as DeploymentStatus +from ._models import EnvironmentVariable as EnvironmentVariable +from ._models import ( + EnvironmentVariableCreatePayload as EnvironmentVariableCreatePayload, +) from ._retry import STREAM_LOGS_MAX_RETRIES as STREAM_LOGS_MAX_RETRIES from .client import APIClient as APIClient diff --git a/src/fastapi_cloud_cli/api/_models.py b/src/fastapi_cloud_cli/api/_models.py index 3baa1c4..0fc87e6 100644 --- a/src/fastapi_cloud_cli/api/_models.py +++ b/src/fastapi_cloud_cli/api/_models.py @@ -4,6 +4,22 @@ from pydantic import BaseModel, Field, TypeAdapter +class EnvironmentVariable(BaseModel): + name: str + value: str | None = None + is_secret: bool = False + updated_at: str | None = None + + +class EnvironmentVariableResponse(BaseModel): + data: list[EnvironmentVariable] + + +class EnvironmentVariableCreatePayload(BaseModel): + value: str + is_secret: bool = False + + class AppLogEntry(BaseModel): timestamp: str message: str diff --git a/src/fastapi_cloud_cli/api/client.py b/src/fastapi_cloud_cli/api/client.py index e28a9d7..709f845 100644 --- a/src/fastapi_cloud_cli/api/client.py +++ b/src/fastapi_cloud_cli/api/client.py @@ -32,6 +32,8 @@ CustomDomainsAPIResponse, Deployment, DeploymentStatus, + EnvironmentVariableCreatePayload, + EnvironmentVariableResponse, ) from ._retry import ( STREAM_LOGS_MAX_RETRIES, @@ -142,6 +144,34 @@ def get_deployment(self, deployment_id: str) -> Deployment: response.raise_for_status() return Deployment.model_validate(response.json()) + def get_environment_variables(self, *, app_id: str) -> EnvironmentVariableResponse: + response = self.get(f"/apps/{app_id}/environment-variables/") + response.raise_for_status() + + return EnvironmentVariableResponse.model_validate(response.json()) + + def batch_environment_variables( + self, + *, + app_id: str, + upsert: dict[str, EnvironmentVariableCreatePayload], + delete: list[str], + redeploy: bool = True, + ) -> EnvironmentVariableResponse: + response = self.put( + f"/apps/{app_id}/environment-variables/", + json={ + "upsert": { + name: payload.model_dump() for name, payload in upsert.items() + }, + "delete": delete, + "redeploy": redeploy, + }, + ) + response.raise_for_status() + + return EnvironmentVariableResponse.model_validate(response.json()) + def get_custom_domains(self, *, app_id: str) -> CustomDomainsAPIResponse: response = self.get(f"/apps/{app_id}/custom-domains") response.raise_for_status() diff --git a/src/fastapi_cloud_cli/commands/env/_shared.py b/src/fastapi_cloud_cli/commands/env/_shared.py index 54d6874..06f526e 100644 --- a/src/fastapi_cloud_cli/commands/env/_shared.py +++ b/src/fastapi_cloud_cli/commands/env/_shared.py @@ -1,31 +1,10 @@ -from pydantic import BaseModel from rich.text import Text -from fastapi_cloud_cli.api import APIClient +from fastapi_cloud_cli.api import EnvironmentVariable ENV_VAR_VALUE_MAX_LENGTH = 40 -class EnvironmentVariable(BaseModel): - name: str - value: str | None = None - is_secret: bool = False - updated_at: str | None = None - - -class EnvironmentVariableResponse(BaseModel): - data: list[EnvironmentVariable] - - -def _get_environment_variables( - client: APIClient, app_id: str -) -> EnvironmentVariableResponse: - response = client.get(f"/apps/{app_id}/environment-variables/") - response.raise_for_status() - - return EnvironmentVariableResponse.model_validate(response.json()) - - def _find_environment_variable( environment_variables: list[EnvironmentVariable], name: str ) -> EnvironmentVariable | None: diff --git a/src/fastapi_cloud_cli/commands/env/delete.py b/src/fastapi_cloud_cli/commands/env/delete.py index c395ae7..c5fc58c 100644 --- a/src/fastapi_cloud_cli/commands/env/delete.py +++ b/src/fastapi_cloud_cli/commands/env/delete.py @@ -9,7 +9,6 @@ from fastapi_cloud_cli.api import APIClient from fastapi_cloud_cli.commands._auth import UserCommand, get_user_command_context from fastapi_cloud_cli.commands.env._app import env_app -from fastapi_cloud_cli.commands.env._shared import _get_environment_variables from fastapi_cloud_cli.utils.apps import resolve_app_id_or_fail from fastapi_cloud_cli.utils.env import validate_environment_variable_name from fastapi_cloud_cli.utils.execution import JsonOutputOption @@ -32,17 +31,6 @@ def _render_environment_variable_delete_output( toolkit.print(f"Environment variable [bold]{data.name}[/] deleted.", bullet=False) -def _delete_environment_variable(client: APIClient, app_id: str, name: str) -> bool: - response = client.delete(f"/apps/{app_id}/environment-variables/{name}") - - if response.status_code == 404: - return False - - response.raise_for_status() - - return True - - @env_app.command(cls=UserCommand) def delete( ctx: typer.Context, @@ -84,10 +72,19 @@ def delete( help="Confirm deletion without prompting.", ), ] = False, + no_redeploy: Annotated[ + bool, + typer.Option( + "--no-redeploy", + help="Delete the environment variable without redeploying the app.", + ), + ] = False, json_output: JsonOutputOption = False, ) -> Any: """ - Delete an environment variable from the app. + Delete an environment variable and redeploy the app by default. + + Succeeds even if the variable is already absent. """ toolkit = get_user_command_context(ctx).toolkit @@ -109,9 +106,9 @@ def delete( with toolkit.progress( "Fetching environment variables...", transient=True ) as progress: - with client.handle_http_errors(progress): - environment_variables = _get_environment_variables( - client=client, app_id=target_app_id + with client.handle_http_errors(progress, toolkit=toolkit): + environment_variables = client.get_environment_variables( + app_id=target_app_id ) toolkit.print_title("environment variables") @@ -163,23 +160,14 @@ def delete( with toolkit.progress( "Deleting environment variable", transient=True ) as progress: - with client.handle_http_errors(progress): - deleted = _delete_environment_variable( - client=client, app_id=target_app_id, name=name + with client.handle_http_errors(progress, toolkit=toolkit): + client.batch_environment_variables( + app_id=target_app_id, + upsert={}, + delete=[name], + redeploy=not no_redeploy, ) - if not deleted: - message = ( - f"Environment variable {name} not found." - if toolkit.mode == "json" - else "Environment variable not found." - ) - toolkit.fail( - "not_found", - message, - hint="Run `fastapi cloud env list` to see available variables.", - ) - toolkit.success( EnvironmentVariableDeleteOutput( app_id=target_app_id, name=name, show_tag=name_provided diff --git a/src/fastapi_cloud_cli/commands/env/get.py b/src/fastapi_cloud_cli/commands/env/get.py index aaa8adc..5970875 100644 --- a/src/fastapi_cloud_cli/commands/env/get.py +++ b/src/fastapi_cloud_cli/commands/env/get.py @@ -7,14 +7,12 @@ from rich_toolkit import RichToolkit from rich_toolkit.menu import Option -from fastapi_cloud_cli.api import APIClient +from fastapi_cloud_cli.api import APIClient, EnvironmentVariable from fastapi_cloud_cli.commands._auth import UserCommand, get_user_command_context from fastapi_cloud_cli.commands.env._app import env_app from fastapi_cloud_cli.commands.env._shared import ( - EnvironmentVariable, _find_environment_variable, _format_env_var_value, - _get_environment_variables, ) from fastapi_cloud_cli.utils.apps import resolve_app_id_or_fail from fastapi_cloud_cli.utils.execution import JsonOutputOption @@ -92,8 +90,8 @@ def get_variable( "Fetching environment variables...", transient=True ) as progress: with client.handle_http_errors(progress): - environment_variables = _get_environment_variables( - client=client, app_id=target_app_id + environment_variables = client.get_environment_variables( + app_id=target_app_id ) if name is None: diff --git a/src/fastapi_cloud_cli/commands/env/list.py b/src/fastapi_cloud_cli/commands/env/list.py index 5779b33..33f31e7 100644 --- a/src/fastapi_cloud_cli/commands/env/list.py +++ b/src/fastapi_cloud_cli/commands/env/list.py @@ -7,14 +7,12 @@ from rich.text import Text from rich_toolkit import RichToolkit -from fastapi_cloud_cli.api import APIClient +from fastapi_cloud_cli.api import APIClient, EnvironmentVariable from fastapi_cloud_cli.commands._auth import UserCommand, get_user_command_context from fastapi_cloud_cli.commands.env._app import env_app from fastapi_cloud_cli.commands.env._shared import ( ENV_VAR_VALUE_MAX_LENGTH, - EnvironmentVariable, _format_env_var_value, - _get_environment_variables, ) from fastapi_cloud_cli.utils.apps import resolve_app_id_or_fail from fastapi_cloud_cli.utils.dates import format_last_updated @@ -94,8 +92,8 @@ def list_variables( "Fetching environment variables...", transient=True ) as progress: with client.handle_http_errors(progress): - environment_variables = _get_environment_variables( - client=client, app_id=target_app_id + environment_variables = client.get_environment_variables( + app_id=target_app_id ) toolkit.success( diff --git a/src/fastapi_cloud_cli/commands/env/set.py b/src/fastapi_cloud_cli/commands/env/set.py index 3271b3a..7c30aca 100644 --- a/src/fastapi_cloud_cli/commands/env/set.py +++ b/src/fastapi_cloud_cli/commands/env/set.py @@ -6,7 +6,7 @@ from pydantic import BaseModel, Field from rich_toolkit import RichToolkit -from fastapi_cloud_cli.api import APIClient +from fastapi_cloud_cli.api import APIClient, EnvironmentVariableCreatePayload from fastapi_cloud_cli.commands._auth import UserCommand, get_user_command_context from fastapi_cloud_cli.commands.env._app import env_app from fastapi_cloud_cli.utils.apps import resolve_app_id_or_fail @@ -95,16 +95,6 @@ def _resolve_environment_variable_value( return _input(toolkit, "Enter the value of the environment variable:") -def _set_environment_variable( - client: APIClient, app_id: str, name: str, value: str, is_secret: bool = False -) -> None: - response = client.post( - f"/apps/{app_id}/environment-variables/", - json={"name": name, "value": value, "is_secret": is_secret}, - ) - response.raise_for_status() - - @env_app.command(cls=UserCommand) def set( ctx: typer.Context, @@ -153,13 +143,20 @@ def set( bool, typer.Option( "--secret", - help="Mark the environment variable as secret", + help="Mark a new environment variable as secret. Existing variables keep their secret status.", + ), + ] = False, + no_redeploy: Annotated[ + bool, + typer.Option( + "--no-redeploy", + help="Save the environment variable without redeploying the app.", ), ] = False, json_output: JsonOutputOption = False, ) -> Any: """ - Set an environment variable for the app. + Create or update an environment variable and redeploy the app by default. """ toolkit = get_user_command_context(ctx).toolkit @@ -190,20 +187,27 @@ def set( with toolkit.progress( "Setting environment variable", transient=True ) as progress: - with client.handle_http_errors(progress): - _set_environment_variable( - client=client, + with client.handle_http_errors(progress, toolkit=toolkit): + environment_variables = client.batch_environment_variables( app_id=target_app_id, - name=name, - value=value, - is_secret=secret, + upsert={ + name: EnvironmentVariableCreatePayload( + value=value, is_secret=secret + ) + }, + delete=[], + redeploy=not no_redeploy, ) + variable = next( + variable for variable in environment_variables.data if variable.name == name + ) + toolkit.success( EnvironmentVariableSetOutput( app_id=target_app_id, name=name, - is_secret=secret, + is_secret=variable.is_secret, show_tag=not prompts_user, ), render_output=_render_environment_variable_set_output, diff --git a/tests/test_env_delete.py b/tests/test_env_delete.py index 56e0b9f..3c08002 100644 --- a/tests/test_env_delete.py +++ b/tests/test_env_delete.py @@ -12,6 +12,52 @@ runner = SnapshotCliRunner() + +@pytest.mark.respx +def test_delete_succeeds_when_variable_is_already_absent( + logged_in_cli: None, respx_mock: respx.MockRouter +) -> None: + respx_mock.put( + "/apps/123/environment-variables/", + json={"upsert": {}, "delete": ["TOKEN"], "redeploy": True}, + ).mock(return_value=Response(200, json={"data": [], "count": 0})) + + for _ in range(2): + result = runner.invoke( + app, ["env", "delete", "TOKEN", "--app-id", "123", "--yes", "--json"] + ) + + assert result.exit_code == 0 + assert json.loads(result.stdout) == { + "data": {"app_id": "123", "name": "TOKEN", "deleted": True} + } + assert result.stderr == "" + + +@pytest.mark.respx +def test_delete_json_reports_managed_variable_error( + logged_in_cli: None, respx_mock: respx.MockRouter +) -> None: + message = ( + "Cannot modify integration-managed environment variables: 'DATABASE_URL'. " + "Disconnect the associated integration resources to remove these variables." + ) + respx_mock.put( + "/apps/123/environment-variables/", + json={"upsert": {}, "delete": ["DATABASE_URL"], "redeploy": True}, + ).mock(return_value=Response(400, json={"detail": message})) + + result = runner.invoke( + app, ["env", "delete", "DATABASE_URL", "--app-id", "123", "--yes", "--json"] + ) + + assert result.exit_code == 1 + assert json.loads(result.stdout) == { + "error": {"code": "invalid_input", "message": message, "hint": None} + } + assert result.stderr == "" + + assets_path = Path(__file__).parent / "assets" @@ -62,9 +108,10 @@ def test_delete_json_returns_missing_required_input_without_app_context( def test_shows_a_message_if_something_is_wrong( logged_in_cli: None, respx_mock: respx.MockRouter, configured_app: Path ) -> None: - respx_mock.delete("/apps/123/environment-variables/SOME_VAR").mock( - return_value=Response(500) - ) + respx_mock.put( + "/apps/123/environment-variables/", + json={"upsert": {}, "delete": ["SOME_VAR"], "redeploy": True}, + ).mock(return_value=Response(500)) with changing_dir(configured_app): result = runner.invoke(app, ["env", "delete", "SOME_VAR", "--yes"]) @@ -77,18 +124,19 @@ def test_shows_a_message_if_something_is_wrong( @pytest.mark.respx -def test_shows_message_if_not_found( +def test_shows_message_if_app_not_found( logged_in_cli: None, respx_mock: respx.MockRouter, configured_app: Path ) -> None: - respx_mock.delete("/apps/123/environment-variables/SOME_VAR").mock( - return_value=Response(404) - ) + respx_mock.put( + "/apps/123/environment-variables/", + json={"upsert": {}, "delete": ["SOME_VAR"], "redeploy": True}, + ).mock(return_value=Response(404, json={"detail": "App not found"})) with changing_dir(configured_app): result = runner.invoke(app, ["env", "delete", "SOME_VAR", "--yes"]) assert result.exit_code == 1 - assert "Environment variable not found" in result.output + assert "App not found" in result.output def test_shows_a_message_if_name_is_invalid( @@ -105,9 +153,10 @@ def test_shows_a_message_if_name_is_invalid( def test_shows_message_when_it_deletes( logged_in_cli: None, respx_mock: respx.MockRouter, configured_app: Path ) -> None: - respx_mock.delete("/apps/123/environment-variables/SOME_VAR").mock( - return_value=Response(204) - ) + respx_mock.put( + "/apps/123/environment-variables/", + json={"upsert": {}, "delete": ["SOME_VAR"], "redeploy": True}, + ).mock(return_value=Response(200, json={"data": [], "count": 0})) with ( changing_dir(configured_app), @@ -129,13 +178,15 @@ def test_shows_message_when_it_deletes( @pytest.mark.respx +@pytest.mark.parametrize("no_redeploy", [False, True]) def test_deletes_environment_variable_as_json_with_app_id( - logged_in_cli: None, respx_mock: respx.MockRouter + logged_in_cli: None, respx_mock: respx.MockRouter, no_redeploy: bool ) -> None: app_id = "00000000-0000-4000-8000-000000000002" - respx_mock.delete(f"/apps/{app_id}/environment-variables/DATABASE_URL").mock( - return_value=Response(204) - ) + respx_mock.put( + f"/apps/{app_id}/environment-variables/", + json={"upsert": {}, "delete": ["DATABASE_URL"], "redeploy": not no_redeploy}, + ).mock(return_value=Response(200, json={"data": [], "count": 0})) result = runner.invoke( app, @@ -147,6 +198,7 @@ def test_deletes_environment_variable_as_json_with_app_id( app_id, "--yes", "--json", + *(["--no-redeploy"] if no_redeploy else []), ], ) @@ -162,13 +214,14 @@ def test_deletes_environment_variable_as_json_with_app_id( @pytest.mark.respx -def test_delete_environment_variable_json_returns_not_found( +def test_delete_environment_variable_json_returns_app_not_found( logged_in_cli: None, respx_mock: respx.MockRouter ) -> None: app_id = "00000000-0000-4000-8000-000000000002" - respx_mock.delete(f"/apps/{app_id}/environment-variables/DATABASE_URL").mock( - return_value=Response(404) - ) + respx_mock.put( + f"/apps/{app_id}/environment-variables/", + json={"upsert": {}, "delete": ["DATABASE_URL"], "redeploy": True}, + ).mock(return_value=Response(404, json={"detail": "App not found"})) result = runner.invoke( app, @@ -187,8 +240,8 @@ def test_delete_environment_variable_json_returns_not_found( assert json.loads(result.stdout) == { "error": { "code": "not_found", - "message": "Environment variable DATABASE_URL not found.", - "hint": "Run `fastapi cloud env list` to see available variables.", + "message": "App not found", + "hint": None, } } assert result.stderr == "" @@ -211,9 +264,10 @@ def test_shows_selector_for_environment_variables( ) ) - respx_mock.delete("/apps/123/environment-variables/SECRET_KEY").mock( - return_value=Response(204) - ) + respx_mock.put( + "/apps/123/environment-variables/", + json={"upsert": {}, "delete": ["SECRET_KEY"], "redeploy": True}, + ).mock(return_value=Response(200, json={"data": [], "count": 0})) with ( changing_dir(configured_app), diff --git a/tests/test_env_set.py b/tests/test_env_set.py index 30603c8..ea5d4a2 100644 --- a/tests/test_env_set.py +++ b/tests/test_env_set.py @@ -15,6 +15,125 @@ assets_path = Path(__file__).parent / "assets" +@pytest.mark.respx +@pytest.mark.parametrize("is_secret", [False, True]) +def test_updates_existing_variable_using_returned_secret_status( + logged_in_cli: None, respx_mock: respx.MockRouter, is_secret: bool +) -> None: + respx_mock.put( + "/apps/123/environment-variables/", + json={ + "upsert": {"TOKEN": {"value": "updated", "is_secret": not is_secret}}, + "delete": [], + "redeploy": True, + }, + ).mock( + return_value=Response( + 200, + json={ + "data": [ + { + "name": "ANOTHER_VAR", + "value": "unchanged", + "is_secret": not is_secret, + }, + { + "name": "TOKEN", + "is_secret": is_secret, + "value": None if is_secret else "updated", + }, + ], + "count": 2, + }, + ) + ) + + result = runner.invoke( + app, + [ + "env", + "set", + "TOKEN", + "updated", + "--app-id", + "123", + "--json", + *([] if is_secret else ["--secret"]), + ], + ) + + assert result.exit_code == 0 + assert json.loads(result.stdout) == { + "data": {"app_id": "123", "name": "TOKEN", "is_secret": is_secret} + } + assert result.stderr == "" + + +@pytest.mark.respx +def test_updating_existing_secret_shows_secret_output( + logged_in_cli: None, respx_mock: respx.MockRouter +) -> None: + respx_mock.put( + "/apps/123/environment-variables/", + json={ + "upsert": {"TOKEN": {"value": "updated", "is_secret": False}}, + "delete": [], + "redeploy": True, + }, + ).mock( + return_value=Response( + 200, json={"data": [{"name": "TOKEN", "is_secret": True}], "count": 1} + ) + ) + + result = runner.invoke(app, ["env", "set", "TOKEN", "updated", "--app-id", "123"]) + + assert result.exit_code == 0 + assert result.output == snapshot("""\ +environment variables + +Secret environment variable TOKEN set.\ +""") + + +@pytest.mark.respx +def test_set_json_reports_managed_variable_error( + logged_in_cli: None, respx_mock: respx.MockRouter +) -> None: + message = ( + "Cannot modify integration-managed environment variables: 'DATABASE_URL'. " + "Disconnect the associated integration resources to remove these variables." + ) + respx_mock.put( + "/apps/123/environment-variables/", + json={ + "upsert": {"DATABASE_URL": {"value": "postgres://db", "is_secret": True}}, + "delete": [], + "redeploy": True, + }, + ).mock(return_value=Response(400, json={"detail": message})) + + result = runner.invoke( + app, + [ + "env", + "set", + "DATABASE_URL", + "postgres://db", + "--secret", + "--app-id", + "123", + "--json", + ], + ) + + assert result.exit_code == 1 + assert json.loads(result.stdout) == { + "error": {"code": "invalid_input", "message": message, "hint": None} + } + assert result.stderr == "" + + @pytest.fixture def configured_app(tmp_path: Path) -> Path: app_id = "123" @@ -46,9 +165,13 @@ def test_shows_a_message_if_app_is_not_configured(logged_in_cli: None) -> None: def test_shows_a_message_if_something_is_wrong( logged_in_cli: None, respx_mock: respx.MockRouter, configured_app: Path ) -> None: - respx_mock.post( + respx_mock.put( "/apps/123/environment-variables/", - json={"name": "SOME_VAR", "value": "secret", "is_secret": False}, + json={ + "upsert": {"SOME_VAR": {"value": "secret", "is_secret": False}}, + "delete": [], + "redeploy": True, + }, ).mock(return_value=Response(500)) with changing_dir(configured_app): @@ -65,10 +188,22 @@ def test_shows_a_message_if_something_is_wrong( def test_shows_message_when_it_sets( logged_in_cli: None, respx_mock: respx.MockRouter, configured_app: Path ) -> None: - respx_mock.post( + respx_mock.put( "/apps/123/environment-variables/", - json={"name": "SOME_VAR", "value": "secret", "is_secret": False}, - ).mock(return_value=Response(200)) + json={ + "upsert": {"SOME_VAR": {"value": "secret", "is_secret": False}}, + "delete": [], + "redeploy": True, + }, + ).mock( + return_value=Response( + 200, + json={ + "data": [{"name": "SOME_VAR", "value": "secret", "is_secret": False}], + "count": 1, + }, + ) + ) with changing_dir(configured_app): result = runner.invoke(app, ["env", "set", "SOME_VAR", "secret"]) @@ -87,10 +222,22 @@ def test_asks_for_name_and_value( ) -> None: steps = [*"API", Keys.ENTER, *"secret", Keys.ENTER] - respx_mock.post( + respx_mock.put( "/apps/123/environment-variables/", - json={"name": "API", "value": "secret", "is_secret": False}, - ).mock(return_value=Response(200)) + json={ + "upsert": {"API": {"value": "secret", "is_secret": False}}, + "delete": [], + "redeploy": True, + }, + ).mock( + return_value=Response( + 200, + json={ + "data": [{"name": "API", "value": "secret", "is_secret": False}], + "count": 1, + }, + ) + ) with ( changing_dir(configured_app), @@ -148,10 +295,22 @@ def test_asks_for_name_and_value_for_secret( ) -> None: steps = [*"API", Keys.ENTER, *"secret", Keys.ENTER] - respx_mock.post( + respx_mock.put( "/apps/123/environment-variables/", - json={"name": "API", "value": "secret", "is_secret": True}, - ).mock(return_value=Response(200)) + json={ + "upsert": {"API": {"value": "secret", "is_secret": True}}, + "delete": [], + "redeploy": True, + }, + ).mock( + return_value=Response( + 200, + json={ + "data": [{"name": "API", "value": None, "is_secret": True}], + "count": 1, + }, + ) + ) with ( changing_dir(configured_app), @@ -207,10 +366,22 @@ def test_asks_for_name_and_value_for_secret( def test_sets_secret_flag( logged_in_cli: None, respx_mock: respx.MockRouter, configured_app: Path ) -> None: - respx_mock.post( + respx_mock.put( "/apps/123/environment-variables/", - json={"name": "SOME_VAR", "value": "secret", "is_secret": True}, - ).mock(return_value=Response(200)) + json={ + "upsert": {"SOME_VAR": {"value": "secret", "is_secret": True}}, + "delete": [], + "redeploy": True, + }, + ).mock( + return_value=Response( + 200, + json={ + "data": [{"name": "SOME_VAR", "value": None, "is_secret": True}], + "count": 1, + }, + ) + ) with changing_dir(configured_app): result = runner.invoke(app, ["env", "set", "SOME_VAR", "secret", "--secret"]) @@ -224,18 +395,40 @@ def test_sets_secret_flag( @pytest.mark.respx +@pytest.mark.parametrize("no_redeploy", [False, True]) def test_sets_environment_variable_as_json( - logged_in_cli: None, respx_mock: respx.MockRouter + logged_in_cli: None, respx_mock: respx.MockRouter, no_redeploy: bool ) -> None: app_id = "00000000-0000-4000-8000-000000000002" - respx_mock.post( + respx_mock.put( f"/apps/{app_id}/environment-variables/", - json={"name": "LOG_LEVEL", "value": "info", "is_secret": False}, - ).mock(return_value=Response(201)) + json={ + "upsert": {"LOG_LEVEL": {"value": "info", "is_secret": False}}, + "delete": [], + "redeploy": not no_redeploy, + }, + ).mock( + return_value=Response( + 200, + json={ + "data": [{"name": "LOG_LEVEL", "value": "info", "is_secret": False}], + "count": 1, + }, + ) + ) result = runner.invoke( app, - ["env", "set", "LOG_LEVEL", "info", "--app-id", app_id, "--json"], + [ + "env", + "set", + "LOG_LEVEL", + "info", + "--app-id", + app_id, + "--json", + *(["--no-redeploy"] if no_redeploy else []), + ], ) assert result.exit_code == 0 @@ -254,10 +447,22 @@ def test_sets_secret_environment_variable_as_json( logged_in_cli: None, respx_mock: respx.MockRouter ) -> None: app_id = "00000000-0000-4000-8000-000000000002" - respx_mock.post( + respx_mock.put( f"/apps/{app_id}/environment-variables/", - json={"name": "DATABASE_URL", "value": "postgres://db", "is_secret": True}, - ).mock(return_value=Response(201)) + json={ + "upsert": {"DATABASE_URL": {"value": "postgres://db", "is_secret": True}}, + "delete": [], + "redeploy": True, + }, + ).mock( + return_value=Response( + 200, + json={ + "data": [{"name": "DATABASE_URL", "value": None, "is_secret": True}], + "count": 1, + }, + ) + ) result = runner.invoke( app, @@ -289,10 +494,22 @@ def test_sets_environment_variable_as_json_reads_value_stdin( logged_in_cli: None, respx_mock: respx.MockRouter ) -> None: app_id = "00000000-0000-4000-8000-000000000002" - respx_mock.post( + respx_mock.put( f"/apps/{app_id}/environment-variables/", - json={"name": "DATABASE_URL", "value": "postgres://db", "is_secret": True}, - ).mock(return_value=Response(201)) + json={ + "upsert": {"DATABASE_URL": {"value": "postgres://db", "is_secret": True}}, + "delete": [], + "redeploy": True, + }, + ).mock( + return_value=Response( + 200, + json={ + "data": [{"name": "DATABASE_URL", "value": None, "is_secret": True}], + "count": 1, + }, + ) + ) result = runner.invoke( app,