diff --git a/backend/api/migrations/0120_add_queued_sync_status.py b/backend/api/migrations/0120_add_queued_sync_status.py new file mode 100644 index 000000000..d82ece1ad --- /dev/null +++ b/backend/api/migrations/0120_add_queued_sync_status.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.29 on 2026-03-25 11:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0119_secretevent_type_field'), + ] + + operations = [ + migrations.AlterField( + model_name='environmentsync', + name='status', + field=models.CharField(choices=[('queued', 'Queued'), ('in_progress', 'In progress'), ('completed', 'Completed'), ('cancelled', 'cancelled'), ('timed_out', 'Timed out'), ('failed', 'Failed')], default='queued', max_length=16), + ), + migrations.AlterField( + model_name='environmentsyncevent', + name='status', + field=models.CharField(choices=[('queued', 'Queued'), ('in_progress', 'In progress'), ('completed', 'Completed'), ('cancelled', 'cancelled'), ('timed_out', 'Timed out'), ('failed', 'Failed')], default='queued', max_length=16), + ), + ] diff --git a/backend/api/models.py b/backend/api/models.py index 9f99a19e3..57aa665ff 100644 --- a/backend/api/models.py +++ b/backend/api/models.py @@ -10,7 +10,7 @@ from django.utils import timezone from django.conf import settings from api.services import Providers, ServiceConfig -from api.tasks.syncing import trigger_sync_tasks +from api.tasks.syncing import trigger_sync_tasks, trigger_syncs_for_referencing_envs from backend.quotas import ( can_add_account, can_add_app, @@ -402,6 +402,10 @@ def save(self, *args, **kwargs): if env_sync.is_active ] + # Trigger syncs for other environments whose secrets + # reference this one (cross-env and cross-app references) + trigger_syncs_for_referencing_envs(self) + class EnvironmentKey(models.Model): id = models.TextField(default=uuid4, primary_key=True, editable=False) @@ -470,6 +474,7 @@ class ProviderCredentials(models.Model): class EnvironmentSync(models.Model): + QUEUED = "queued" IN_PROGRESS = "in_progress" COMPLETED = "completed" CANCELLED = "cancelled" @@ -477,6 +482,7 @@ class EnvironmentSync(models.Model): FAILED = "failed" STATUS_OPTIONS = [ + (QUEUED, "Queued"), (IN_PROGRESS, "In progress"), (COMPLETED, "Completed"), (CANCELLED, "cancelled"), @@ -501,7 +507,7 @@ class EnvironmentSync(models.Model): status = models.CharField( max_length=16, choices=STATUS_OPTIONS, - default=IN_PROGRESS, + default=QUEUED, ) @@ -512,7 +518,7 @@ class EnvironmentSyncEvent(models.Model): status = models.CharField( max_length=16, choices=EnvironmentSync.STATUS_OPTIONS, - default=EnvironmentSync.IN_PROGRESS, + default=EnvironmentSync.QUEUED, ) created_at = models.DateTimeField(auto_now_add=True, blank=True, null=True) completed_at = models.DateTimeField(blank=True, null=True) diff --git a/backend/api/tasks/syncing.py b/backend/api/tasks/syncing.py index 6338f69b0..27a48e427 100644 --- a/backend/api/tasks/syncing.py +++ b/backend/api/tasks/syncing.py @@ -58,114 +58,37 @@ def trigger_sync_tasks(env_sync): cancel_sync_tasks(env_sync) # cancel any running or queued jobs for this sync - if env_sync.service == ServiceConfig.CLOUDFLARE_PAGES["id"]: - env_sync.status = EnvironmentSync.IN_PROGRESS - env_sync.save() - - job = perform_cloudflare_pages_sync.delay(env_sync) - job_id = job.get_id() - - EnvironmentSyncEvent.objects.create(id=job_id, env_sync=env_sync) - - elif env_sync.service == ServiceConfig.CLOUDFLARE_WORKERS["id"]: - env_sync.status = EnvironmentSync.IN_PROGRESS - env_sync.save() - - job = perform_cloudflare_workers_sync.delay(env_sync) - job_id = job.get_id() - - EnvironmentSyncEvent.objects.create(id=job_id, env_sync=env_sync) - - elif env_sync.service == ServiceConfig.AWS_SECRETS_MANAGER["id"]: - env_sync.status = EnvironmentSync.IN_PROGRESS - env_sync.save() - - job = perform_aws_sm_sync.delay(env_sync) - job_id = job.get_id() - - EnvironmentSyncEvent.objects.create(id=job_id, env_sync=env_sync) - - elif env_sync.service == ServiceConfig.GITHUB_ACTIONS["id"]: - env_sync.status = EnvironmentSync.IN_PROGRESS - env_sync.save() - - job = perform_github_actions_sync.delay(env_sync) - job_id = job.get_id() - - EnvironmentSyncEvent.objects.create(id=job_id, env_sync=env_sync) - - elif env_sync.service == ServiceConfig.GITHUB_DEPENDABOT["id"]: - env_sync.status = EnvironmentSync.IN_PROGRESS - env_sync.save() - - job = perform_github_dependabot_sync.delay(env_sync) - job_id = job.get_id() - - EnvironmentSyncEvent.objects.create(id=job_id, env_sync=env_sync) - - elif env_sync.service == ServiceConfig.HASHICORP_VAULT["id"]: - env_sync.status = EnvironmentSync.IN_PROGRESS - env_sync.save() - - job = perform_vault_sync.delay(env_sync) - job_id = job.get_id() - - EnvironmentSyncEvent.objects.create(id=job_id, env_sync=env_sync) - - elif env_sync.service == ServiceConfig.HASHICORP_NOMAD["id"]: - env_sync.status = EnvironmentSync.IN_PROGRESS - env_sync.save() - - job = perform_nomad_sync.delay(env_sync) - job_id = job.get_id() - - EnvironmentSyncEvent.objects.create(id=job_id, env_sync=env_sync) - - elif env_sync.service == ServiceConfig.GITLAB_CI["id"]: - env_sync.status = EnvironmentSync.IN_PROGRESS - env_sync.save() - - job = perform_gitlab_sync.delay(env_sync) - job_id = job.get_id() - - EnvironmentSyncEvent.objects.create(id=job_id, env_sync=env_sync) - - elif env_sync.service == ServiceConfig.RAILWAY["id"]: - env_sync.status = EnvironmentSync.IN_PROGRESS - env_sync.save() - - job = perform_railway_sync.delay(env_sync) - job_id = job.get_id() - - EnvironmentSyncEvent.objects.create(id=job_id, env_sync=env_sync) - - elif env_sync.service == ServiceConfig.VERCEL["id"]: - env_sync.status = EnvironmentSync.IN_PROGRESS - env_sync.save() - - job = perform_vercel_sync.delay(env_sync) - job_id = job.get_id() - - EnvironmentSyncEvent.objects.create(id=job_id, env_sync=env_sync) - - elif env_sync.service == ServiceConfig.RENDER["id"]: - env_sync.status = EnvironmentSync.IN_PROGRESS - env_sync.save() + SERVICE_DISPATCH = { + ServiceConfig.CLOUDFLARE_PAGES["id"]: perform_cloudflare_pages_sync, + ServiceConfig.CLOUDFLARE_WORKERS["id"]: perform_cloudflare_workers_sync, + ServiceConfig.AWS_SECRETS_MANAGER["id"]: perform_aws_sm_sync, + ServiceConfig.GITHUB_ACTIONS["id"]: perform_github_actions_sync, + ServiceConfig.GITHUB_DEPENDABOT["id"]: perform_github_dependabot_sync, + ServiceConfig.HASHICORP_VAULT["id"]: perform_vault_sync, + ServiceConfig.HASHICORP_NOMAD["id"]: perform_nomad_sync, + ServiceConfig.GITLAB_CI["id"]: perform_gitlab_sync, + ServiceConfig.RAILWAY["id"]: perform_railway_sync, + ServiceConfig.VERCEL["id"]: perform_vercel_sync, + ServiceConfig.RENDER["id"]: perform_render_service_sync, + ServiceConfig.AZURE_KEY_VAULT["id"]: perform_azure_kv_sync, + } + + sync_func = SERVICE_DISPATCH.get(env_sync.service) + if sync_func is None: + return + + env_sync.status = EnvironmentSync.QUEUED + env_sync.save() - job = perform_render_service_sync.delay(env_sync) + try: + job = sync_func.delay(env_sync) job_id = job.get_id() - EnvironmentSyncEvent.objects.create(id=job_id, env_sync=env_sync) - - elif env_sync.service == ServiceConfig.AZURE_KEY_VAULT["id"]: - env_sync.status = EnvironmentSync.IN_PROGRESS + except Exception as e: + logger.error(f"Failed to dispatch sync job for {env_sync.id}: {e}") + env_sync.status = EnvironmentSync.FAILED env_sync.save() - job = perform_azure_kv_sync.delay(env_sync) - job_id = job.get_id() - - EnvironmentSyncEvent.objects.create(id=job_id, env_sync=env_sync) - # try and cancel running or queued jobs for this sync def cancel_sync_tasks(env_sync): @@ -175,7 +98,8 @@ def cancel_sync_tasks(env_sync): EnvironmentSyncEvent = apps.get_model("api", "EnvironmentSyncEvent") for sync_event in EnvironmentSyncEvent.objects.filter( - env_sync=env_sync, status=EnvironmentSync.IN_PROGRESS + env_sync=env_sync, + status__in=[EnvironmentSync.IN_PROGRESS, EnvironmentSync.QUEUED], ): try: job = Job.fetch(sync_event.id, connection=get_queue("default").connection) @@ -202,10 +126,14 @@ def handle_sync_event(environment_sync, sync_function, *args, **kwargs): .first() ) - try: - EnvironmentSync = apps.get_model("api", "EnvironmentSync") - EnvironmentSyncEvent = apps.get_model("api", "EnvironmentSyncEvent") + # Mark as in-progress now that the worker has picked up the job + environment_sync.status = EnvironmentSync.IN_PROGRESS + environment_sync.save() + if sync_event: + sync_event.status = EnvironmentSync.IN_PROGRESS + sync_event.save() + try: secrets = get_environment_secrets( environment_sync.environment, environment_sync.path ) @@ -572,3 +500,62 @@ def perform_azure_kv_sync(environment_sync): credentials.get("client_secret"), vault_uri, ) + + +def trigger_syncs_for_referencing_envs(changed_env): + """ + Finds environments with active syncs whose secrets reference the changed + environment, and triggers those syncs to keep referenced values up to date. + + Called synchronously from Environment.save() so that sync status is set to + IN_PROGRESS immediately (the actual sync work is dispatched async by + trigger_sync_tasks). This ensures the UI reflects the pending sync right away. + + This handles the case where env B has a secret like ${staging.DB_HOST} + referencing env "staging" — when a secret in "staging" changes, env B's + syncs need to be triggered too. + """ + from api.utils.secrets import env_has_references_to + + EnvironmentSync = apps.get_model("api", "EnvironmentSync") + + org = changed_env.app.organisation + changed_env_name = changed_env.name + changed_app_name = changed_env.app.name + changed_app_id = changed_env.app_id + + # Find all active syncs in the org, excluding the changed environment + candidate_syncs = EnvironmentSync.objects.filter( + environment__app__organisation=org, + is_active=True, + deleted_at=None, + ).exclude( + environment=changed_env + ).select_related("environment", "environment__app") + + # Group syncs by environment to avoid redundant reference checks + env_syncs_map = {} + for sync in candidate_syncs: + env_id = sync.environment_id + if env_id not in env_syncs_map: + env_syncs_map[env_id] = [] + env_syncs_map[env_id].append(sync) + + if not env_syncs_map: + return + + for env_id, syncs in env_syncs_map.items(): + try: + if env_has_references_to( + env_id, changed_env_name, changed_app_name, changed_app_id + ): + logger.info( + f"Environment {env_id} references changed environment " + f"{changed_env.id}, triggering syncs" + ) + for sync in syncs: + trigger_sync_tasks(sync) + except Exception as e: + logger.warning( + f"Failed to check references for environment {env_id}: {e}" + ) diff --git a/backend/api/utils/secrets.py b/backend/api/utils/secrets.py index 34947d4a5..bf435cb89 100644 --- a/backend/api/utils/secrets.py +++ b/backend/api/utils/secrets.py @@ -488,3 +488,76 @@ def decrypt_secret_value( raise SecretReferenceException("\n".join(unresolved_local_references)) return value + + +def env_has_references_to(source_env_id, target_env_name, target_app_name, target_app_id): + """ + Check if any secrets in the source environment contain references + to the target environment. + + Used to determine which syncs need to be triggered when a referenced + environment's secrets change. + + Args: + source_env_id: ID of the environment to check secrets in. + target_env_name: Name of the potentially-referenced environment. + target_app_name: Name of the app containing the target environment. + target_app_id: ID of the app containing the target environment. + + Returns: + bool: True if any secret in source_env references the target environment. + """ + Secret = apps.get_model("api", "Secret") + ServerEnvironmentKey = apps.get_model("api", "ServerEnvironmentKey") + Environment = apps.get_model("api", "Environment") + + try: + source_env = Environment.objects.select_related("app").get(id=source_env_id) + except Environment.DoesNotExist: + return False + + try: + server_env_key = ServerEnvironmentKey.objects.get( + environment_id=source_env_id + ) + except ServerEnvironmentKey.DoesNotExist: + return False + + pk, sk = get_server_keypair() + + try: + env_seed = decrypt_asymmetric(server_env_key.wrapped_seed, sk.hex(), pk.hex()) + env_pubkey, env_privkey = env_keypair(env_seed) + except Exception: + return False + + secrets = Secret.objects.filter( + environment_id=source_env_id, + deleted_at=None, + ) + + same_app = str(source_env.app_id) == str(target_app_id) + target_env_lower = target_env_name.lower() + target_app_lower = target_app_name.lower() + + for secret in secrets: + try: + value = decrypt_asymmetric(secret.value, env_privkey, env_pubkey) + + # Check cross-app references: ${APP::ENV.KEY} + for ref_app, ref_env, _ in CROSS_APP_ENV_PATTERN.findall(value): + if ( + ref_app.lower() == target_app_lower + and ref_env.lower() == target_env_lower + ): + return True + + # Check cross-env references: ${ENV.KEY} (only if same app) + if same_app: + for ref_env, _ in CROSS_ENV_PATTERN.findall(value): + if ref_env.lower() == target_env_lower: + return True + except Exception: + continue + + return False diff --git a/backend/tests/tasks/__init__.py b/backend/tests/tasks/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/tests/tasks/test_syncing.py b/backend/tests/tasks/test_syncing.py new file mode 100644 index 000000000..1d21d3079 --- /dev/null +++ b/backend/tests/tasks/test_syncing.py @@ -0,0 +1,156 @@ +import pytest +from unittest.mock import patch, MagicMock, call +from api.tasks.syncing import trigger_syncs_for_referencing_envs + + +@patch("api.tasks.syncing.trigger_sync_tasks") +@patch("api.tasks.syncing.apps.get_model") +def test_trigger_syncs_for_referencing_envs_with_references( + mock_get_model, mock_trigger_sync +): + """Test that syncs are triggered for environments whose secrets reference the changed env""" + changed_env = MagicMock() + changed_env.id = "changed-env-id" + changed_env.name = "staging" + changed_env.app.name = "my-app" + changed_env.app_id = "app-1" + changed_env.app.organisation = MagicMock() + + candidate_sync = MagicMock() + candidate_sync.environment_id = "candidate-env-id" + candidate_sync.environment = MagicMock() + + MockEnvironmentSync = MagicMock() + MockEnvironmentSync.objects.filter.return_value.exclude.return_value.select_related.return_value = [ + candidate_sync + ] + + def get_model_side_effect(app_label, model_name): + if model_name == "EnvironmentSync": + return MockEnvironmentSync + return MagicMock() + + mock_get_model.side_effect = get_model_side_effect + + with patch( + "api.utils.secrets.env_has_references_to", return_value=True + ) as mock_has_refs: + trigger_syncs_for_referencing_envs(changed_env) + + mock_has_refs.assert_called_once_with( + "candidate-env-id", "staging", "my-app", "app-1" + ) + + mock_trigger_sync.assert_called_once_with(candidate_sync) + + +@patch("api.tasks.syncing.trigger_sync_tasks") +@patch("api.tasks.syncing.apps.get_model") +def test_trigger_syncs_for_referencing_envs_no_references( + mock_get_model, mock_trigger_sync +): + """Test that syncs are NOT triggered when no references exist""" + changed_env = MagicMock() + changed_env.id = "changed-env-id" + changed_env.name = "staging" + changed_env.app.name = "my-app" + changed_env.app_id = "app-1" + changed_env.app.organisation = MagicMock() + + candidate_sync = MagicMock() + candidate_sync.environment_id = "candidate-env-id" + candidate_sync.environment = MagicMock() + + MockEnvironmentSync = MagicMock() + MockEnvironmentSync.objects.filter.return_value.exclude.return_value.select_related.return_value = [ + candidate_sync + ] + + def get_model_side_effect(app_label, model_name): + if model_name == "EnvironmentSync": + return MockEnvironmentSync + return MagicMock() + + mock_get_model.side_effect = get_model_side_effect + + with patch("api.utils.secrets.env_has_references_to", return_value=False): + trigger_syncs_for_referencing_envs(changed_env) + + mock_trigger_sync.assert_not_called() + + +@patch("api.tasks.syncing.trigger_sync_tasks") +@patch("api.tasks.syncing.apps.get_model") +def test_trigger_syncs_for_referencing_envs_no_candidate_syncs( + mock_get_model, mock_trigger_sync +): + """Test early return when no other active syncs exist in the org""" + changed_env = MagicMock() + changed_env.id = "changed-env-id" + changed_env.name = "staging" + changed_env.app.name = "my-app" + changed_env.app_id = "app-1" + changed_env.app.organisation = MagicMock() + + MockEnvironmentSync = MagicMock() + # Empty queryset — no candidate syncs + MockEnvironmentSync.objects.filter.return_value.exclude.return_value.select_related.return_value = ( + [] + ) + + def get_model_side_effect(app_label, model_name): + if model_name == "EnvironmentSync": + return MockEnvironmentSync + return MagicMock() + + mock_get_model.side_effect = get_model_side_effect + + trigger_syncs_for_referencing_envs(changed_env) + + mock_trigger_sync.assert_not_called() + + +@patch("api.tasks.syncing.trigger_sync_tasks") +@patch("api.tasks.syncing.apps.get_model") +def test_trigger_syncs_multiple_envs_only_matching_triggered( + mock_get_model, mock_trigger_sync +): + """Test that only syncs for environments with references are triggered""" + changed_env = MagicMock() + changed_env.id = "changed-env-id" + changed_env.name = "staging" + changed_env.app.name = "my-app" + changed_env.app_id = "app-1" + changed_env.app.organisation = MagicMock() + + # Two candidate syncs on different environments + sync_with_ref = MagicMock() + sync_with_ref.environment_id = "env-with-ref" + sync_with_ref.environment = MagicMock() + + sync_without_ref = MagicMock() + sync_without_ref.environment_id = "env-without-ref" + sync_without_ref.environment = MagicMock() + + MockEnvironmentSync = MagicMock() + MockEnvironmentSync.objects.filter.return_value.exclude.return_value.select_related.return_value = [ + sync_with_ref, + sync_without_ref, + ] + + def get_model_side_effect(app_label, model_name): + if model_name == "EnvironmentSync": + return MockEnvironmentSync + return MagicMock() + + mock_get_model.side_effect = get_model_side_effect + + def has_refs_side_effect(source_env_id, *args): + return source_env_id == "env-with-ref" + + with patch( + "api.utils.secrets.env_has_references_to", side_effect=has_refs_side_effect + ): + trigger_syncs_for_referencing_envs(changed_env) + + mock_trigger_sync.assert_called_once_with(sync_with_ref) diff --git a/backend/tests/utils/test_secret.py b/backend/tests/utils/test_secret.py index 15acc3d33..4a232d2be 100644 --- a/backend/tests/utils/test_secret.py +++ b/backend/tests/utils/test_secret.py @@ -8,6 +8,7 @@ normalize_path_string, decompose_path_and_key, decrypt_secret_value, + env_has_references_to, ) @@ -316,3 +317,280 @@ def test_decrypt_secret_value_ignores_railway_syntax( result = decrypt_secret_value(mock_secret) assert result == "Some value with ${{RAILWAY_REF}}" + + +# --- env_has_references_to tests --- + + +@patch("api.utils.secrets.apps.get_model") +@patch("api.utils.secrets.decrypt_asymmetric") +@patch("api.utils.secrets.env_keypair") +@patch("api.utils.secrets.get_server_keypair") +def test_env_has_references_to_cross_env( + mock_server_kp, mock_env_kp, mock_decrypt, mock_get_model +): + """Test detecting ${ENV.KEY} cross-env reference""" + mock_server_kp.return_value = (b"pk", b"sk") + mock_env_kp.return_value = (b"env_pub", b"env_priv") + + # Mock models + mock_env = MagicMock() + mock_env.app_id = "app-1" + + mock_server_env_key = MagicMock() + mock_server_env_key.wrapped_seed = "wrapped_seed" + + MockEnvironment = MagicMock() + MockEnvironment.objects.select_related.return_value.get.return_value = mock_env + MockEnvironment.DoesNotExist = Exception + + MockServerEnvKey = MagicMock() + MockServerEnvKey.objects.get.return_value = mock_server_env_key + MockServerEnvKey.DoesNotExist = Exception + + mock_secret = MagicMock() + mock_secret.value = "encrypted" + + MockSecret = MagicMock() + MockSecret.objects.filter.return_value = [mock_secret] + + def get_model_side_effect(app_label, model_name): + if model_name == "Secret": + return MockSecret + if model_name == "ServerEnvironmentKey": + return MockServerEnvKey + if model_name == "Environment": + return MockEnvironment + return MagicMock() + + mock_get_model.side_effect = get_model_side_effect + + # decrypt_asymmetric: first call for seed, second for secret value + mock_decrypt.side_effect = ["env_seed", "url=${staging.DB_HOST}"] + + result = env_has_references_to("env-1", "staging", "my-app", "app-1") + assert result is True + + +@patch("api.utils.secrets.apps.get_model") +@patch("api.utils.secrets.decrypt_asymmetric") +@patch("api.utils.secrets.env_keypair") +@patch("api.utils.secrets.get_server_keypair") +def test_env_has_references_to_cross_app( + mock_server_kp, mock_env_kp, mock_decrypt, mock_get_model +): + """Test detecting ${APP::ENV.KEY} cross-app reference""" + mock_server_kp.return_value = (b"pk", b"sk") + mock_env_kp.return_value = (b"env_pub", b"env_priv") + + mock_env = MagicMock() + mock_env.app_id = "app-2" # Different app + + mock_server_env_key = MagicMock() + mock_server_env_key.wrapped_seed = "wrapped_seed" + + MockEnvironment = MagicMock() + MockEnvironment.objects.select_related.return_value.get.return_value = mock_env + MockEnvironment.DoesNotExist = Exception + + MockServerEnvKey = MagicMock() + MockServerEnvKey.objects.get.return_value = mock_server_env_key + MockServerEnvKey.DoesNotExist = Exception + + mock_secret = MagicMock() + mock_secret.value = "encrypted" + + MockSecret = MagicMock() + MockSecret.objects.filter.return_value = [mock_secret] + + def get_model_side_effect(app_label, model_name): + if model_name == "Secret": + return MockSecret + if model_name == "ServerEnvironmentKey": + return MockServerEnvKey + if model_name == "Environment": + return MockEnvironment + return MagicMock() + + mock_get_model.side_effect = get_model_side_effect + + mock_decrypt.side_effect = ["env_seed", "url=${backend::production.API_KEY}"] + + result = env_has_references_to("env-2", "production", "backend", "app-1") + assert result is True + + +@patch("api.utils.secrets.apps.get_model") +@patch("api.utils.secrets.decrypt_asymmetric") +@patch("api.utils.secrets.env_keypair") +@patch("api.utils.secrets.get_server_keypair") +def test_env_has_references_to_no_match( + mock_server_kp, mock_env_kp, mock_decrypt, mock_get_model +): + """Test that no reference is detected when values don't reference the target""" + mock_server_kp.return_value = (b"pk", b"sk") + mock_env_kp.return_value = (b"env_pub", b"env_priv") + + mock_env = MagicMock() + mock_env.app_id = "app-1" + + mock_server_env_key = MagicMock() + mock_server_env_key.wrapped_seed = "wrapped_seed" + + MockEnvironment = MagicMock() + MockEnvironment.objects.select_related.return_value.get.return_value = mock_env + MockEnvironment.DoesNotExist = Exception + + MockServerEnvKey = MagicMock() + MockServerEnvKey.objects.get.return_value = mock_server_env_key + MockServerEnvKey.DoesNotExist = Exception + + mock_secret = MagicMock() + mock_secret.value = "encrypted" + + MockSecret = MagicMock() + MockSecret.objects.filter.return_value = [mock_secret] + + def get_model_side_effect(app_label, model_name): + if model_name == "Secret": + return MockSecret + if model_name == "ServerEnvironmentKey": + return MockServerEnvKey + if model_name == "Environment": + return MockEnvironment + return MagicMock() + + mock_get_model.side_effect = get_model_side_effect + + mock_decrypt.side_effect = ["env_seed", "just a plain value"] + + result = env_has_references_to("env-1", "staging", "my-app", "app-1") + assert result is False + + +@patch("api.utils.secrets.apps.get_model") +@patch("api.utils.secrets.decrypt_asymmetric") +@patch("api.utils.secrets.env_keypair") +@patch("api.utils.secrets.get_server_keypair") +def test_env_has_references_to_no_sse( + mock_server_kp, mock_env_kp, mock_decrypt, mock_get_model +): + """Test that False is returned when source env has no ServerEnvironmentKey (no SSE)""" + mock_server_kp.return_value = (b"pk", b"sk") + + mock_env = MagicMock() + MockEnvironment = MagicMock() + MockEnvironment.objects.select_related.return_value.get.return_value = mock_env + MockEnvironment.DoesNotExist = Exception + + MockServerEnvKey = MagicMock() + MockServerEnvKey.DoesNotExist = type("DoesNotExist", (Exception,), {}) + MockServerEnvKey.objects.get.side_effect = MockServerEnvKey.DoesNotExist() + + def get_model_side_effect(app_label, model_name): + if model_name == "ServerEnvironmentKey": + return MockServerEnvKey + if model_name == "Environment": + return MockEnvironment + return MagicMock() + + mock_get_model.side_effect = get_model_side_effect + + result = env_has_references_to("env-1", "staging", "my-app", "app-1") + assert result is False + + +@patch("api.utils.secrets.apps.get_model") +@patch("api.utils.secrets.decrypt_asymmetric") +@patch("api.utils.secrets.env_keypair") +@patch("api.utils.secrets.get_server_keypair") +def test_env_has_references_to_case_insensitive( + mock_server_kp, mock_env_kp, mock_decrypt, mock_get_model +): + """Test that reference matching is case-insensitive""" + mock_server_kp.return_value = (b"pk", b"sk") + mock_env_kp.return_value = (b"env_pub", b"env_priv") + + mock_env = MagicMock() + mock_env.app_id = "app-1" + + mock_server_env_key = MagicMock() + mock_server_env_key.wrapped_seed = "wrapped_seed" + + MockEnvironment = MagicMock() + MockEnvironment.objects.select_related.return_value.get.return_value = mock_env + MockEnvironment.DoesNotExist = Exception + + MockServerEnvKey = MagicMock() + MockServerEnvKey.objects.get.return_value = mock_server_env_key + MockServerEnvKey.DoesNotExist = Exception + + mock_secret = MagicMock() + mock_secret.value = "encrypted" + + MockSecret = MagicMock() + MockSecret.objects.filter.return_value = [mock_secret] + + def get_model_side_effect(app_label, model_name): + if model_name == "Secret": + return MockSecret + if model_name == "ServerEnvironmentKey": + return MockServerEnvKey + if model_name == "Environment": + return MockEnvironment + return MagicMock() + + mock_get_model.side_effect = get_model_side_effect + + mock_decrypt.side_effect = ["env_seed", "url=${STAGING.DB_HOST}"] + + result = env_has_references_to("env-1", "staging", "my-app", "app-1") + assert result is True + + +@patch("api.utils.secrets.apps.get_model") +@patch("api.utils.secrets.decrypt_asymmetric") +@patch("api.utils.secrets.env_keypair") +@patch("api.utils.secrets.get_server_keypair") +def test_env_has_references_to_ignores_railway_syntax( + mock_server_kp, mock_env_kp, mock_decrypt, mock_get_model +): + """Test that ${{...}} Railway-style syntax is not treated as a reference""" + mock_server_kp.return_value = (b"pk", b"sk") + mock_env_kp.return_value = (b"env_pub", b"env_priv") + + mock_env = MagicMock() + mock_env.app_id = "app-1" + + mock_server_env_key = MagicMock() + mock_server_env_key.wrapped_seed = "wrapped_seed" + + MockEnvironment = MagicMock() + MockEnvironment.objects.select_related.return_value.get.return_value = mock_env + MockEnvironment.DoesNotExist = Exception + + MockServerEnvKey = MagicMock() + MockServerEnvKey.objects.get.return_value = mock_server_env_key + MockServerEnvKey.DoesNotExist = Exception + + mock_secret = MagicMock() + mock_secret.value = "encrypted" + + MockSecret = MagicMock() + MockSecret.objects.filter.return_value = [mock_secret] + + def get_model_side_effect(app_label, model_name): + if model_name == "Secret": + return MockSecret + if model_name == "ServerEnvironmentKey": + return MockServerEnvKey + if model_name == "Environment": + return MockEnvironment + return MagicMock() + + mock_get_model.side_effect = get_model_side_effect + + mock_decrypt.side_effect = ["env_seed", "url=${{staging.DB_HOST}}"] + + result = env_has_references_to("env-1", "staging", "my-app", "app-1") + assert result is False diff --git a/frontend/apollo/gql.ts b/frontend/apollo/gql.ts index 56fd55adb..ef60a1b10 100644 --- a/frontend/apollo/gql.ts +++ b/frontend/apollo/gql.ts @@ -157,7 +157,7 @@ type Documents = { "query GetAzureKeyVaultSecrets($credentialId: ID!, $vaultUri: String!) {\n azureKvSecrets(credentialId: $credentialId, vaultUri: $vaultUri) {\n name\n updatedOn\n contentType\n }\n}": typeof types.GetAzureKeyVaultSecretsDocument, "query GetCfPages($credentialId: ID!) {\n cloudflarePagesProjects(credentialId: $credentialId) {\n name\n deploymentId\n environments\n }\n}": typeof types.GetCfPagesDocument, "query GetCfWorkers($credentialId: ID!) {\n cloudflareWorkers(credentialId: $credentialId) {\n name\n scriptId\n }\n}": typeof types.GetCfWorkersDocument, - "query GetAppSyncStatus($appId: ID!) {\n sseEnabled(appId: $appId)\n syncs(appId: $appId) {\n id\n environment {\n id\n name\n envType\n app {\n id\n name\n }\n }\n path\n serviceInfo {\n id\n name\n provider {\n id\n }\n }\n options\n isActive\n lastSync\n status\n authentication {\n id\n name\n credentials\n }\n createdAt\n history {\n id\n status\n createdAt\n completedAt\n meta\n }\n }\n serverPublicKey\n}": typeof types.GetAppSyncStatusDocument, + "query GetAppSyncStatus($appId: ID!) {\n sseEnabled(appId: $appId)\n syncs(appId: $appId) {\n id\n environment {\n id\n name\n envType\n index\n app {\n id\n name\n }\n }\n path\n serviceInfo {\n id\n name\n provider {\n id\n }\n }\n options\n isActive\n lastSync\n status\n authentication {\n id\n name\n credentials\n }\n createdAt\n history {\n id\n status\n createdAt\n completedAt\n meta\n }\n }\n serverPublicKey\n}": typeof types.GetAppSyncStatusDocument, "query GetProviderList {\n providers {\n id\n name\n expectedCredentials\n optionalCredentials\n authScheme\n }\n serverPublicKey\n}": typeof types.GetProviderListDocument, "query GetSavedCredentials($orgId: ID!) {\n savedCredentials(orgId: $orgId) {\n id\n name\n credentials\n createdAt\n provider {\n id\n name\n expectedCredentials\n optionalCredentials\n }\n syncCount\n }\n}": typeof types.GetSavedCredentialsDocument, "query GetServerKey {\n serverPublicKey\n}": typeof types.GetServerKeyDocument, @@ -318,7 +318,7 @@ const documents: Documents = { "query GetAzureKeyVaultSecrets($credentialId: ID!, $vaultUri: String!) {\n azureKvSecrets(credentialId: $credentialId, vaultUri: $vaultUri) {\n name\n updatedOn\n contentType\n }\n}": types.GetAzureKeyVaultSecretsDocument, "query GetCfPages($credentialId: ID!) {\n cloudflarePagesProjects(credentialId: $credentialId) {\n name\n deploymentId\n environments\n }\n}": types.GetCfPagesDocument, "query GetCfWorkers($credentialId: ID!) {\n cloudflareWorkers(credentialId: $credentialId) {\n name\n scriptId\n }\n}": types.GetCfWorkersDocument, - "query GetAppSyncStatus($appId: ID!) {\n sseEnabled(appId: $appId)\n syncs(appId: $appId) {\n id\n environment {\n id\n name\n envType\n app {\n id\n name\n }\n }\n path\n serviceInfo {\n id\n name\n provider {\n id\n }\n }\n options\n isActive\n lastSync\n status\n authentication {\n id\n name\n credentials\n }\n createdAt\n history {\n id\n status\n createdAt\n completedAt\n meta\n }\n }\n serverPublicKey\n}": types.GetAppSyncStatusDocument, + "query GetAppSyncStatus($appId: ID!) {\n sseEnabled(appId: $appId)\n syncs(appId: $appId) {\n id\n environment {\n id\n name\n envType\n index\n app {\n id\n name\n }\n }\n path\n serviceInfo {\n id\n name\n provider {\n id\n }\n }\n options\n isActive\n lastSync\n status\n authentication {\n id\n name\n credentials\n }\n createdAt\n history {\n id\n status\n createdAt\n completedAt\n meta\n }\n }\n serverPublicKey\n}": types.GetAppSyncStatusDocument, "query GetProviderList {\n providers {\n id\n name\n expectedCredentials\n optionalCredentials\n authScheme\n }\n serverPublicKey\n}": types.GetProviderListDocument, "query GetSavedCredentials($orgId: ID!) {\n savedCredentials(orgId: $orgId) {\n id\n name\n credentials\n createdAt\n provider {\n id\n name\n expectedCredentials\n optionalCredentials\n }\n syncCount\n }\n}": types.GetSavedCredentialsDocument, "query GetServerKey {\n serverPublicKey\n}": types.GetServerKeyDocument, @@ -925,7 +925,7 @@ export function graphql(source: "query GetCfWorkers($credentialId: ID!) {\n clo /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "query GetAppSyncStatus($appId: ID!) {\n sseEnabled(appId: $appId)\n syncs(appId: $appId) {\n id\n environment {\n id\n name\n envType\n app {\n id\n name\n }\n }\n path\n serviceInfo {\n id\n name\n provider {\n id\n }\n }\n options\n isActive\n lastSync\n status\n authentication {\n id\n name\n credentials\n }\n createdAt\n history {\n id\n status\n createdAt\n completedAt\n meta\n }\n }\n serverPublicKey\n}"): (typeof documents)["query GetAppSyncStatus($appId: ID!) {\n sseEnabled(appId: $appId)\n syncs(appId: $appId) {\n id\n environment {\n id\n name\n envType\n app {\n id\n name\n }\n }\n path\n serviceInfo {\n id\n name\n provider {\n id\n }\n }\n options\n isActive\n lastSync\n status\n authentication {\n id\n name\n credentials\n }\n createdAt\n history {\n id\n status\n createdAt\n completedAt\n meta\n }\n }\n serverPublicKey\n}"]; +export function graphql(source: "query GetAppSyncStatus($appId: ID!) {\n sseEnabled(appId: $appId)\n syncs(appId: $appId) {\n id\n environment {\n id\n name\n envType\n index\n app {\n id\n name\n }\n }\n path\n serviceInfo {\n id\n name\n provider {\n id\n }\n }\n options\n isActive\n lastSync\n status\n authentication {\n id\n name\n credentials\n }\n createdAt\n history {\n id\n status\n createdAt\n completedAt\n meta\n }\n }\n serverPublicKey\n}"): (typeof documents)["query GetAppSyncStatus($appId: ID!) {\n sseEnabled(appId: $appId)\n syncs(appId: $appId) {\n id\n environment {\n id\n name\n envType\n index\n app {\n id\n name\n }\n }\n path\n serviceInfo {\n id\n name\n provider {\n id\n }\n }\n options\n isActive\n lastSync\n status\n authentication {\n id\n name\n credentials\n }\n createdAt\n history {\n id\n status\n createdAt\n completedAt\n meta\n }\n }\n serverPublicKey\n}"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/frontend/apollo/graphql.ts b/frontend/apollo/graphql.ts index f00798e51..916f4e385 100644 --- a/frontend/apollo/graphql.ts +++ b/frontend/apollo/graphql.ts @@ -181,6 +181,8 @@ export enum ApiEnvironmentSyncEventStatusChoices { Failed = 'FAILED', /** In progress */ InProgress = 'IN_PROGRESS', + /** Queued */ + Queued = 'QUEUED', /** Timed out */ TimedOut = 'TIMED_OUT' } @@ -195,6 +197,8 @@ export enum ApiEnvironmentSyncStatusChoices { Failed = 'FAILED', /** In progress */ InProgress = 'IN_PROGRESS', + /** Queued */ + Queued = 'QUEUED', /** Timed out */ TimedOut = 'TIMED_OUT' } @@ -221,6 +225,16 @@ export enum ApiSecretEventEventTypeChoices { U = 'U' } +/** An enumeration. */ +export enum ApiSecretEventTypeChoices { + /** Config */ + Config = 'CONFIG', + /** Sealed */ + Sealed = 'SEALED', + /** Secret */ + Secret = 'SECRET' +} + /** An enumeration. */ export enum ApiSecretTypeChoices { /** Config */ @@ -2346,7 +2360,7 @@ export type SecretEventType = { serviceToken?: Maybe; tags: Array; timestamp: Scalars['DateTime']['output']; - type: ApiSecretTypeChoices; + type: ApiSecretEventTypeChoices; user?: Maybe; userAgent?: Maybe; value: Scalars['String']['output']; @@ -3810,7 +3824,7 @@ export type GetSecretHistoryQueryVariables = Exact<{ }>; -export type GetSecretHistoryQuery = { __typename?: 'Query', secrets?: Array<{ __typename?: 'SecretType', id: string, history?: Array<{ __typename?: 'SecretEventType', id: string, key: string, value: string, type: ApiSecretTypeChoices, path: string, version: number, comment: string, timestamp: any, ipAddress?: string | null, userAgent?: string | null, eventType: ApiSecretEventEventTypeChoices, tags: Array<{ __typename?: 'SecretTagType', id: string, name: string, color: string }>, user?: { __typename?: 'OrganisationMemberType', email?: string | null, username?: string | null, fullName?: string | null, avatarUrl?: string | null } | null, serviceToken?: { __typename?: 'ServiceTokenType', id: string, name: string } | null, serviceAccount?: { __typename?: 'ServiceAccountType', id: string, name: string, deletedAt?: any | null } | null } | null> | null } | null> | null, environmentKeys?: Array<{ __typename?: 'EnvironmentKeyType', id: string, identityKey: string, wrappedSeed: string, wrappedSalt: string } | null> | null }; +export type GetSecretHistoryQuery = { __typename?: 'Query', secrets?: Array<{ __typename?: 'SecretType', id: string, history?: Array<{ __typename?: 'SecretEventType', id: string, key: string, value: string, type: ApiSecretEventTypeChoices, path: string, version: number, comment: string, timestamp: any, ipAddress?: string | null, userAgent?: string | null, eventType: ApiSecretEventEventTypeChoices, tags: Array<{ __typename?: 'SecretTagType', id: string, name: string, color: string }>, user?: { __typename?: 'OrganisationMemberType', email?: string | null, username?: string | null, fullName?: string | null, avatarUrl?: string | null } | null, serviceToken?: { __typename?: 'ServiceTokenType', id: string, name: string } | null, serviceAccount?: { __typename?: 'ServiceAccountType', id: string, name: string, deletedAt?: any | null } | null } | null> | null } | null> | null, environmentKeys?: Array<{ __typename?: 'EnvironmentKeyType', id: string, identityKey: string, wrappedSeed: string, wrappedSalt: string } | null> | null }; export type GetEnvSecretsKvQueryVariables = Exact<{ envId: Scalars['ID']['input']; @@ -3928,7 +3942,7 @@ export type GetAppSyncStatusQueryVariables = Exact<{ }>; -export type GetAppSyncStatusQuery = { __typename?: 'Query', sseEnabled?: boolean | null, serverPublicKey?: string | null, syncs?: Array<{ __typename?: 'EnvironmentSyncType', id: string, path: string, options: any, isActive: boolean, lastSync?: any | null, status: ApiEnvironmentSyncStatusChoices, createdAt?: any | null, environment: { __typename?: 'EnvironmentType', id: string, name: string, envType: ApiEnvironmentEnvTypeChoices, app: { __typename?: 'AppMembershipType', id: string, name: string } }, serviceInfo?: { __typename?: 'ServiceType', id?: string | null, name?: string | null, provider?: { __typename?: 'ProviderType', id: string } | null } | null, authentication?: { __typename?: 'ProviderCredentialsType', id: string, name: string, credentials: any } | null, history: Array<{ __typename?: 'EnvironmentSyncEventType', id: string, status: ApiEnvironmentSyncEventStatusChoices, createdAt?: any | null, completedAt?: any | null, meta?: any | null }> } | null> | null }; +export type GetAppSyncStatusQuery = { __typename?: 'Query', sseEnabled?: boolean | null, serverPublicKey?: string | null, syncs?: Array<{ __typename?: 'EnvironmentSyncType', id: string, path: string, options: any, isActive: boolean, lastSync?: any | null, status: ApiEnvironmentSyncStatusChoices, createdAt?: any | null, environment: { __typename?: 'EnvironmentType', id: string, name: string, envType: ApiEnvironmentEnvTypeChoices, index: number, app: { __typename?: 'AppMembershipType', id: string, name: string } }, serviceInfo?: { __typename?: 'ServiceType', id?: string | null, name?: string | null, provider?: { __typename?: 'ProviderType', id: string } | null } | null, authentication?: { __typename?: 'ProviderCredentialsType', id: string, name: string, credentials: any } | null, history: Array<{ __typename?: 'EnvironmentSyncEventType', id: string, status: ApiEnvironmentSyncEventStatusChoices, createdAt?: any | null, completedAt?: any | null, meta?: any | null }> } | null> | null }; export type GetProviderListQueryVariables = Exact<{ [key: string]: never; }>; @@ -4176,7 +4190,7 @@ export const ValidateAwsAssumeRoleCredentialsDocument = {"kind":"Document","defi export const GetAzureKeyVaultSecretsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAzureKeyVaultSecrets"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"vaultUri"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"azureKvSecrets"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"credentialId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}}},{"kind":"Argument","name":{"kind":"Name","value":"vaultUri"},"value":{"kind":"Variable","name":{"kind":"Name","value":"vaultUri"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"updatedOn"}},{"kind":"Field","name":{"kind":"Name","value":"contentType"}}]}}]}}]} as unknown as DocumentNode; export const GetCfPagesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetCfPages"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cloudflarePagesProjects"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"credentialId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"deploymentId"}},{"kind":"Field","name":{"kind":"Name","value":"environments"}}]}}]}}]} as unknown as DocumentNode; export const GetCfWorkersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetCfWorkers"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cloudflareWorkers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"credentialId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"scriptId"}}]}}]}}]} as unknown as DocumentNode; -export const GetAppSyncStatusDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAppSyncStatus"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"sseEnabled"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}}]},{"kind":"Field","name":{"kind":"Name","value":"syncs"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"environment"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"envType"}},{"kind":"Field","name":{"kind":"Name","value":"app"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"serviceInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"provider"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"options"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"lastSync"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"authentication"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"credentials"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"history"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"completedAt"}},{"kind":"Field","name":{"kind":"Name","value":"meta"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"serverPublicKey"}}]}}]} as unknown as DocumentNode; +export const GetAppSyncStatusDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAppSyncStatus"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"sseEnabled"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}}]},{"kind":"Field","name":{"kind":"Name","value":"syncs"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"environment"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"envType"}},{"kind":"Field","name":{"kind":"Name","value":"index"}},{"kind":"Field","name":{"kind":"Name","value":"app"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"serviceInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"provider"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"options"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"lastSync"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"authentication"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"credentials"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"history"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"completedAt"}},{"kind":"Field","name":{"kind":"Name","value":"meta"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"serverPublicKey"}}]}}]} as unknown as DocumentNode; export const GetProviderListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetProviderList"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"providers"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"expectedCredentials"}},{"kind":"Field","name":{"kind":"Name","value":"optionalCredentials"}},{"kind":"Field","name":{"kind":"Name","value":"authScheme"}}]}},{"kind":"Field","name":{"kind":"Name","value":"serverPublicKey"}}]}}]} as unknown as DocumentNode; export const GetSavedCredentialsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetSavedCredentials"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"savedCredentials"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"orgId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"credentials"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"provider"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"expectedCredentials"}},{"kind":"Field","name":{"kind":"Name","value":"optionalCredentials"}}]}},{"kind":"Field","name":{"kind":"Name","value":"syncCount"}}]}}]}}]} as unknown as DocumentNode; export const GetServerKeyDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetServerKey"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"serverPublicKey"}}]}}]} as unknown as DocumentNode; diff --git a/frontend/apollo/schema.graphql b/frontend/apollo/schema.graphql index 6b1227c41..342810876 100644 --- a/frontend/apollo/schema.graphql +++ b/frontend/apollo/schema.graphql @@ -269,7 +269,7 @@ type SecretEventType { version: Int! tags: [SecretTagType!]! comment: String! - type: ApiSecretTypeChoices! + type: ApiSecretEventTypeChoices! eventType: ApiSecretEventEventTypeChoices! timestamp: DateTime! ipAddress: String @@ -366,6 +366,18 @@ type AwsIamConfigType { stsEndpoint: String } +"""An enumeration.""" +enum ApiSecretEventTypeChoices { + """Secret""" + SECRET + + """Sealed""" + SEALED + + """Config""" + CONFIG +} + """An enumeration.""" enum ApiSecretEventEventTypeChoices { """Create""" @@ -550,6 +562,9 @@ type EnvironmentSyncType { """An enumeration.""" enum ApiEnvironmentSyncStatusChoices { + """Queued""" + QUEUED + """In progress""" IN_PROGRESS @@ -584,6 +599,9 @@ type EnvironmentSyncEventType { """An enumeration.""" enum ApiEnvironmentSyncEventStatusChoices { + """Queued""" + QUEUED + """In progress""" IN_PROGRESS diff --git a/frontend/components/environments/secrets/SecretPropertyDiffs.tsx b/frontend/components/environments/secrets/SecretPropertyDiffs.tsx index 46ec7a87d..272de6003 100644 --- a/frontend/components/environments/secrets/SecretPropertyDiffs.tsx +++ b/frontend/components/environments/secrets/SecretPropertyDiffs.tsx @@ -1,4 +1,4 @@ -import { ApiSecretTypeChoices, SecretEventType, SecretTagType, SecretType } from '@/apollo/graphql' +import { ApiSecretEventTypeChoices, ApiSecretTypeChoices, SecretEventType, SecretTagType, SecretType } from '@/apollo/graphql' import { areTagsAreSame } from '@/utils/tags' import { FaRedoAlt, FaUndoAlt } from 'react-icons/fa' import { Button } from '../../common/Button' @@ -33,14 +33,14 @@ export const SecretPropertyDiffs = ({ return removedTags } - const isSealed = historyItem!.type === ApiSecretTypeChoices.Sealed - const wasSealed = previousItem.type === ApiSecretTypeChoices.Sealed + const isSealed = historyItem!.type === ApiSecretEventTypeChoices.Sealed + const wasSealed = previousItem.type === ApiSecretEventTypeChoices.Sealed const typeLabel = (type: string) => { switch (type) { - case ApiSecretTypeChoices.Sealed: + case ApiSecretEventTypeChoices.Sealed: return 'Sealed' - case ApiSecretTypeChoices.Config: + case ApiSecretEventTypeChoices.Config: return 'Config' default: return 'Secret' diff --git a/frontend/components/syncing/EnvSyncStatus.tsx b/frontend/components/syncing/EnvSyncStatus.tsx index 74529a651..c43d92bea 100644 --- a/frontend/components/syncing/EnvSyncStatus.tsx +++ b/frontend/components/syncing/EnvSyncStatus.tsx @@ -17,7 +17,9 @@ export const EnvSyncStatus = (props: { const syncStatus = () => { if ( syncs.some( - (sync: EnvironmentSyncType) => sync.status === ApiEnvironmentSyncStatusChoices.InProgress + (sync: EnvironmentSyncType) => + sync.status === ApiEnvironmentSyncStatusChoices.InProgress || + sync.status === ApiEnvironmentSyncStatusChoices.Queued ) ) return ApiEnvironmentSyncStatusChoices.InProgress @@ -65,9 +67,11 @@ export const EnvSyncStatus = (props: { - {syncs.map((sync: EnvironmentSyncType) => ( - - ))} + {[...syncs] + .sort((a, b) => a.environment.index - b.environment.index) + .map((sync: EnvironmentSyncType) => ( + + ))} diff --git a/frontend/components/syncing/SyncCard.tsx b/frontend/components/syncing/SyncCard.tsx index 9add770fc..806ffa433 100644 --- a/frontend/components/syncing/SyncCard.tsx +++ b/frontend/components/syncing/SyncCard.tsx @@ -72,7 +72,8 @@ export const SyncCard = (props: {
{sync.status && }
- {sync.status === ApiEnvironmentSyncStatusChoices.InProgress ? ( + {sync.status === ApiEnvironmentSyncStatusChoices.InProgress || + sync.status === ApiEnvironmentSyncStatusChoices.Queued ? (
) : (
diff --git a/frontend/components/syncing/SyncHistory.tsx b/frontend/components/syncing/SyncHistory.tsx index 708abffff..4891b56f1 100644 --- a/frontend/components/syncing/SyncHistory.tsx +++ b/frontend/components/syncing/SyncHistory.tsx @@ -75,7 +75,8 @@ const SyncLogRow = (props: { event: EnvironmentSyncEventType }) => { {event.completedAt && - event.status !== ApiEnvironmentSyncEventStatusChoices.InProgress && ( + event.status !== ApiEnvironmentSyncEventStatusChoices.InProgress && + event.status !== ApiEnvironmentSyncEventStatusChoices.Queued && (
{relativeTimeFromDates(new Date(event.completedAt))}
)} diff --git a/frontend/components/syncing/SyncStatusIndicator.tsx b/frontend/components/syncing/SyncStatusIndicator.tsx index 171c4d459..d0e82b296 100644 --- a/frontend/components/syncing/SyncStatusIndicator.tsx +++ b/frontend/components/syncing/SyncStatusIndicator.tsx @@ -2,7 +2,13 @@ import { ApiEnvironmentSyncEventStatusChoices, ApiEnvironmentSyncStatusChoices, } from '@/apollo/graphql' -import { FaCheckCircle, FaHourglassEnd, FaMinusCircle, FaTimesCircle } from 'react-icons/fa' +import { + FaCheckCircle, + FaClock, + FaHourglassEnd, + FaMinusCircle, + FaTimesCircle, +} from 'react-icons/fa' import Spinner from '../common/Spinner' export const SyncStatusIndicator = (props: { @@ -39,6 +45,13 @@ export const SyncStatusIndicator = (props: { {showLabel && 'Skipped'}
) + } else if (status === ApiEnvironmentSyncStatusChoices.Queued) { + return ( +
+ + {showLabel && 'Queued'} +
+ ) } else return (
diff --git a/frontend/graphql/queries/syncing/getAppSyncStatus.gql b/frontend/graphql/queries/syncing/getAppSyncStatus.gql index 445c43e84..b63f4ac37 100644 --- a/frontend/graphql/queries/syncing/getAppSyncStatus.gql +++ b/frontend/graphql/queries/syncing/getAppSyncStatus.gql @@ -6,6 +6,7 @@ query GetAppSyncStatus($appId: ID!) { id name envType + index app { id name