diff --git a/Gemfile b/Gemfile
index 7e25a3bae9157d13d8513229f64340baa8f299a2..7d5a6177dee8aa944a949613dab84e34397cdce4 100644
--- a/Gemfile
+++ b/Gemfile
@@ -587,6 +587,9 @@ gem 'cvss-suite', '~> 3.0.1', require: 'cvss_suite'
# Work with RPM packages
gem 'arr-pm', '~> 0.0.12'
+# Remote Development
+gem 'devfile', '~> 0.0.17.pre.alpha1'
+
# Apple plist parsing
gem 'CFPropertyList', '~> 3.0.0'
gem 'app_store_connect'
diff --git a/Gemfile.checksum b/Gemfile.checksum
index 8b9a45e5a1949a80107eb99eb8cef33974f43093..7011065b5b95b29864097afb001ef64744e324a9 100644
--- a/Gemfile.checksum
+++ b/Gemfile.checksum
@@ -109,6 +109,9 @@
{"name":"deprecation_toolkit","version":"1.5.1","platform":"ruby","checksum":"a8a1ab1a19ae40ea12560b65010e099f3459ebde390b76621ef0c21c516a04ba"},
{"name":"derailed_benchmarks","version":"2.1.2","platform":"ruby","checksum":"eaadc6206ceeb5538ff8f5e04a0023d54ebdd95d04f33e8960fb95a5f189a14f"},
{"name":"descendants_tracker","version":"0.0.4","platform":"ruby","checksum":"e9c41dd4cfbb85829a9301ea7e7c48c2a03b26f09319db230e6479ccdc780897"},
+{"name":"devfile","version":"0.0.17.pre.alpha1","platform":"arm64-darwin","checksum":"a6e4d970914399a3acce38d81c42ba5b98f849d878031ff83decd6575369d0c3"},
+{"name":"devfile","version":"0.0.17.pre.alpha1","platform":"ruby","checksum":"2855e7513ab8322e456d3080bf2449109cf4a5785e262443128db0ebf48e646c"},
+{"name":"devfile","version":"0.0.17.pre.alpha1","platform":"x86_64-linux","checksum":"da045e7cbeb2f0685b9b6c7f3d54147403720dced01f727e2f8ca53cef333eaa"},
{"name":"device_detector","version":"1.0.0","platform":"ruby","checksum":"b800fb3150b00c23e87b6768011808ac1771fffaae74c3238ebaf2b782947a7d"},
{"name":"devise","version":"4.8.1","platform":"ruby","checksum":"fdd48bbe79a89e7c1152236a70479842ede48bea4fa7f4f2d8da1f872559803e"},
{"name":"devise-two-factor","version":"4.0.2","platform":"ruby","checksum":"6548d2696ed090d27046f888f4fa7380f151e0f823902d46fd9b91e7d0cac511"},
diff --git a/Gemfile.lock b/Gemfile.lock
index f074a37cc8edf7289094648abb9ef595f8191a37..be9a727e611273853e151a3ba5db9126ad90ddb7 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -375,6 +375,7 @@ GEM
thor (>= 0.19, < 2)
descendants_tracker (0.0.4)
thread_safe (~> 0.3, >= 0.3.1)
+ devfile (0.0.17.pre.alpha1)
device_detector (1.0.0)
devise (4.8.1)
bcrypt (~> 3.0)
@@ -1714,6 +1715,7 @@ DEPENDENCIES
declarative_policy (~> 1.1.0)
deprecation_toolkit (~> 1.5.1)
derailed_benchmarks
+ devfile (~> 0.0.17.pre.alpha1)
device_detector
devise (~> 4.8.1)
devise-pbkdf2-encryptable (~> 0.0.0)!
diff --git a/app/graphql/types/user_interface.rb b/app/graphql/types/user_interface.rb
index 83d2f3f830a81c9962b6a8390a0a52fa2a356b8b..64fc069b508950b86b24bf3071ac5fe299268031 100644
--- a/app/graphql/types/user_interface.rb
+++ b/app/graphql/types/user_interface.rb
@@ -175,3 +175,5 @@ def redacted_name
end
end
end
+
+Types::UserInterface.prepend_mod
diff --git a/db/docs/remote_development_agent_configs.yml b/db/docs/remote_development_agent_configs.yml
new file mode 100644
index 0000000000000000000000000000000000000000..89e8009558056c3c8a99f654ce5f278dace4dd8f
--- /dev/null
+++ b/db/docs/remote_development_agent_configs.yml
@@ -0,0 +1,10 @@
+---
+table_name: remote_development_agent_configs
+classes:
+- RemoteDevelopment::RemoteDevelopmentAgentConfig
+feature_categories:
+- remote_development
+description: Remote Development Cluster Agent Configuration
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/105783
+milestone: '16.0'
+gitlab_schema: gitlab_main
diff --git a/db/docs/verification_codes.yml b/db/docs/verification_codes.yml
index 9d0e3f53830bcd090c116bc7e74a7aac218a13ef..b34818070b123bec8f9c2072552f744f54c5d345 100644
--- a/db/docs/verification_codes.yml
+++ b/db/docs/verification_codes.yml
@@ -1,6 +1,7 @@
---
table_name: verification_codes
-classes: []
+classes:
+-
feature_categories:
- jihu
description: Used by the JiHu edition for user verification
diff --git a/db/docs/workspaces.yml b/db/docs/workspaces.yml
new file mode 100644
index 0000000000000000000000000000000000000000..045a31d0d73ecaa25305ece8f7c00cf600d4efbc
--- /dev/null
+++ b/db/docs/workspaces.yml
@@ -0,0 +1,10 @@
+---
+table_name: workspaces
+classes:
+- RemoteDevelopment::Workspace
+feature_categories:
+- remote_development
+description: Remote Development Workspaces
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/105783
+milestone: '16.0'
+gitlab_schema: gitlab_main
diff --git a/db/migrate/20221225010101_create_workspaces_table.rb b/db/migrate/20221225010101_create_workspaces_table.rb
new file mode 100644
index 0000000000000000000000000000000000000000..4c8bc26bcf651fbb82559e2781b6711377f0e5e7
--- /dev/null
+++ b/db/migrate/20221225010101_create_workspaces_table.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+class CreateWorkspacesTable < Gitlab::Database::Migration[2.1]
+ def up
+ create_table :workspaces do |t|
+ t.timestamps_with_timezone null: false
+ # NOTE: All workspace foreign key references are currently `on_delete: :cascade`, because we have no support or
+ # testing around null values. However, in the future we may want to switch these to nullify, especially
+ # once we start introducing logging, metrics, billing, etc. around workspaces.
+ t.bigint :user_id, null: false, index: true
+ t.bigint :project_id, null: false, index: true
+ t.bigint :cluster_agent_id, null: false, index: true
+ t.datetime_with_timezone :desired_state_updated_at, null: false
+ t.datetime_with_timezone :responded_to_agent_at
+ t.integer :max_hours_before_termination, limit: 2, null: false
+ t.text :name, limit: 64, null: false, index: { unique: true }
+ t.text :namespace, limit: 64, null: false
+ t.text :desired_state, limit: 32, null: false
+ t.text :actual_state, limit: 32, null: false
+ t.text :editor, limit: 256, null: false
+ t.text :devfile_ref, limit: 256, null: false
+ t.text :devfile_path, limit: 2048, null: false
+ # NOTE: The limit on the devfile fields are arbitrary, and only added to avoid a rubocop
+ # Migration/AddLimitToTextColumns error. We expect the average devfile side to be small, perhaps ~0.5k for a
+ # devfile and ~2k for a processed_devfile, but to account for unexpected usage resulting in larger files,
+ # we have specified 65535, which allows for a YAML file with over 800 lines of an average 80-character
+ # length.
+ t.text :devfile, limit: 65535
+ t.text :processed_devfile, limit: 65535
+ t.text :url, limit: 1024, null: false
+ # NOTE: The resource version is currently backed by etcd's mod_revision.
+ # However, it's important to note that the application should not rely on the implementation details of
+ # the versioning system maintained by Kubernetes. We may change the implementation of resource version
+ # in the future, such as to change it to a timestamp or per-object counter.
+ # https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency
+ # The limit of 64 is arbitrary.
+ t.text :deployment_resource_version, limit: 64
+ end
+ end
+
+ def down
+ drop_table :workspaces
+ end
+end
diff --git a/db/migrate/20221225010102_create_workspaces_user_foreign_key.rb b/db/migrate/20221225010102_create_workspaces_user_foreign_key.rb
new file mode 100644
index 0000000000000000000000000000000000000000..f6c38f289d62f42cc4fcc7ba4af415c3a452f382
--- /dev/null
+++ b/db/migrate/20221225010102_create_workspaces_user_foreign_key.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+class CreateWorkspacesUserForeignKey < Gitlab::Database::Migration[2.1]
+ disable_ddl_transaction!
+
+ def up
+ # NOTE: All workspace foreign key references are currently `on_delete: :cascade`, because we have no support or
+ # testing around null values. However, in the future we may want to switch these to nullify, especially
+ # once we start introducing logging, metrics, billing, etc. around workspaces.
+ add_concurrent_foreign_key :workspaces, :users, column: :user_id, on_delete: :cascade
+ end
+
+ def down
+ with_lock_retries do
+ remove_foreign_key :workspaces, column: :user_id
+ end
+ end
+end
diff --git a/db/migrate/20221225010103_create_workspaces_project_foreign_key.rb b/db/migrate/20221225010103_create_workspaces_project_foreign_key.rb
new file mode 100644
index 0000000000000000000000000000000000000000..fe2b6eec2e01edee81584aba3aa7e4d4aa5b0351
--- /dev/null
+++ b/db/migrate/20221225010103_create_workspaces_project_foreign_key.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+class CreateWorkspacesProjectForeignKey < Gitlab::Database::Migration[2.1]
+ disable_ddl_transaction!
+
+ def up
+ # NOTE: All workspace foreign key references are currently `on_delete: :cascade`, because we have no support or
+ # testing around null values. However, in the future we may want to switch these to nullify, especially
+ # once we start introducing logging, metrics, billing, etc. around workspaces.
+ add_concurrent_foreign_key :workspaces, :projects, column: :project_id, on_delete: :cascade
+ end
+
+ def down
+ with_lock_retries do
+ remove_foreign_key :workspaces, column: :project_id
+ end
+ end
+end
diff --git a/db/migrate/20221225010104_create_workspaces_cluster_agent_foreign_key.rb b/db/migrate/20221225010104_create_workspaces_cluster_agent_foreign_key.rb
new file mode 100644
index 0000000000000000000000000000000000000000..c7874349e861d93335c7b41b1404ffda0ddf71fd
--- /dev/null
+++ b/db/migrate/20221225010104_create_workspaces_cluster_agent_foreign_key.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+class CreateWorkspacesClusterAgentForeignKey < Gitlab::Database::Migration[2.1]
+ disable_ddl_transaction!
+
+ def up
+ # NOTE: All workspace foreign key references are currently `on_delete: :cascade`, because we have no support or
+ # testing around null values. However, in the future we may want to switch these to nullify, especially
+ # once we start introducing logging, metrics, billing, etc. around workspaces.
+ add_concurrent_foreign_key :workspaces, :cluster_agents, column: :cluster_agent_id, on_delete: :cascade
+ end
+
+ def down
+ with_lock_retries do
+ remove_foreign_key :workspaces, column: :cluster_agent_id
+ end
+ end
+end
diff --git a/db/migrate/20221225010105_create_remote_development_agent_configs_table.rb b/db/migrate/20221225010105_create_remote_development_agent_configs_table.rb
new file mode 100644
index 0000000000000000000000000000000000000000..f375f78b616099cfcc31339d184e4b525f58ed53
--- /dev/null
+++ b/db/migrate/20221225010105_create_remote_development_agent_configs_table.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+class CreateRemoteDevelopmentAgentConfigsTable < Gitlab::Database::Migration[2.1]
+ def up
+ create_table :remote_development_agent_configs do |t|
+ t.timestamps_with_timezone null: false
+ t.bigint :cluster_agent_id, null: false, index: true
+ t.boolean :enabled, null: false
+ t.text :dns_zone, null: false, limit: 256
+ end
+ end
+
+ def down
+ drop_table :remote_development_agent_configs
+ end
+end
diff --git a/db/migrate/20221225010106_create_remote_development_agent_config_agent_foreign_key.rb b/db/migrate/20221225010106_create_remote_development_agent_config_agent_foreign_key.rb
new file mode 100644
index 0000000000000000000000000000000000000000..b861f41716847824f541bb4716d7f2a89bf0397f
--- /dev/null
+++ b/db/migrate/20221225010106_create_remote_development_agent_config_agent_foreign_key.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+class CreateRemoteDevelopmentAgentConfigAgentForeignKey < Gitlab::Database::Migration[2.1]
+ disable_ddl_transaction!
+
+ def up
+ add_concurrent_foreign_key :remote_development_agent_configs,
+ :cluster_agents, column: :cluster_agent_id, on_delete: :cascade
+ end
+
+ def down
+ with_lock_retries do
+ remove_foreign_key :remote_development_agent_configs, column: :cluster_agent_id
+ end
+ end
+end
diff --git a/db/schema_migrations/20221225010101 b/db/schema_migrations/20221225010101
new file mode 100644
index 0000000000000000000000000000000000000000..62d2d001438b771113c5af5a10eacca6091f70ff
--- /dev/null
+++ b/db/schema_migrations/20221225010101
@@ -0,0 +1 @@
+94810a223f2d37a673d690ba326577068c18d6353021a78a8f820cf8a95c756c
\ No newline at end of file
diff --git a/db/schema_migrations/20221225010102 b/db/schema_migrations/20221225010102
new file mode 100644
index 0000000000000000000000000000000000000000..8aacd082afcd12aabd447f86f6dfbd36029f5f38
--- /dev/null
+++ b/db/schema_migrations/20221225010102
@@ -0,0 +1 @@
+74a3b48267b16dcd9d3374b01604a0ae7f55dd35e681e3bf6bf5386ea4f6bdc3
\ No newline at end of file
diff --git a/db/schema_migrations/20221225010103 b/db/schema_migrations/20221225010103
new file mode 100644
index 0000000000000000000000000000000000000000..99590b1246f879fa421b34556fe171165817db8e
--- /dev/null
+++ b/db/schema_migrations/20221225010103
@@ -0,0 +1 @@
+bfa7df29a9f021b67db23127c6382161b131b77738f7a29dac5b64bc7431fd88
\ No newline at end of file
diff --git a/db/schema_migrations/20221225010104 b/db/schema_migrations/20221225010104
new file mode 100644
index 0000000000000000000000000000000000000000..abbf974cda0dfc5c909daa14f0627fe2a9d6421b
--- /dev/null
+++ b/db/schema_migrations/20221225010104
@@ -0,0 +1 @@
+b2b2a169bb1d8581eec2706d03314d0675dcdf05b23b2787292b18ac1dfe7847
\ No newline at end of file
diff --git a/db/schema_migrations/20221225010105 b/db/schema_migrations/20221225010105
new file mode 100644
index 0000000000000000000000000000000000000000..9f101f1aff31c054815e2f6468134265071e238b
--- /dev/null
+++ b/db/schema_migrations/20221225010105
@@ -0,0 +1 @@
+241ed02cdd479f06a5a4a817b2d27bfa970997167fbd67ddae1da8359830a2ea
\ No newline at end of file
diff --git a/db/schema_migrations/20221225010106 b/db/schema_migrations/20221225010106
new file mode 100644
index 0000000000000000000000000000000000000000..1499a3257eb5ff68ece1f4517d34ac6f1bdc1e39
--- /dev/null
+++ b/db/schema_migrations/20221225010106
@@ -0,0 +1 @@
+08e0fd85bca9eff63f0fc5d1e34cca628ee191decddebcb90aaf98ce18f97147
\ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index a84efd98fb4da36adb210c79a1003451cb247ef1..96a2baced05c140e88883ca71d2372ccd3457d95 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -11279,8 +11279,8 @@ CREATE TABLE appearances (
email_header_and_footer_enabled boolean DEFAULT false NOT NULL,
profile_image_guidelines text,
profile_image_guidelines_html text,
- pwa_short_name text,
pwa_icon text,
+ pwa_short_name text,
pwa_name text,
pwa_description text,
CONSTRAINT appearances_profile_image_guidelines CHECK ((char_length(profile_image_guidelines) <= 4096)),
@@ -11694,10 +11694,6 @@ CREATE TABLE application_settings (
database_grafana_api_url text,
database_grafana_tag text,
public_runner_releases_url text DEFAULT 'https://gitlab.com/api/v4/projects/gitlab-org%2Fgitlab-runner/releases'::text NOT NULL,
- password_uppercase_required boolean DEFAULT false NOT NULL,
- password_lowercase_required boolean DEFAULT false NOT NULL,
- password_number_required boolean DEFAULT false NOT NULL,
- password_symbol_required boolean DEFAULT false NOT NULL,
encrypted_arkose_labs_public_api_key bytea,
encrypted_arkose_labs_public_api_key_iv bytea,
encrypted_arkose_labs_private_api_key bytea,
@@ -11708,14 +11704,14 @@ CREATE TABLE application_settings (
inactive_projects_min_size_mb integer DEFAULT 0 NOT NULL,
inactive_projects_send_warning_email_after_months integer DEFAULT 1 NOT NULL,
delayed_group_deletion boolean DEFAULT true NOT NULL,
- maven_package_requests_forwarding boolean DEFAULT true NOT NULL,
arkose_labs_namespace text DEFAULT 'client'::text NOT NULL,
max_export_size integer DEFAULT 0,
- encrypted_slack_app_signing_secret bytea,
- encrypted_slack_app_signing_secret_iv bytea,
container_registry_pre_import_timeout integer DEFAULT 1800 NOT NULL,
container_registry_import_timeout integer DEFAULT 600 NOT NULL,
pipeline_limit_per_project_user_sha integer DEFAULT 0 NOT NULL,
+ encrypted_slack_app_signing_secret bytea,
+ encrypted_slack_app_signing_secret_iv bytea,
+ globally_allowed_ips text DEFAULT ''::text NOT NULL,
dingtalk_integration_enabled boolean DEFAULT false NOT NULL,
encrypted_dingtalk_corpid bytea,
encrypted_dingtalk_corpid_iv bytea,
@@ -11723,8 +11719,11 @@ CREATE TABLE application_settings (
encrypted_dingtalk_app_key_iv bytea,
encrypted_dingtalk_app_secret bytea,
encrypted_dingtalk_app_secret_iv bytea,
+ password_uppercase_required boolean DEFAULT false NOT NULL,
+ password_lowercase_required boolean DEFAULT false NOT NULL,
+ password_number_required boolean DEFAULT false NOT NULL,
+ password_symbol_required boolean DEFAULT false NOT NULL,
jira_connect_application_key text,
- globally_allowed_ips text DEFAULT ''::text NOT NULL,
container_registry_pre_import_tags_rate numeric(6,2) DEFAULT 0.5 NOT NULL,
license_usage_data_exported boolean DEFAULT false NOT NULL,
phone_verification_code_enabled boolean DEFAULT false NOT NULL,
@@ -11739,33 +11738,34 @@ CREATE TABLE application_settings (
error_tracking_api_url text,
git_rate_limit_users_allowlist text[] DEFAULT '{}'::text[] NOT NULL,
error_tracking_access_token_encrypted text,
- invitation_flow_enforcement boolean DEFAULT false NOT NULL,
package_registry_cleanup_policies_worker_capacity integer DEFAULT 2 NOT NULL,
deactivate_dormant_users_period integer DEFAULT 90 NOT NULL,
auto_ban_user_on_excessive_projects_download boolean DEFAULT false NOT NULL,
+ invitation_flow_enforcement boolean DEFAULT false NOT NULL,
max_pages_custom_domains_per_project integer DEFAULT 0 NOT NULL,
cube_api_base_url text,
encrypted_cube_api_key bytea,
encrypted_cube_api_key_iv bytea,
- jitsu_host text,
- jitsu_project_xid text,
- jitsu_administrator_email text,
- encrypted_jitsu_administrator_password bytea,
- encrypted_jitsu_administrator_password_iv bytea,
+ maven_package_requests_forwarding boolean DEFAULT true NOT NULL,
dashboard_limit_enabled boolean DEFAULT false NOT NULL,
dashboard_limit integer DEFAULT 0 NOT NULL,
dashboard_notification_limit integer DEFAULT 0 NOT NULL,
dashboard_enforcement_limit integer DEFAULT 0 NOT NULL,
dashboard_limit_new_namespace_creation_enforcement_date date,
+ jitsu_host text,
+ jitsu_project_xid text,
+ jitsu_administrator_email text,
+ encrypted_jitsu_administrator_password bytea,
+ encrypted_jitsu_administrator_password_iv bytea,
can_create_group boolean DEFAULT true NOT NULL,
lock_maven_package_requests_forwarding boolean DEFAULT false NOT NULL,
lock_pypi_package_requests_forwarding boolean DEFAULT false NOT NULL,
lock_npm_package_requests_forwarding boolean DEFAULT false NOT NULL,
- jira_connect_proxy_url text,
password_expiration_enabled boolean DEFAULT false NOT NULL,
password_expires_in_days integer DEFAULT 90 NOT NULL,
password_expires_notice_before_days integer DEFAULT 7 NOT NULL,
product_analytics_enabled boolean DEFAULT false NOT NULL,
+ jira_connect_proxy_url text,
email_confirmation_setting smallint DEFAULT 0,
disable_admin_oauth_scopes boolean DEFAULT false NOT NULL,
default_preferred_language text DEFAULT 'en'::text NOT NULL,
@@ -11774,37 +11774,37 @@ CREATE TABLE application_settings (
encrypted_telesign_customer_xid_iv bytea,
encrypted_telesign_api_key bytea,
encrypted_telesign_api_key_iv bytea,
- disable_personal_access_tokens boolean DEFAULT false NOT NULL,
max_terraform_state_size_bytes integer DEFAULT 0 NOT NULL,
+ disable_personal_access_tokens boolean DEFAULT false NOT NULL,
bulk_import_enabled boolean DEFAULT false NOT NULL,
- allow_runner_registration_token boolean DEFAULT true NOT NULL,
user_defaults_to_private_profile boolean DEFAULT false NOT NULL,
- allow_possible_spam boolean DEFAULT false NOT NULL,
- default_syntax_highlighting_theme integer DEFAULT 1 NOT NULL,
+ allow_runner_registration_token boolean DEFAULT true NOT NULL,
encrypted_product_analytics_clickhouse_connection_string bytea,
encrypted_product_analytics_clickhouse_connection_string_iv bytea,
+ allow_possible_spam boolean DEFAULT false NOT NULL,
search_max_shard_size_gb integer DEFAULT 50 NOT NULL,
search_max_docs_denominator integer DEFAULT 5000000 NOT NULL,
search_min_docs_before_rollover integer DEFAULT 100000 NOT NULL,
deactivation_email_additional_text text,
- jira_connect_public_key_storage_enabled boolean DEFAULT false NOT NULL,
git_rate_limit_users_alertlist integer[] DEFAULT '{}'::integer[] NOT NULL,
- allow_deploy_tokens_and_keys_with_external_authn boolean DEFAULT false NOT NULL,
+ jira_connect_public_key_storage_enabled boolean DEFAULT false NOT NULL,
security_policy_global_group_approvers_enabled boolean DEFAULT true NOT NULL,
+ default_syntax_highlighting_theme integer DEFAULT 1 NOT NULL,
+ allow_deploy_tokens_and_keys_with_external_authn boolean DEFAULT false NOT NULL,
projects_api_rate_limit_unauthenticated integer DEFAULT 400 NOT NULL,
deny_all_requests_except_allowed boolean DEFAULT false NOT NULL,
product_analytics_data_collector_host text,
lock_memberships_to_saml boolean DEFAULT false NOT NULL,
- gitlab_dedicated_instance boolean DEFAULT false NOT NULL,
update_runner_versions_enabled boolean DEFAULT true NOT NULL,
+ gitlab_dedicated_instance boolean DEFAULT false NOT NULL,
database_apdex_settings jsonb,
encrypted_openai_api_key bytea,
encrypted_openai_api_key_iv bytea,
database_max_running_batched_background_migrations integer DEFAULT 2 NOT NULL,
- encrypted_product_analytics_configurator_connection_string bytea,
- encrypted_product_analytics_configurator_connection_string_iv bytea,
silent_mode_enabled boolean DEFAULT false NOT NULL,
package_metadata_purl_types smallint[] DEFAULT '{}'::smallint[],
+ encrypted_product_analytics_configurator_connection_string bytea,
+ encrypted_product_analytics_configurator_connection_string_iv bytea,
ci_max_includes integer DEFAULT 150 NOT NULL,
encrypted_tofa_credentials bytea,
encrypted_tofa_credentials_iv bytea,
@@ -18672,13 +18672,13 @@ CREATE TABLE namespace_settings (
runner_token_expiration_interval integer,
subgroup_runner_token_expiration_interval integer,
project_runner_token_expiration_interval integer,
- show_diff_preview_in_email boolean DEFAULT true NOT NULL,
enabled_git_access_protocol smallint DEFAULT 0 NOT NULL,
unique_project_download_limit smallint DEFAULT 0 NOT NULL,
unique_project_download_limit_interval_in_seconds integer DEFAULT 0 NOT NULL,
project_import_level smallint DEFAULT 50 NOT NULL,
unique_project_download_limit_allowlist text[] DEFAULT '{}'::text[] NOT NULL,
auto_ban_user_on_excessive_projects_download boolean DEFAULT false NOT NULL,
+ show_diff_preview_in_email boolean DEFAULT true NOT NULL,
only_allow_merge_if_pipeline_succeeds boolean DEFAULT false NOT NULL,
allow_merge_on_skipped_pipeline boolean DEFAULT false NOT NULL,
only_allow_merge_if_all_discussions_are_resolved boolean DEFAULT false NOT NULL,
@@ -20065,6 +20065,7 @@ CREATE TABLE plan_limits (
helm_max_file_size bigint DEFAULT 5242880 NOT NULL,
ci_registered_group_runners integer DEFAULT 1000 NOT NULL,
ci_registered_project_runners integer DEFAULT 1000 NOT NULL,
+ web_hook_calls integer DEFAULT 0 NOT NULL,
ci_daily_pipeline_schedule_triggers integer DEFAULT 0 NOT NULL,
ci_max_artifact_size_running_container_scanning integer DEFAULT 0 NOT NULL,
ci_max_artifact_size_cluster_image_scanning integer DEFAULT 0 NOT NULL,
@@ -20089,7 +20090,6 @@ CREATE TABLE plan_limits (
enforcement_limit integer DEFAULT 0 NOT NULL,
notification_limit integer DEFAULT 0 NOT NULL,
dashboard_limit_enabled_at timestamp with time zone,
- web_hook_calls integer DEFAULT 0 NOT NULL,
project_access_token_limit integer DEFAULT 0 NOT NULL
);
@@ -21133,11 +21133,11 @@ CREATE TABLE project_settings (
target_platforms character varying[] DEFAULT '{}'::character varying[] NOT NULL,
enforce_auth_checks_on_uploads boolean DEFAULT true NOT NULL,
selective_code_owner_removals boolean DEFAULT false NOT NULL,
- issue_branch_template text,
show_diff_preview_in_email boolean DEFAULT true NOT NULL,
- jitsu_key text,
suggested_reviewers_enabled boolean DEFAULT false NOT NULL,
+ jitsu_key text,
only_allow_merge_if_all_status_checks_passed boolean DEFAULT false NOT NULL,
+ issue_branch_template text,
mirror_branch_regex text,
allow_pipeline_trigger_approve_deployment boolean DEFAULT false NOT NULL,
emails_enabled boolean DEFAULT true NOT NULL,
@@ -21768,6 +21768,25 @@ CREATE SEQUENCE releases_id_seq
ALTER SEQUENCE releases_id_seq OWNED BY releases.id;
+CREATE TABLE remote_development_agent_configs (
+ id bigint NOT NULL,
+ created_at timestamp with time zone NOT NULL,
+ updated_at timestamp with time zone NOT NULL,
+ cluster_agent_id bigint NOT NULL,
+ enabled boolean NOT NULL,
+ dns_zone text NOT NULL,
+ CONSTRAINT check_9f5cd54d1c CHECK ((char_length(dns_zone) <= 256))
+);
+
+CREATE SEQUENCE remote_development_agent_configs_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+ALTER SEQUENCE remote_development_agent_configs_id_seq OWNED BY remote_development_agent_configs.id;
+
CREATE TABLE remote_mirrors (
id integer NOT NULL,
project_id integer,
@@ -24545,6 +24564,49 @@ CREATE SEQUENCE work_item_widget_definitions_id_seq
ALTER SEQUENCE work_item_widget_definitions_id_seq OWNED BY work_item_widget_definitions.id;
+CREATE TABLE workspaces (
+ id bigint NOT NULL,
+ created_at timestamp with time zone NOT NULL,
+ updated_at timestamp with time zone NOT NULL,
+ user_id bigint NOT NULL,
+ project_id bigint NOT NULL,
+ cluster_agent_id bigint NOT NULL,
+ desired_state_updated_at timestamp with time zone NOT NULL,
+ responded_to_agent_at timestamp with time zone,
+ max_hours_before_termination smallint NOT NULL,
+ name text NOT NULL,
+ namespace text NOT NULL,
+ desired_state text NOT NULL,
+ actual_state text NOT NULL,
+ editor text NOT NULL,
+ devfile_ref text NOT NULL,
+ devfile_path text NOT NULL,
+ devfile text,
+ processed_devfile text,
+ url text NOT NULL,
+ deployment_resource_version text,
+ CONSTRAINT check_15543fb0fa CHECK ((char_length(name) <= 64)),
+ CONSTRAINT check_157d5f955c CHECK ((char_length(namespace) <= 64)),
+ CONSTRAINT check_2b401b0034 CHECK ((char_length(deployment_resource_version) <= 64)),
+ CONSTRAINT check_77d1a2ff50 CHECK ((char_length(processed_devfile) <= 65535)),
+ CONSTRAINT check_8e363ee3ad CHECK ((char_length(devfile_ref) <= 256)),
+ CONSTRAINT check_8e4db5ffc2 CHECK ((char_length(actual_state) <= 32)),
+ CONSTRAINT check_9e42558c35 CHECK ((char_length(url) <= 1024)),
+ CONSTRAINT check_b70eddcbc1 CHECK ((char_length(desired_state) <= 32)),
+ CONSTRAINT check_d7ed376e49 CHECK ((char_length(editor) <= 256)),
+ CONSTRAINT check_dc58d56169 CHECK ((char_length(devfile_path) <= 2048)),
+ CONSTRAINT check_eb32879a3d CHECK ((char_length(devfile) <= 65535))
+);
+
+CREATE SEQUENCE workspaces_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+ALTER SEQUENCE workspaces_id_seq OWNED BY workspaces.id;
+
CREATE TABLE x509_certificates (
id bigint NOT NULL,
created_at timestamp with time zone NOT NULL,
@@ -25544,6 +25606,8 @@ ALTER TABLE ONLY release_links ALTER COLUMN id SET DEFAULT nextval('release_link
ALTER TABLE ONLY releases ALTER COLUMN id SET DEFAULT nextval('releases_id_seq'::regclass);
+ALTER TABLE ONLY remote_development_agent_configs ALTER COLUMN id SET DEFAULT nextval('remote_development_agent_configs_id_seq'::regclass);
+
ALTER TABLE ONLY remote_mirrors ALTER COLUMN id SET DEFAULT nextval('remote_mirrors_id_seq'::regclass);
ALTER TABLE ONLY required_code_owners_sections ALTER COLUMN id SET DEFAULT nextval('required_code_owners_sections_id_seq'::regclass);
@@ -25784,6 +25848,8 @@ ALTER TABLE ONLY work_item_types ALTER COLUMN id SET DEFAULT nextval('work_item_
ALTER TABLE ONLY work_item_widget_definitions ALTER COLUMN id SET DEFAULT nextval('work_item_widget_definitions_id_seq'::regclass);
+ALTER TABLE ONLY workspaces ALTER COLUMN id SET DEFAULT nextval('workspaces_id_seq'::regclass);
+
ALTER TABLE ONLY x509_certificates ALTER COLUMN id SET DEFAULT nextval('x509_certificates_id_seq'::regclass);
ALTER TABLE ONLY x509_commit_signatures ALTER COLUMN id SET DEFAULT nextval('x509_commit_signatures_id_seq'::regclass);
@@ -27919,6 +27985,9 @@ ALTER TABLE releases
ALTER TABLE ONLY releases
ADD CONSTRAINT releases_pkey PRIMARY KEY (id);
+ALTER TABLE ONLY remote_development_agent_configs
+ ADD CONSTRAINT remote_development_agent_configs_pkey PRIMARY KEY (id);
+
ALTER TABLE ONLY remote_mirrors
ADD CONSTRAINT remote_mirrors_pkey PRIMARY KEY (id);
@@ -28330,6 +28399,9 @@ ALTER TABLE ONLY work_item_types
ALTER TABLE ONLY work_item_widget_definitions
ADD CONSTRAINT work_item_widget_definitions_pkey PRIMARY KEY (id);
+ALTER TABLE ONLY workspaces
+ ADD CONSTRAINT workspaces_pkey PRIMARY KEY (id);
+
ALTER TABLE ONLY x509_certificates
ADD CONSTRAINT x509_certificates_pkey PRIMARY KEY (id);
@@ -31263,6 +31335,8 @@ CREATE UNIQUE INDEX index_merge_request_reviewers_on_merge_request_id_and_user_i
CREATE INDEX index_merge_request_reviewers_on_user_id ON merge_request_reviewers USING btree (user_id);
+CREATE UNIQUE INDEX index_merge_request_user_mentions_note_id_convert_to_bigint ON merge_request_user_mentions USING btree (note_id_convert_to_bigint) WHERE (note_id_convert_to_bigint IS NOT NULL);
+
CREATE UNIQUE INDEX index_merge_request_user_mentions_on_note_id ON merge_request_user_mentions USING btree (note_id) WHERE (note_id IS NOT NULL);
CREATE INDEX index_merge_requests_closing_issues_on_issue_id ON merge_requests_closing_issues USING btree (issue_id);
@@ -32131,6 +32205,8 @@ CREATE UNIQUE INDEX index_releases_on_project_tag_unique ON releases USING btree
CREATE INDEX index_releases_on_released_at ON releases USING btree (released_at);
+CREATE INDEX index_remote_development_agent_configs_on_cluster_agent_id ON remote_development_agent_configs USING btree (cluster_agent_id);
+
CREATE INDEX index_remote_mirrors_on_last_successful_update_at ON remote_mirrors USING btree (last_successful_update_at);
CREATE INDEX index_remote_mirrors_on_project_id ON remote_mirrors USING btree (project_id);
@@ -32935,6 +33011,14 @@ CREATE UNIQUE INDEX index_work_item_widget_definitions_on_namespace_type_and_nam
CREATE INDEX index_work_item_widget_definitions_on_work_item_type_id ON work_item_widget_definitions USING btree (work_item_type_id);
+CREATE INDEX index_workspaces_on_cluster_agent_id ON workspaces USING btree (cluster_agent_id);
+
+CREATE UNIQUE INDEX index_workspaces_on_name ON workspaces USING btree (name);
+
+CREATE INDEX index_workspaces_on_project_id ON workspaces USING btree (project_id);
+
+CREATE INDEX index_workspaces_on_user_id ON workspaces USING btree (user_id);
+
CREATE INDEX index_x509_certificates_on_subject_key_identifier ON x509_certificates USING btree (subject_key_identifier);
CREATE INDEX index_x509_certificates_on_x509_issuer_id ON x509_certificates USING btree (x509_issuer_id);
@@ -34565,6 +34649,9 @@ ALTER TABLE ONLY user_interacted_projects
ALTER TABLE ONLY merge_request_assignment_events
ADD CONSTRAINT fk_08f7602bfd FOREIGN KEY (merge_request_id) REFERENCES merge_requests(id) ON DELETE CASCADE;
+ALTER TABLE ONLY remote_development_agent_configs
+ ADD CONSTRAINT fk_0a3c0ada56 FOREIGN KEY (cluster_agent_id) REFERENCES cluster_agents(id) ON DELETE CASCADE;
+
ALTER TABLE ONLY dast_sites
ADD CONSTRAINT fk_0a57f2271b FOREIGN KEY (dast_site_validation_id) REFERENCES dast_site_validations(id) ON DELETE SET NULL;
@@ -35252,6 +35339,9 @@ ALTER TABLE ONLY resource_link_events
ALTER TABLE ONLY metrics_users_starred_dashboards
ADD CONSTRAINT fk_bd6ae32fac FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
+ALTER TABLE ONLY workspaces
+ ADD CONSTRAINT fk_bdb0b31131 FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
+
ALTER TABLE ONLY project_compliance_framework_settings
ADD CONSTRAINT fk_be413374a9 FOREIGN KEY (framework_id) REFERENCES compliance_management_frameworks(id) ON DELETE CASCADE;
@@ -35396,6 +35486,9 @@ ALTER TABLE ONLY web_hooks
ALTER TABLE ONLY security_scans
ADD CONSTRAINT fk_dbc89265b9 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE;
+ALTER TABLE ONLY workspaces
+ ADD CONSTRAINT fk_dc7c316be1 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE;
+
ALTER TABLE ONLY epics
ADD CONSTRAINT fk_dccd3f98fc FOREIGN KEY (assignee_id) REFERENCES users(id) ON DELETE SET NULL;
@@ -35501,6 +35594,9 @@ ALTER TABLE ONLY user_project_callouts
ALTER TABLE ONLY approval_merge_request_rules
ADD CONSTRAINT fk_f726c79756 FOREIGN KEY (scan_result_policy_id) REFERENCES scan_result_policies(id) ON DELETE CASCADE;
+ALTER TABLE ONLY workspaces
+ ADD CONSTRAINT fk_f78aeddc77 FOREIGN KEY (cluster_agent_id) REFERENCES cluster_agents(id) ON DELETE CASCADE;
+
ALTER TABLE ONLY cluster_agents
ADD CONSTRAINT fk_f7d43dee13 FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE SET NULL;
@@ -35531,6 +35627,9 @@ ALTER TABLE ONLY issues
ALTER TABLE ONLY geo_event_log
ADD CONSTRAINT fk_geo_event_log_on_geo_event_id FOREIGN KEY (geo_event_id) REFERENCES geo_events(id) ON DELETE CASCADE;
+ALTER TABLE ONLY merge_request_user_mentions
+ ADD CONSTRAINT fk_merge_request_user_mentions_note_id_convert_to_bigint FOREIGN KEY (note_id_convert_to_bigint) REFERENCES notes(id) ON DELETE CASCADE NOT VALID;
+
ALTER TABLE ONLY ml_candidate_metrics
ADD CONSTRAINT fk_ml_candidate_metrics_on_candidate_id FOREIGN KEY (candidate_id) REFERENCES ml_candidates(id) ON DELETE CASCADE;
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index 9babc0dfcbd563d058d8b760d7e6d64c8fa4a1ae..82745c69a155ccdc2c898996e45dca7d9ac6b310 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -770,6 +770,42 @@ Returns [`WorkItem`](#workitem).
| ---- | ---- | ----------- |
| `id` | [`WorkItemID!`](#workitemid) | Global ID of the work item. |
+### `Query.workspace`
+
+Find a workspace.
+
+WARNING:
+**Introduced** in 16.0.
+This feature is an Experiment. It can be changed or removed at any time.
+
+Returns [`Workspace`](#workspace).
+
+#### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `id` | [`RemoteDevelopmentWorkspaceID!`](#remotedevelopmentworkspaceid) | Find a workspace by its ID. |
+
+### `Query.workspaces`
+
+Find workspaces owned by the current user by their IDs.
+
+WARNING:
+**Introduced** in 16.0.
+This feature is an Experiment. It can be changed or removed at any time.
+
+Returns [`WorkspaceConnection`](#workspaceconnection).
+
+This field returns a [connection](#connections). It accepts the
+four standard [pagination arguments](#connection-pagination-arguments):
+`before: String`, `after: String`, `first: Int`, `last: Int`.
+
+#### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `ids` | [`[RemoteDevelopmentWorkspaceID!]`](#remotedevelopmentworkspaceid) | Array of global workspace IDs. For example, `["gid://gitlab/RemoteDevelopment::Workspace/1"]`. |
+
## `Mutation` type
The `Mutation` type contains all the mutations you can execute.
@@ -6835,6 +6871,59 @@ Input type: `WorkItemUpdateTaskInput`
| `task` | [`WorkItem`](#workitem) | Updated task. |
| `workItem` | [`WorkItem`](#workitem) | Updated work item. |
+### `Mutation.workspaceCreate`
+
+WARNING:
+**Introduced** in 16.0.
+This feature is an Experiment. It can be changed or removed at any time.
+
+Input type: `WorkspaceCreateInput`
+
+#### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| `clusterAgentId` | [`ClustersAgentID!`](#clustersagentid) | ID of the cluster agent the created workspace will be associated with. |
+| `desiredState` | [`String!`](#string) | Desired state of the created workspace. |
+| `devfilePath` | [`String!`](#string) | Project repo git path containing the devfile used to configure the workspace. |
+| `devfileRef` | [`String!`](#string) | Project repo git ref containing the devfile used to configure the workspace. |
+| `editor` | [`String!`](#string) | Editor to inject into the created workspace. Must match a configured template. |
+| `maxHoursBeforeTermination` | [`Int!`](#int) | Maximum hours the workspace can exist before it is automatically terminated. |
+| `projectId` | [`ProjectID!`](#projectid) | ID of the project that will provide the Devfile for the created workspace. |
+
+#### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
+| `workspace` | [`Workspace`](#workspace) | Created workspace. |
+
+### `Mutation.workspaceUpdate`
+
+WARNING:
+**Introduced** in 16.0.
+This feature is an Experiment. It can be changed or removed at any time.
+
+Input type: `WorkspaceUpdateInput`
+
+#### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| `desiredState` | [`String!`](#string) | Desired state of the created workspace. |
+| `id` | [`RemoteDevelopmentWorkspaceID!`](#remotedevelopmentworkspaceid) | Global ID of the workspace. |
+
+#### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
+| `workspace` | [`Workspace`](#workspace) | Created workspace. |
+
## Connections
Some types in our schema are `Connection` types - they represent a paginated
@@ -11132,6 +11221,29 @@ The edge type for [`WorkItemType`](#workitemtype).
| `cursor` | [`String!`](#string) | A cursor for use in pagination. |
| `node` | [`WorkItemType`](#workitemtype) | The item at the end of the edge. |
+#### `WorkspaceConnection`
+
+The connection type for [`Workspace`](#workspace).
+
+##### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `edges` | [`[WorkspaceEdge]`](#workspaceedge) | A list of edges. |
+| `nodes` | [`[Workspace]`](#workspace) | A list of nodes. |
+| `pageInfo` | [`PageInfo!`](#pageinfo) | Information to aid in pagination. |
+
+#### `WorkspaceEdge`
+
+The edge type for [`Workspace`](#workspace).
+
+##### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `cursor` | [`String!`](#string) | A cursor for use in pagination. |
+| `node` | [`Workspace`](#workspace) | The item at the end of the edge. |
+
## Object types
Object types represent the resources that the GitLab GraphQL API can return.
@@ -16764,6 +16876,22 @@ four standard [pagination arguments](#connection-pagination-arguments):
| `state` | [`[TodoStateEnum!]`](#todostateenum) | State of the todo. |
| `type` | [`[TodoTargetEnum!]`](#todotargetenum) | Type of the todo. |
+##### `MergeRequestAssignee.workspaces`
+
+Workspaces owned by the current user.
+
+Returns [`WorkspaceConnection`](#workspaceconnection).
+
+This field returns a [connection](#connections). It accepts the
+four standard [pagination arguments](#connection-pagination-arguments):
+`before: String`, `after: String`, `first: Int`, `last: Int`.
+
+###### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `ids` | [`[RemoteDevelopmentWorkspaceID!]`](#remotedevelopmentworkspaceid) | Array of global workspace IDs. For example, `["gid://gitlab/RemoteDevelopment::Workspace/1"]`. |
+
### `MergeRequestAuthor`
The author of the merge request.
@@ -17014,6 +17142,22 @@ four standard [pagination arguments](#connection-pagination-arguments):
| `state` | [`[TodoStateEnum!]`](#todostateenum) | State of the todo. |
| `type` | [`[TodoTargetEnum!]`](#todotargetenum) | Type of the todo. |
+##### `MergeRequestAuthor.workspaces`
+
+Workspaces owned by the current user.
+
+Returns [`WorkspaceConnection`](#workspaceconnection).
+
+This field returns a [connection](#connections). It accepts the
+four standard [pagination arguments](#connection-pagination-arguments):
+`before: String`, `after: String`, `first: Int`, `last: Int`.
+
+###### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `ids` | [`[RemoteDevelopmentWorkspaceID!]`](#remotedevelopmentworkspaceid) | Array of global workspace IDs. For example, `["gid://gitlab/RemoteDevelopment::Workspace/1"]`. |
+
### `MergeRequestDiffRegistry`
Represents the Geo sync and verification state of a Merge Request diff.
@@ -17283,6 +17427,22 @@ four standard [pagination arguments](#connection-pagination-arguments):
| `state` | [`[TodoStateEnum!]`](#todostateenum) | State of the todo. |
| `type` | [`[TodoTargetEnum!]`](#todotargetenum) | Type of the todo. |
+##### `MergeRequestParticipant.workspaces`
+
+Workspaces owned by the current user.
+
+Returns [`WorkspaceConnection`](#workspaceconnection).
+
+This field returns a [connection](#connections). It accepts the
+four standard [pagination arguments](#connection-pagination-arguments):
+`before: String`, `after: String`, `first: Int`, `last: Int`.
+
+###### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `ids` | [`[RemoteDevelopmentWorkspaceID!]`](#remotedevelopmentworkspaceid) | Array of global workspace IDs. For example, `["gid://gitlab/RemoteDevelopment::Workspace/1"]`. |
+
### `MergeRequestPermissions`
Check permissions for the current user on a merge request.
@@ -17552,6 +17712,22 @@ four standard [pagination arguments](#connection-pagination-arguments):
| `state` | [`[TodoStateEnum!]`](#todostateenum) | State of the todo. |
| `type` | [`[TodoTargetEnum!]`](#todotargetenum) | Type of the todo. |
+##### `MergeRequestReviewer.workspaces`
+
+Workspaces owned by the current user.
+
+Returns [`WorkspaceConnection`](#workspaceconnection).
+
+This field returns a [connection](#connections). It accepts the
+four standard [pagination arguments](#connection-pagination-arguments):
+`before: String`, `after: String`, `first: Int`, `last: Int`.
+
+###### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `ids` | [`[RemoteDevelopmentWorkspaceID!]`](#remotedevelopmentworkspaceid) | Array of global workspace IDs. For example, `["gid://gitlab/RemoteDevelopment::Workspace/1"]`. |
+
### `Metadata`
#### Fields
@@ -21966,6 +22142,22 @@ four standard [pagination arguments](#connection-pagination-arguments):
| `state` | [`[TodoStateEnum!]`](#todostateenum) | State of the todo. |
| `type` | [`[TodoTargetEnum!]`](#todotargetenum) | Type of the todo. |
+##### `UserCore.workspaces`
+
+Workspaces owned by the current user.
+
+Returns [`WorkspaceConnection`](#workspaceconnection).
+
+This field returns a [connection](#connections). It accepts the
+four standard [pagination arguments](#connection-pagination-arguments):
+`before: String`, `after: String`, `first: Int`, `last: Int`.
+
+###### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `ids` | [`[RemoteDevelopmentWorkspaceID!]`](#remotedevelopmentworkspaceid) | Array of global workspace IDs. For example, `["gid://gitlab/RemoteDevelopment::Workspace/1"]`. |
+
### `UserMergeRequestInteraction`
Information about a merge request given a specific user.
@@ -22950,6 +23142,35 @@ Represents a weight widget.
| `type` | [`WorkItemWidgetType`](#workitemwidgettype) | Widget type. |
| `weight` | [`Int`](#int) | Weight of the work item. |
+### `Workspace`
+
+Represents a remote development workspace.
+
+#### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `actualState` | [`String!`](#string) | Actual state of the workspace. |
+| `clusterAgent` | [`ClusterAgent!`](#clusteragent) | Kubernetes Agent associated with the workspace. |
+| `createdAt` | [`Time!`](#time) | Timestamp of workspace creation. |
+| `deploymentResourceVersion` | [`Int`](#int) | ResourceVersion of the Deployment resource for the workspace. |
+| `desiredState` | [`String!`](#string) | Desired state of the workspace. |
+| `desiredStateUpdatedAt` | [`Time!`](#time) | Timestamp of last update to desired state. |
+| `devfile` | [`String!`](#string) | Source YAML of the devfile used to configure the workspace. |
+| `devfilePath` | [`String!`](#string) | Project repo git path containing the devfile used to configure the workspace. |
+| `devfileRef` | [`String!`](#string) | Project repo git ref containing the devfile used to configure the workspace. |
+| `editor` | [`String!`](#string) | Editor used to configure the workspace. Must match a configured template. |
+| `id` | [`RemoteDevelopmentWorkspaceID!`](#remotedevelopmentworkspaceid) | Global ID of the workspace. |
+| `maxHoursBeforeTermination` | [`Int!`](#int) | Maximum hours the workspace can exist before it is automatically terminated. |
+| `name` | [`String!`](#string) | Name of the workspace in Kubernetes. |
+| `namespace` | [`String!`](#string) | Namespace of the workspace in Kubernetes. |
+| `processedDevfile` | [`String!`](#string) | Processed YAML of the devfile used to configure the workspace. |
+| `projectId` | [`ID!`](#id) | ID of the Project providing the Devfile for the workspace. |
+| `respondedToAgentAt` | [`Time`](#time) | Timestamp of last response sent to GA4K for the workspace. |
+| `updatedAt` | [`Time!`](#time) | Timestamp of last update to any mutable workspace property. |
+| `url` | [`String!`](#string) | URL of the workspace. |
+| `user` | [`UserCore!`](#usercore) | Owner of the workspace. |
+
### `X509Certificate`
Represents an X.509 certificate.
@@ -25877,6 +26098,12 @@ A `ReleasesLinkID` is a global ID. It is encoded as a string.
An example `ReleasesLinkID` is: `"gid://gitlab/Releases::Link/1"`.
+### `RemoteDevelopmentWorkspaceID`
+
+A `RemoteDevelopmentWorkspaceID` is a global ID. It is encoded as a string.
+
+An example `RemoteDevelopmentWorkspaceID` is: `"gid://gitlab/RemoteDevelopment::Workspace/1"`.
+
### `SecurityTrainingProviderID`
A `SecurityTrainingProviderID` is a global ID. It is encoded as a string.
@@ -26672,6 +26899,22 @@ four standard [pagination arguments](#connection-pagination-arguments):
| `state` | [`[TodoStateEnum!]`](#todostateenum) | State of the todo. |
| `type` | [`[TodoTargetEnum!]`](#todotargetenum) | Type of the todo. |
+###### `User.workspaces`
+
+Workspaces owned by the current user.
+
+Returns [`WorkspaceConnection`](#workspaceconnection).
+
+This field returns a [connection](#connections). It accepts the
+four standard [pagination arguments](#connection-pagination-arguments):
+`before: String`, `after: String`, `first: Int`, `last: Int`.
+
+####### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `ids` | [`[RemoteDevelopmentWorkspaceID!]`](#remotedevelopmentworkspaceid) | Array of global workspace IDs. For example, `["gid://gitlab/RemoteDevelopment::Workspace/1"]`. |
+
#### `WorkItemWidget`
Implementations:
diff --git a/ee/app/assets/javascripts/remote_development/components/create/search_projects_listbox.vue b/ee/app/assets/javascripts/remote_development/components/create/search_projects_listbox.vue
index 7753839ac62e90bfd1e94648aca99f060827e1db..9b2482a3925e85e7a48b8d9753ca9c973abc62f9 100644
--- a/ee/app/assets/javascripts/remote_development/components/create/search_projects_listbox.vue
+++ b/ee/app/assets/javascripts/remote_development/components/create/search_projects_listbox.vue
@@ -18,9 +18,8 @@ export const i18n = {
export const PROJECTS_MAX_LIMIT = 20;
-/* TODO: Consider creating a follow-up issue to convert this into a
- * vue_shared reusable component.
- * Issue: https://gitlab.com/gitlab-org/gitlab/-/issues/407360
+/* TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/407360
+ Convert this into a vue_shared reusable component.
*/
export default {
components: {
diff --git a/ee/app/assets/javascripts/remote_development/components/list/workspace_state_indicator.vue b/ee/app/assets/javascripts/remote_development/components/list/workspace_state_indicator.vue
index 07fc19502166272afd2c03d64be3a3fcde7fc17d..aa3da498bb066e69004391597f66150d111395b4 100644
--- a/ee/app/assets/javascripts/remote_development/components/list/workspace_state_indicator.vue
+++ b/ee/app/assets/javascripts/remote_development/components/list/workspace_state_indicator.vue
@@ -83,5 +83,6 @@ export default {
:aria-label="iconLabel"
class="workspace-state-indicator"
:class="iconClass"
+ data-testid="workspace-state-indicator"
/>
diff --git a/ee/app/assets/javascripts/remote_development/graphql/mutations/workspace_create.mutation.graphql b/ee/app/assets/javascripts/remote_development/graphql/mutations/workspace_create.mutation.graphql
index 99ca46acfff96cc7f9653982d7606a96ae3e142a..600e80181644131028e38389de4247b6fd5a467b 100644
--- a/ee/app/assets/javascripts/remote_development/graphql/mutations/workspace_create.mutation.graphql
+++ b/ee/app/assets/javascripts/remote_development/graphql/mutations/workspace_create.mutation.graphql
@@ -1,5 +1,5 @@
mutation workspaceCreate($input: WorkspaceCreateInput!) {
- workspaceCreate(input: $input) @client {
+ workspaceCreate(input: $input) {
workspace {
id
name
diff --git a/ee/app/assets/javascripts/remote_development/graphql/mutations/workspace_update.mutation.graphql b/ee/app/assets/javascripts/remote_development/graphql/mutations/workspace_update.mutation.graphql
index e091af000e6dd97ae3c4c074d9ed3be7d2f8df71..e21a90b0745d15ec08cbf9cc1f208008838616a5 100644
--- a/ee/app/assets/javascripts/remote_development/graphql/mutations/workspace_update.mutation.graphql
+++ b/ee/app/assets/javascripts/remote_development/graphql/mutations/workspace_update.mutation.graphql
@@ -1,5 +1,5 @@
-mutation updateWorkspace($input: WorkspaceUpdateInput!) {
- workspaceUpdate(input: $input) @client {
+mutation workspaceUpdate($input: WorkspaceUpdateInput!) {
+ workspaceUpdate(input: $input) {
workspace {
id
actualState
diff --git a/ee/app/assets/javascripts/remote_development/graphql/queries/user_workspaces_list.query.graphql b/ee/app/assets/javascripts/remote_development/graphql/queries/user_workspaces_list.query.graphql
index 15ca0a4de97b6d27a0e30c233ac603c34e904cf7..704ceaca1980c06c33aa4fca69055a1a8b7cc303 100644
--- a/ee/app/assets/javascripts/remote_development/graphql/queries/user_workspaces_list.query.graphql
+++ b/ee/app/assets/javascripts/remote_development/graphql/queries/user_workspaces_list.query.graphql
@@ -3,7 +3,7 @@
query userWorkspacesList($first: Int, $before: String, $after: String) {
currentUser {
id
- workspaces(first: $first, before: $before, after: $after) @client {
+ workspaces(first: $first, before: $before, after: $after) {
nodes {
id
name
diff --git a/ee/app/assets/javascripts/remote_development/init_workspaces_app.js b/ee/app/assets/javascripts/remote_development/init_workspaces_app.js
index 4941663d02564a6b535e4016ef279fbd89d32300..3237671b164f2faae794a8c911d7221e0226f5e5 100644
--- a/ee/app/assets/javascripts/remote_development/init_workspaces_app.js
+++ b/ee/app/assets/javascripts/remote_development/init_workspaces_app.js
@@ -1,84 +1,13 @@
import Vue from 'vue';
-import { random } from 'lodash';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import App from './pages/app.vue';
import createRouter from './router/index';
-import userWorkspacesListQuery from './graphql/queries/user_workspaces_list.query.graphql';
-import { WORKSPACE_STATES, WORKSPACE_DESIRED_STATES } from './constants';
Vue.use(VueApollo);
-const generateDummyWorkspace = (actualState, desiredState, createdAt = new Date()) => {
- const id = random(0, 100000).toString(16).substring(0, 9);
-
- return {
- id: `gid://gitlab/RemoteDevelopment::Workspace/${id}`,
- name: `workspace-1-1-${id}`,
- namespace: `gl-rd-ns-1-1-${id}`,
- url: 'http://8000-workspace-1-1-idmi02.workspaces.localdev.me?tkn=password',
- devfileRef: 'main',
- devfilePath: '.devfile.yaml',
- actualState,
- desiredState,
- createdAt: createdAt.toISOString(),
- projectId: random(0, 1) === 1 ? 'gid://gitlab/Project/2' : 'gid://gitlab/Project/2qweqweqw',
- };
-};
-
const createApolloProvider = () => {
- const defaultClient = createDefaultClient({
- Mutation: {
- workspaceCreate: () => {
- return {
- workspace: generateDummyWorkspace(
- WORKSPACE_STATES.creationRequested,
- WORKSPACE_STATES.running,
- ),
- errors: [],
- };
- },
- },
- });
- // what: Dummy data to support development
- defaultClient.cache.writeQuery({
- query: userWorkspacesListQuery,
- data: {
- currentUser: {
- id: 1,
- workspaces: {
- nodes: [
- generateDummyWorkspace(WORKSPACE_STATES.running, WORKSPACE_DESIRED_STATES.running),
- generateDummyWorkspace(
- WORKSPACE_STATES.creationRequested,
- WORKSPACE_DESIRED_STATES.restartRequested,
- ),
- generateDummyWorkspace(WORKSPACE_STATES.starting, WORKSPACE_DESIRED_STATES.stopped),
- generateDummyWorkspace(
- WORKSPACE_STATES.terminating,
- WORKSPACE_DESIRED_STATES.terminated,
- ),
- generateDummyWorkspace(WORKSPACE_STATES.stopped, WORKSPACE_DESIRED_STATES.terminated),
- generateDummyWorkspace(WORKSPACE_STATES.stopping, WORKSPACE_DESIRED_STATES.running),
- generateDummyWorkspace(WORKSPACE_STATES.terminated, WORKSPACE_DESIRED_STATES.running),
- generateDummyWorkspace(
- WORKSPACE_STATES.terminated,
- WORKSPACE_DESIRED_STATES.running,
- new Date(2023, 0, 1),
- ),
- generateDummyWorkspace(WORKSPACE_STATES.failed, WORKSPACE_DESIRED_STATES.running),
- generateDummyWorkspace(WORKSPACE_STATES.error, WORKSPACE_DESIRED_STATES.running),
- ],
- pageInfo: {
- hasNextPage: true,
- hasPreviousPage: false,
- startCursor: null,
- endCursor: null,
- },
- },
- },
- },
- });
+ const defaultClient = createDefaultClient();
return new VueApollo({ defaultClient });
};
diff --git a/ee/app/assets/javascripts/remote_development/pages/create.vue b/ee/app/assets/javascripts/remote_development/pages/create.vue
index 7c8115bda8ff75f6cc93ef2cfeed468f063c0f0f..29220d094babcc0cb6b62d3a19995fc99369af0d 100644
--- a/ee/app/assets/javascripts/remote_development/pages/create.vue
+++ b/ee/app/assets/javascripts/remote_development/pages/create.vue
@@ -80,7 +80,6 @@ export default {
isCreatingWorkspace: false,
clusterAgents: [],
hasDevFile: null,
- groupPath: null,
projectId: null,
rootRef: null,
maxHoursBeforeTermination: DEFAULT_MAX_HOURS_BEFORE_TERMINATION,
diff --git a/ee/app/assets/javascripts/remote_development/services/apollo_cache_mutators.js b/ee/app/assets/javascripts/remote_development/services/apollo_cache_mutators.js
index dc053981b2b62a577950e08e3015ad425b174328..a663ef34b391b5cfaf73c88fb35b25828a5e5d40 100644
--- a/ee/app/assets/javascripts/remote_development/services/apollo_cache_mutators.js
+++ b/ee/app/assets/javascripts/remote_development/services/apollo_cache_mutators.js
@@ -1,15 +1,21 @@
import produce from 'immer';
import userWorkspacesQuery from '../graphql/queries/user_workspaces_list.query.graphql';
+import { WORKSPACES_LIST_PAGE_SIZE } from '../constants';
export const addWorkspace = (store, workspace) => {
- store.updateQuery({ query: userWorkspacesQuery }, (sourceData) =>
- produce(sourceData, (draftData) => {
- // If there's nothing in the query we don't really need to update it. It should just refetch naturally.
- if (!draftData) {
- return;
- }
+ store.updateQuery(
+ {
+ query: userWorkspacesQuery,
+ variables: { after: null, before: null, first: WORKSPACES_LIST_PAGE_SIZE },
+ },
+ (sourceData) =>
+ produce(sourceData, (draftData) => {
+ // If there's nothing in the query we don't really need to update it. It should just refetch naturally.
+ if (!draftData) {
+ return;
+ }
- draftData.currentUser.workspaces.nodes.unshift(workspace);
- }),
+ draftData.currentUser.workspaces.nodes.unshift(workspace);
+ }),
);
};
diff --git a/ee/app/finders/remote_development/workspaces_finder.rb b/ee/app/finders/remote_development/workspaces_finder.rb
new file mode 100644
index 0000000000000000000000000000000000000000..66c8e7d8ec1cf44becc89f248534e9d608fa8113
--- /dev/null
+++ b/ee/app/finders/remote_development/workspaces_finder.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module RemoteDevelopment
+ class WorkspacesFinder < UnionFinder
+ attr_reader :current_user, :params
+
+ def initialize(current_user, params = {})
+ @current_user = current_user
+ @params = params
+ end
+
+ def execute
+ return Workspace.none unless current_user&.can?(:read_workspace)
+
+ items = current_user.workspaces
+ items = by_ids(items)
+
+ items.order_by('id_desc')
+ end
+
+ private
+
+ def by_ids(items)
+ return items unless params[:ids].present?
+
+ items.id_in(params[:ids])
+ end
+ end
+end
diff --git a/ee/app/graphql/ee/types/mutation_type.rb b/ee/app/graphql/ee/types/mutation_type.rb
index 04ca0a36d949dd2963ea0884f364fe2402ad8caf..757b33cc94f9994a926358bcd1aa67a4c0dccc34 100644
--- a/ee/app/graphql/ee/types/mutation_type.rb
+++ b/ee/app/graphql/ee/types/mutation_type.rb
@@ -100,6 +100,8 @@ module MutationType
mount_mutation ::Mutations::AuditEvents::ExternalAuditEventDestinations::Update
mount_mutation ::Mutations::Ci::NamespaceCiCdSettingsUpdate
mount_mutation ::Mutations::Ci::Catalog::Resources::Create, alpha: { milestone: '15.11' }
+ mount_mutation ::Mutations::RemoteDevelopment::Workspaces::Create, alpha: { milestone: '16.0' }
+ mount_mutation ::Mutations::RemoteDevelopment::Workspaces::Update, alpha: { milestone: '16.0' }
mount_mutation ::Mutations::AuditEvents::Streaming::Headers::Destroy
mount_mutation ::Mutations::AuditEvents::Streaming::Headers::Create
mount_mutation ::Mutations::AuditEvents::Streaming::Headers::Update
diff --git a/ee/app/graphql/ee/types/query_type.rb b/ee/app/graphql/ee/types/query_type.rb
index ea7f4fdd3cede0b822aef19e86bd38d1a220db93..c50fee320784a83a5162573144e0381492f26848 100644
--- a/ee/app/graphql/ee/types/query_type.rb
+++ b/ee/app/graphql/ee/types/query_type.rb
@@ -71,7 +71,20 @@ module QueryType
required: true,
description: 'Global ID of the Vulnerability.'
end
-
+ field :workspace, ::Types::RemoteDevelopment::WorkspaceType,
+ null: true,
+ alpha: { milestone: '16.0' },
+ description: 'Find a workspace.' do
+ argument :id, ::Types::GlobalIDType[::RemoteDevelopment::Workspace],
+ required: true,
+ description: 'Find a workspace by its ID.'
+ end
+ field :workspaces,
+ ::Types::RemoteDevelopment::WorkspaceType.connection_type,
+ null: true,
+ alpha: { milestone: '16.0' },
+ resolver: ::Resolvers::RemoteDevelopment::WorkspacesResolver,
+ description: 'Find workspaces owned by the current user by their IDs.'
field :ci_catalog_resources,
::Types::Ci::Catalog::ResourceType.connection_type,
null: true,
@@ -94,6 +107,26 @@ def iteration(id:)
::GitlabSchema.find_by_gid(id)
end
+ def workspace(id:)
+ unless ::Feature.enabled?(:remote_development_feature_flag)
+ # TODO: Could have `included Gitlab::Graphql::Authorize::AuthorizeResource` and then use
+ # raise_resource_not_available_error!, but didn't want to take the risk to mix that into
+ # the root query type
+ raise ::Gitlab::Graphql::Errors::ResourceNotAvailable,
+ "'remote_development_feature_flag' feature flag is disabled"
+ end
+
+ unless License.feature_available?(:remote_development)
+ # TODO: Could have `included Gitlab::Graphql::Authorize::AuthorizeResource` and then use
+ # raise_resource_not_available_error!, but didn't want to take the risk to mix that into
+ # the root query type
+ raise ::Gitlab::Graphql::Errors::ResourceNotAvailable,
+ "'remote_development' licensed feature is not available"
+ end
+
+ ::GitlabSchema.find_by_gid(id)
+ end
+
def ci_minutes_usage(namespace_id: nil, date: nil)
root_namespace = find_root_namespace(namespace_id)
if date
diff --git a/ee/app/graphql/ee/types/user_interface.rb b/ee/app/graphql/ee/types/user_interface.rb
new file mode 100644
index 0000000000000000000000000000000000000000..f09268fe22abf3a96e17dcb3b9025ce101b00920
--- /dev/null
+++ b/ee/app/graphql/ee/types/user_interface.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module EE
+ module Types
+ module UserInterface
+ extend ActiveSupport::Concern
+
+ prepended do
+ field :workspaces,
+ description: 'Workspaces owned by the current user.',
+ resolver: ::Resolvers::RemoteDevelopment::WorkspacesResolver
+ end
+ end
+ end
+end
diff --git a/ee/app/graphql/mutations/remote_development/workspaces/create.rb b/ee/app/graphql/mutations/remote_development/workspaces/create.rb
new file mode 100644
index 0000000000000000000000000000000000000000..20babd00f6fa1d383283d8305ec5c96237172fbf
--- /dev/null
+++ b/ee/app/graphql/mutations/remote_development/workspaces/create.rb
@@ -0,0 +1,83 @@
+# frozen_string_literal: true
+
+module Mutations
+ module RemoteDevelopment
+ module Workspaces
+ # noinspection RubyMismatchedArgumentType
+ class Create < BaseMutation
+ graphql_name 'WorkspaceCreate'
+
+ authorize :create_workspace
+
+ field :workspace,
+ Types::RemoteDevelopment::WorkspaceType,
+ null: true,
+ description: 'Created workspace.'
+
+ argument :cluster_agent_id,
+ ::Types::GlobalIDType[::Clusters::Agent],
+ required: true,
+ description: 'ID of the cluster agent the created workspace will be associated with.'
+
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/409772 - Make this a type:enum
+ argument :desired_state,
+ GraphQL::Types::String,
+ required: true,
+ description: 'Desired state of the created workspace.'
+
+ argument :editor,
+ GraphQL::Types::String,
+ required: true,
+ description: 'Editor to inject into the created workspace. Must match a configured template.'
+
+ argument :max_hours_before_termination,
+ GraphQL::Types::Int,
+ required: true,
+ description: 'Maximum hours the workspace can exist before it is automatically terminated.'
+
+ argument :project_id,
+ ::Types::GlobalIDType[::Project],
+ required: true,
+ description: 'ID of the project that will provide the Devfile for the created workspace.'
+
+ argument :devfile_ref,
+ GraphQL::Types::String,
+ required: true,
+ description: 'Project repo git ref containing the devfile used to configure the workspace.'
+
+ argument :devfile_path,
+ GraphQL::Types::String,
+ required: true,
+ description: 'Project repo git path containing the devfile used to configure the workspace.'
+
+ def resolve(args)
+ unless Feature.enabled?(:remote_development_feature_flag)
+ raise_resource_not_available_error!("'remote_development_feature_flag' feature flag is disabled")
+ end
+
+ unless License.feature_available?(:remote_development)
+ raise_resource_not_available_error!("'remote_development' licensed feature is not available")
+ end
+
+ project_id = args.delete(:project_id)
+ project = authorized_find!(id: project_id)
+
+ cluster_agent_id = args.delete(:cluster_agent_id)
+
+ agent = authorized_find!(id: cluster_agent_id)
+
+ service = ::RemoteDevelopment::Workspaces::CreateService.new(current_user: current_user)
+ params = args.merge(agent: agent, user: current_user, project: project)
+ response = service.execute(params: params)
+
+ response_object = response.success? ? response.payload[:workspace] : nil
+
+ {
+ workspace: response_object,
+ errors: response.errors
+ }
+ end
+ end
+ end
+ end
+end
diff --git a/ee/app/graphql/mutations/remote_development/workspaces/update.rb b/ee/app/graphql/mutations/remote_development/workspaces/update.rb
new file mode 100644
index 0000000000000000000000000000000000000000..5e865aa9e6bc5f2311ecfccc86ca0563ab743327
--- /dev/null
+++ b/ee/app/graphql/mutations/remote_development/workspaces/update.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+module Mutations
+ module RemoteDevelopment
+ module Workspaces
+ class Update < BaseMutation
+ graphql_name 'WorkspaceUpdate'
+
+ authorize :update_workspace
+
+ field :workspace,
+ Types::RemoteDevelopment::WorkspaceType,
+ null: true,
+ description: 'Created workspace.'
+
+ argument :id, ::Types::GlobalIDType[::RemoteDevelopment::Workspace],
+ required: true,
+ description: copy_field_description(Types::RemoteDevelopment::WorkspaceType, :id)
+
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/409772 - Make this a type:enum
+ argument :desired_state,
+ GraphQL::Types::String,
+ required: true, # NOTE: This is required, because it is the only mutable field.
+ description: 'Desired state of the created workspace.'
+
+ def resolve(id:, **args)
+ unless Feature.enabled?(:remote_development_feature_flag)
+ raise_resource_not_available_error!("'remote_development_feature_flag' feature flag is disabled")
+ end
+
+ unless License.feature_available?(:remote_development)
+ raise_resource_not_available_error!("'remote_development' licensed feature is not available")
+ end
+
+ workspace = authorized_find!(id: id)
+
+ service = ::RemoteDevelopment::Workspaces::UpdateService.new(current_user: current_user)
+ response = service.execute(workspace: workspace, params: args)
+
+ response_object = response.success? ? response.payload[:workspace] : nil
+
+ {
+ workspace: response_object,
+ errors: response.errors
+ }
+ end
+
+ private
+
+ def find_object(id:)
+ GitlabSchema.find_by_gid(id)
+ end
+ end
+ end
+ end
+end
diff --git a/ee/app/graphql/resolvers/remote_development/workspaces_resolver.rb b/ee/app/graphql/resolvers/remote_development/workspaces_resolver.rb
new file mode 100644
index 0000000000000000000000000000000000000000..fb7978f60255bf9fa3aa43de8c226e7e2f83f610
--- /dev/null
+++ b/ee/app/graphql/resolvers/remote_development/workspaces_resolver.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module RemoteDevelopment
+ class WorkspacesResolver < ::Resolvers::BaseResolver
+ include ResolvesIds
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+
+ type Types::RemoteDevelopment::WorkspaceType.connection_type, null: true
+
+ argument :ids, [::Types::GlobalIDType[::RemoteDevelopment::Workspace]],
+ required: false,
+ description:
+ 'Array of global workspace IDs. For example, `["gid://gitlab/RemoteDevelopment::Workspace/1"]`.'
+
+ def resolve(**args)
+ unless ::Feature.enabled?(:remote_development_feature_flag)
+ # noinspection RubyMismatchedArgumentType
+ raise ::Gitlab::Graphql::Errors::ResourceNotAvailable,
+ "'remote_development_feature_flag' feature flag is disabled"
+ end
+
+ unless License.feature_available?(:remote_development)
+ raise_resource_not_available_error! "'remote_development' licensed feature is not available"
+ end
+
+ ::RemoteDevelopment::WorkspacesFinder.new(
+ current_user,
+ { ids: resolve_ids(args[:ids]) }
+ ).execute
+ end
+ end
+ end
+end
diff --git a/ee/app/graphql/types/remote_development/workspace_type.rb b/ee/app/graphql/types/remote_development/workspace_type.rb
new file mode 100644
index 0000000000000000000000000000000000000000..496205ba097a461531ce66aa52cfa888f84a69c3
--- /dev/null
+++ b/ee/app/graphql/types/remote_development/workspace_type.rb
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+
+module Types
+ module RemoteDevelopment
+ class WorkspaceType < ::Types::BaseObject
+ graphql_name 'Workspace'
+ description 'Represents a remote development workspace'
+
+ authorize :read_workspace
+
+ field :id, ::Types::GlobalIDType[::RemoteDevelopment::Workspace],
+ null: false, description: 'Global ID of the workspace.'
+
+ field :cluster_agent, ::Types::Clusters::AgentType,
+ null: false,
+ method: :agent,
+ description: 'Kubernetes Agent associated with the workspace.'
+
+ field :project_id, GraphQL::Types::ID,
+ null: false, description: 'ID of the Project providing the Devfile for the workspace.'
+
+ field :user, ::Types::UserType,
+ null: false, description: 'Owner of the workspace.'
+
+ field :name, GraphQL::Types::String,
+ null: false, description: 'Name of the workspace in Kubernetes.'
+
+ field :namespace, GraphQL::Types::String,
+ null: false, description: 'Namespace of the workspace in Kubernetes.'
+
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/409772 - Make this a type:enum
+ field :desired_state, GraphQL::Types::String,
+ null: false, description: 'Desired state of the workspace.'
+
+ field :desired_state_updated_at, Types::TimeType,
+ null: false, description: 'Timestamp of last update to desired state.'
+
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/409772 - Make this a type:enum
+ field :actual_state, GraphQL::Types::String,
+ null: false, description: 'Actual state of the workspace.'
+
+ field :responded_to_agent_at, Types::TimeType,
+ null: true, description: 'Timestamp of last response sent to GA4K for the workspace.'
+
+ field :url, GraphQL::Types::String,
+ null: false, description: 'URL of the workspace.'
+
+ field :editor, GraphQL::Types::String,
+ null: false, description: 'Editor used to configure the workspace. Must match a configured template.'
+
+ field :max_hours_before_termination, GraphQL::Types::Int,
+ null: false, description: 'Maximum hours the workspace can exist before it is automatically terminated.'
+
+ field :devfile_ref, GraphQL::Types::String,
+ null: false, description: 'Project repo git ref containing the devfile used to configure the workspace.'
+
+ field :devfile_path, GraphQL::Types::String,
+ null: false, description: 'Project repo git path containing the devfile used to configure the workspace.'
+
+ field :devfile, GraphQL::Types::String,
+ null: false, description: 'Source YAML of the devfile used to configure the workspace.'
+
+ field :processed_devfile, GraphQL::Types::String,
+ null: false, description: 'Processed YAML of the devfile used to configure the workspace.'
+
+ field :deployment_resource_version, GraphQL::Types::Int,
+ null: true, description: 'ResourceVersion of the Deployment resource for the workspace.'
+
+ field :created_at, Types::TimeType,
+ null: false, description: 'Timestamp of workspace creation.'
+
+ field :updated_at, Types::TimeType,
+ null: false, description: 'Timestamp of last update to any mutable workspace property.'
+
+ def project_id
+ "gid://gitlab/Project/#{object.project_id}"
+ end
+ end
+ end
+end
diff --git a/ee/app/models/ee/clusters/agent.rb b/ee/app/models/ee/clusters/agent.rb
index 62a8ecf86c15321eb4353e5921055d18100671aa..d0d3bce6fbcc7464dfcc5f770140a9d1cec2517b 100644
--- a/ee/app/models/ee/clusters/agent.rb
+++ b/ee/app/models/ee/clusters/agent.rb
@@ -8,6 +8,16 @@ module Agent
prepended do
has_many :vulnerability_reads, class_name: 'Vulnerabilities::Read', foreign_key: :casted_cluster_agent_id
+ has_many :workspaces,
+ class_name: 'RemoteDevelopment::Workspace',
+ foreign_key: 'cluster_agent_id',
+ inverse_of: :agent
+
+ has_one :remote_development_agent_config,
+ class_name: 'RemoteDevelopment::RemoteDevelopmentAgentConfig',
+ inverse_of: :agent,
+ foreign_key: :cluster_agent_id
+
scope :for_projects, -> (projects) { where(project: projects) }
end
end
diff --git a/ee/app/models/ee/project.rb b/ee/app/models/ee/project.rb
index 32f7f32d6f8a0dbe67604faf7be277cd424690ba..0594f0f6039f97da9ca19d50411df6f27dc6fcae 100644
--- a/ee/app/models/ee/project.rb
+++ b/ee/app/models/ee/project.rb
@@ -93,6 +93,8 @@ def lock_for_confirmation!(id)
has_many :vulnerability_exports, class_name: 'Vulnerabilities::Export'
has_many :vulnerability_remediations, class_name: 'Vulnerabilities::Remediation', inverse_of: :project
+ has_many :workspaces, class_name: 'RemoteDevelopment::Workspace', inverse_of: :project
+
has_many :dast_site_profiles
has_many :dast_site_tokens
has_many :dast_sites
diff --git a/ee/app/models/ee/user.rb b/ee/app/models/ee/user.rb
index 99a7aa3c8964bb9072854be0ff4bf789422c4c1b..7aae0e2c7cd7f724d4ade75a2e0b6cf1ee84b656 100644
--- a/ee/app/models/ee/user.rb
+++ b/ee/app/models/ee/user.rb
@@ -93,6 +93,8 @@ module User
has_many :namespace_bans, class_name: 'Namespaces::NamespaceBan'
+ has_many :workspaces, class_name: 'RemoteDevelopment::Workspace', inverse_of: :user
+
has_many :dependency_list_exports, class_name: 'Dependencies::DependencyListExport', inverse_of: :author
scope :not_managed, ->(group: nil) {
diff --git a/ee/app/models/remote_development/remote_development_agent_config.rb b/ee/app/models/remote_development/remote_development_agent_config.rb
new file mode 100644
index 0000000000000000000000000000000000000000..5e599c59cbc1c62350b4f35ddf731243ad098bd4
--- /dev/null
+++ b/ee/app/models/remote_development/remote_development_agent_config.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module RemoteDevelopment
+ # noinspection RailsParamDefResolve
+ class RemoteDevelopmentAgentConfig < ApplicationRecord
+ belongs_to :agent,
+ class_name: 'Clusters::Agent', foreign_key: 'cluster_agent_id', inverse_of: :remote_development_agent_config
+
+ has_many :workspaces, through: :agent, source: :workspaces
+
+ validates :enabled, presence: true
+ validates :agent, presence: true
+
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/409772 - Make this a type:enum
+ validates :enabled, inclusion: { in: [true], message: 'is currently immutable, and must be set to true' }
+ # noinspection RubyResolve
+ before_validation :prevent_dns_zone_update, if: ->(record) { record.persisted? && record.dns_zone_changed? }
+
+ private
+
+ def prevent_dns_zone_update
+ errors.add(:dns_zone, _('is currently immutable, and cannot be updated. Create a new agent instead.'))
+ end
+ end
+end
diff --git a/ee/app/models/remote_development/workspace.rb b/ee/app/models/remote_development/workspace.rb
new file mode 100644
index 0000000000000000000000000000000000000000..4117a8c5317c99f7500dcf2f53990a13de8a2120
--- /dev/null
+++ b/ee/app/models/remote_development/workspace.rb
@@ -0,0 +1,76 @@
+# frozen_string_literal: true
+
+module RemoteDevelopment
+ # noinspection RailsParamDefResolve,RubyResolve
+ class Workspace < ApplicationRecord
+ include Sortable
+ include RemoteDevelopment::Workspaces::States
+
+ MAX_HOURS_BEFORE_TERMINATION_LIMIT = 120
+
+ belongs_to :user, inverse_of: :workspaces
+ belongs_to :project, inverse_of: :workspaces
+ belongs_to :agent, class_name: 'Clusters::Agent', foreign_key: 'cluster_agent_id', inverse_of: :workspaces
+
+ has_one :remote_development_agent_config, through: :agent, source: :remote_development_agent_config
+ delegate :dns_zone, to: :remote_development_agent_config, prefix: false, allow_nil: false
+
+ validates :user, presence: true
+ validates :agent, presence: true
+ validates :editor, presence: true
+
+ # Ensure that the associated agent has an existing RemoteDevelopmentAgentConfig before we allow it
+ # to be used to create a new workspace
+ validate :validate_agent_config_presence
+
+ # We do not yet support workspaces for private projects, so validate that the associated project is currently public
+ validate :validate_project_is_public
+
+ # See https://gitlab.com/gitlab-org/remote-development/gitlab-remote-development-docs/blob/main/doc/architecture.md?plain=0#workspace-states
+ # for state validation rules
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/409773
+ # Add validation preventing desired_state change if it is TERMINATED; you can't restart a terminated workspace
+ # Also reflect this in GraphQL API and Vue component UI
+ validates :desired_state, inclusion: { in: VALID_DESIRED_STATES }
+ validates :actual_state, inclusion: { in: VALID_ACTUAL_STATES }
+ validates :editor, inclusion: { in: ['webide'], message: "'webide' is currently the only supported editor" }
+ validates :max_hours_before_termination, numericality: { less_than_or_equal_to: MAX_HOURS_BEFORE_TERMINATION_LIMIT }
+
+ scope :with_desired_state_updated_more_recently_than_last_response_to_agent, -> do
+ # noinspection SqlResolve
+ where('desired_state_updated_at >= responded_to_agent_at').or(where(responded_to_agent_at: nil))
+ end
+
+ before_save :touch_desired_state_updated_at, if: ->(workspace) do
+ workspace.new_record? || workspace.desired_state_changed?
+ end
+
+ def desired_state_updated_more_recently_than_last_response_to_agent?
+ return true if responded_to_agent_at.nil?
+
+ desired_state_updated_at >= responded_to_agent_at
+ end
+
+ def terminated?
+ actual_state == TERMINATED
+ end
+
+ private
+
+ def validate_agent_config_presence
+ return true if agent.remote_development_agent_config
+
+ errors.add(:agent, _('for Workspace must have an associated RemoteDevelopmentAgentConfig'))
+ end
+
+ def validate_project_is_public
+ return true if project.public?
+
+ errors.add(:project, _('for Workspace is required to be public'))
+ end
+
+ def touch_desired_state_updated_at
+ self.desired_state_updated_at = Time.current.utc
+ end
+ end
+end
diff --git a/ee/app/policies/ee/global_policy.rb b/ee/app/policies/ee/global_policy.rb
index 63fbc95d8e70b3920d59e35b43c13efcde5e7db3..0efa7bde405f9bce286aac9acbbe3f15999d1eae 100644
--- a/ee/app/policies/ee/global_policy.rb
+++ b/ee/app/policies/ee/global_policy.rb
@@ -52,9 +52,7 @@ module GlobalPolicy
rule { ~anonymous & operations_dashboard_available }.enable :read_operations_dashboard
- rule { ~anonymous & remote_development_available }.policy do
- enable :read_workspace
- end
+ rule { ~anonymous & remote_development_available }.enable :read_workspace
rule { admin & instance_devops_adoption_available }.policy do
enable :manage_devops_adoption_namespaces
diff --git a/ee/app/policies/ee/project_policy.rb b/ee/app/policies/ee/project_policy.rb
index 4d6e1c24ddee298ec20aeb86135ea39aeeb265de..38623ebe5559d7aa56bfbc85d9d3bcc47b5bbb48 100644
--- a/ee/app/policies/ee/project_policy.rb
+++ b/ee/app/policies/ee/project_policy.rb
@@ -266,6 +266,7 @@ module ProjectPolicy
enable :admin_feature_flags_issue_links
enable :read_project_audit_events
enable :read_product_analytics
+ enable :create_workspace
end
rule { can?(:reporter_access) & iterations_available }.policy do
diff --git a/ee/app/policies/remote_development/workspace_policy.rb b/ee/app/policies/remote_development/workspace_policy.rb
new file mode 100644
index 0000000000000000000000000000000000000000..c74361008bf1a6510ce58180737a458b1afd0efb
--- /dev/null
+++ b/ee/app/policies/remote_development/workspace_policy.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module RemoteDevelopment
+ class WorkspacePolicy < BasePolicy
+ delegate { subject.project }
+
+ condition(:workspace_owner) { user.id == workspace&.user_id }
+
+ # noinspection RubyResolve
+ rule { workspace_owner & can?(:developer_access) }.enable :update_workspace
+
+ # noinspection RubyResolve
+ rule { workspace_owner }.enable :read_workspace
+
+ def workspace
+ subject
+ end
+ end
+end
diff --git a/ee/app/services/remote_development/agent_config/update_service.rb b/ee/app/services/remote_development/agent_config/update_service.rb
new file mode 100644
index 0000000000000000000000000000000000000000..5ba2e48b85a552ca2811e0dd905f424f89d9c4f4
--- /dev/null
+++ b/ee/app/services/remote_development/agent_config/update_service.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+module RemoteDevelopment
+ module AgentConfig
+ class UpdateService
+ def execute(agent:, config:)
+ # NOTE: We rely on the authentication from the internal kubernetes endpoint and kas so we don't do any
+ # additional authorization checks here.
+ # See https://gitlab.com/gitlab-org/gitlab/-/issues/409038
+
+ payload, error = RemoteDevelopment::AgentConfig::UpdateProcessor.new.process(agent: agent, config: config)
+
+ # TODO: https://gitlab.com/groups/gitlab-org/-/epics/10461
+ # The other existing service called from the `internal/kubernetes/agent_configuration` API endpoint
+ # (::Clusters::Agents::RefreshAuthorizationService) does not use ServiceResponse, it just returns a
+ # boolean value. So we do the same (return the payload, which is truthy) for consistency.
+ # The `internal/kubernetes/agent_configuration` endpoint explictly returns
+ # `no_content!` regardless of the return value, so it wouldn't matter what we returned anyway.
+ # We _don't_ want to change this behavior for now or return an exception in the case of failure,
+ # because that could potentially interfere with the existing behavior of the endpoint, which is
+ # to execute ::Clusters::Agents::RefreshAuthorizationService. So, it's safer to just silently fail to
+ # save the record, log an error, return a boolean for now. We should look into fixing this properly as
+ # part of https://gitlab.com/gitlab-org/gitlab/-/issues/402718 or another error handling issue.
+ #
+ # Note that we have abstracted this logic to our domain-layer tier in `lib/remote_development`,
+ # with our standard `[payload, RemoteDevelopment::Error]` tuple return value,
+ # so that abstracts us somewhat from whatever we decide to do with this error handling at the Service
+ # layer.
+ #
+ # Also note that currently, this will always be expected to fail if `enabled: false` is specified, because
+ # for the initial release, we are enforcing that all config attributes (including `enabled`) are
+ # immutable, and thus enabled must be set to true upon creation.
+
+ return false if error
+
+ payload
+ end
+ end
+ end
+end
diff --git a/ee/app/services/remote_development/workspaces/create_service.rb b/ee/app/services/remote_development/workspaces/create_service.rb
new file mode 100644
index 0000000000000000000000000000000000000000..84fc8edffdd70283c2e1eeb47767294c0a1e9151
--- /dev/null
+++ b/ee/app/services/remote_development/workspaces/create_service.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+module RemoteDevelopment
+ module Workspaces
+ class CreateService
+ attr_reader :current_user
+
+ # NOTE: This constructor intentionally does not follow all of the conventions from
+ # https://docs.gitlab.com/ee/development/reusing_abstractions.html#service-classes
+ # suggesting that the dependencies be passed via the constructor. This is because
+ # the RemoteDevelopment feature architecture follows a more pure-functional style,
+ # by avoiding instance variables and instance state and preferring to pass data
+ # directly in method calls rather than via constructor. We also don't use any of the
+ # provided superclasses like BaseContainerService or its descendants, because all of the
+ # domain logic is isolated and decoupled to the architectural tier below this,
+ # i.e. in the `*Processor` classes, and therefore these superclasses provide nothing
+ # of use. However, we do still conform to the convention of passing the current_user
+ # in the constructor, since this convention is related to security, and worth following
+ # the existing patterns and principle of least surprise.
+ #
+ # See https://gitlab.com/gitlab-org/remote-development/gitlab-remote-development-docs/-/blob/main/doc/remote-development-feature-architectural-standards.md
+ # for more discussion on this topic.
+ def initialize(current_user:)
+ @current_user = current_user
+ end
+
+ # noinspection RubyNilAnalysis,RubyResolve
+ def execute(params:)
+ project = params[:project]
+ return ServiceResponse.error(message: 'Unauthorized', reason: :unauthorized) unless authorized?(project)
+
+ payload, error = RemoteDevelopment::Workspaces::Create::CreateProcessor.new.process(params: params)
+
+ if error
+ ServiceResponse.error(message: error.message, reason: error.reason)
+ else
+ ServiceResponse.success(payload: payload)
+ end
+ end
+
+ private
+
+ def authorized?(project)
+ current_user&.can?(:create_workspace, project)
+ end
+ end
+ end
+end
diff --git a/ee/app/services/remote_development/workspaces/reconcile_service.rb b/ee/app/services/remote_development/workspaces/reconcile_service.rb
new file mode 100644
index 0000000000000000000000000000000000000000..4a6699551a20c6c015f78fb39f80a9dd3104233e
--- /dev/null
+++ b/ee/app/services/remote_development/workspaces/reconcile_service.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+module RemoteDevelopment
+ module Workspaces
+ class ReconcileService
+ # NOTE: This class intentionally does not follow the constructor conventions from
+ # https://docs.gitlab.com/ee/development/reusing_abstractions.html#service-classes
+ # suggesting that the dependencies be passed via the constructor. This is because
+ # the RemoteDevelopment feature architecture follows a more pure-functional style,
+ # directly in method calls rather than via constructor. We also don't use any of the
+ # provided superclasses like BaseContainerService or its descendants, because all of the
+ # domain logic is isolated and decoupled to the architectural tier below this,
+ # i.e. in the `*Processor` classes, and therefore these superclasses provide nothing
+ # of use. In this case we also do not even pass the `current_user:` parameter, because this
+ # service is called from GA4K kas from an internal kubernetes endpoint, and thus there
+ # is no current_user in context. Therefore we have no need for a constructor at all.
+ #
+ # See https://gitlab.com/gitlab-org/remote-development/gitlab-remote-development-docs/-/blob/main/doc/remote-development-feature-architectural-standards.md
+ # for more discussion on this topic.
+
+ def execute(agent:, params:)
+ # NOTE: We rely on the authentication from the internal kubernetes endpoint and kas so we don't do any
+ # additional authorization checks here.
+ # See https://gitlab.com/gitlab-org/gitlab/-/issues/409038
+
+ # TODO: https://gitlab.com/groups/gitlab-org/-/epics/10461
+ # We need to perform all processing in an explicit transaction, so that any unexpected exceptions will
+ # cause the transaction to be rolled back. This might not be necessary if we weren't having to rescue
+ # all exceptions below due to another problem. See the to do comment below in rescue clause for more info
+ ApplicationRecord.transaction do
+ process(agent, params)
+ end
+ rescue => e # rubocop:disable Style:RescueStandardError
+ # TODO: https://gitlab.com/groups/gitlab-org/-/epics/10461
+ # If anything in the service class throws an exception, it ends up calling
+ # #handle_api_exception, in lib/api/helpers.rb, which tries to get current_user,
+ # which ends up calling API::Helpers#unauthorized! in lib/api/helpers.rb,
+ # when ends up setting @current_user to a Rack::Response, which blows up later
+ # in API::Helpers#current_user (lib/api/helpers.rb#79), when we try to get
+ # #preferred_language off of it.
+ # So we have to catch all exceptions and handle as a ServiceResponse.error
+ # in order to avoid this.
+ # How do the other ga4k requests like starboard_vulnerability handle this?
+ # UPDATE: See more context in https://gitlab.com/gitlab-org/gitlab/-/issues/402718#note_1343933650
+
+ Gitlab::ErrorTracking.track_exception(e, error_type: 'reconcile', agent_id: agent.id)
+ ServiceResponse.error(
+ message: "Unexpected reconcile error. Exception class: #{e.class}.",
+ reason: :internal_server_error
+ )
+ end
+
+ private
+
+ def process(agent, params)
+ parsed_params, error = RemoteDevelopment::Workspaces::Reconcile::ParamsParser.new.parse(params: params)
+ return ServiceResponse.error(message: error.message, reason: error.reason) if error
+
+ reconcile_processor = Reconcile::ReconcileProcessor.new
+ payload, error = reconcile_processor.process(agent: agent, **parsed_params)
+
+ return ServiceResponse.error(message: error.message, reason: error.reason) if error
+
+ ServiceResponse.success(payload: payload)
+ end
+ end
+ end
+end
diff --git a/ee/app/services/remote_development/workspaces/update_service.rb b/ee/app/services/remote_development/workspaces/update_service.rb
new file mode 100644
index 0000000000000000000000000000000000000000..50d7b5c47c6bed135ba7139b477b24a8fd5524b8
--- /dev/null
+++ b/ee/app/services/remote_development/workspaces/update_service.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+module RemoteDevelopment
+ module Workspaces
+ class UpdateService
+ attr_reader :current_user
+
+ # NOTE: This constructor intentionally does not follow all of the conventions from
+ # https://docs.gitlab.com/ee/development/reusing_abstractions.html#service-classes
+ # suggesting that the dependencies be passed via the constructor. This is because
+ # the RemoteDevelopment feature architecture follows a more pure-functional style,
+ # by avoiding instance variables and instance state and preferring to pass data
+ # directly in method calls rather than via constructor. We also don't use any of the
+ # provided superclasses like BaseContainerService or its descendants, because all of the
+ # domain logic is isolated and decoupled to the architectural tier below this,
+ # i.e. in the `*Processor` classes, and therefore these superclasses provide nothing
+ # of use. However, we do still conform to the convention of passing the current_user
+ # in the constructor, since this convention is related to security, and worth following
+ # the existing patterns and principle of least surprise.
+ #
+ # See https://gitlab.com/gitlab-org/remote-development/gitlab-remote-development-docs/-/blob/main/doc/remote-development-feature-architectural-standards.md
+ # for more discussion on this topic.
+ def initialize(current_user:)
+ @current_user = current_user
+ end
+
+ def execute(workspace:, params:)
+ return ServiceResponse.error(message: 'Unauthorized', reason: :unauthorized) unless authorized?(workspace)
+
+ payload, error = RemoteDevelopment::Workspaces::Update::UpdateProcessor.new.process(
+ workspace: workspace,
+ params: params
+ )
+
+ return ServiceResponse.error(message: error.message, reason: error.reason) if error
+
+ ServiceResponse.success(payload: payload)
+ end
+
+ def authorized?(workspace)
+ current_user&.can?(:update_workspace, workspace)
+ end
+ end
+ end
+end
diff --git a/ee/config/feature_flags/development/remote_development_feature_flag.yml b/ee/config/feature_flags/development/remote_development_feature_flag.yml
index ca32b9c764f2a01bf96ae90085f856f32d630cee..f30bdd583aeb4735a6f430e27085a0f3a90419cd 100644
--- a/ee/config/feature_flags/development/remote_development_feature_flag.yml
+++ b/ee/config/feature_flags/development/remote_development_feature_flag.yml
@@ -4,5 +4,5 @@ introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/112397
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/391543
milestone: '15.11'
type: development
-group: group::editor
+group: group::ide
default_enabled: false
diff --git a/ee/lib/ee/api/internal/kubernetes.rb b/ee/lib/ee/api/internal/kubernetes.rb
index 993b39ceea0d26d5ea765c34379d5bc6b66c07af..cbfed899acc140d96f77260820015c03a7b9a20e 100644
--- a/ee/lib/ee/api/internal/kubernetes.rb
+++ b/ee/lib/ee/api/internal/kubernetes.rb
@@ -5,10 +5,50 @@ module Internal
module Kubernetes
extend ActiveSupport::Concern
prepended do
+ helpers do
+ def update_configuration(agent:, config:)
+ super(agent: agent, config: config)
+
+ if ::Feature.enabled?(:remote_development_feature_flag) &&
+ ::License.feature_available?(:remote_development) # rubocop:disable Layout/MultilineOperationIndentation - Currently can't override default RubyMine formatting
+
+ ::RemoteDevelopment::AgentConfig::UpdateService.new.execute(agent: agent, config: config)
+ end
+
+ true
+ end
+ end
+
namespace 'internal' do
namespace 'kubernetes' do
before { check_agent_token }
+ namespace 'modules/remote_development' do
+ desc 'POST remote development work request' do
+ detail 'Remote development work request'
+ end
+
+ route_setting :authentication, cluster_agent_token_allowed: true
+ post '/reconcile', feature_category: :remote_development do
+ unless ::Feature.enabled?(:remote_development_feature_flag)
+ forbidden!("'remote_development_feature_flag' feature flag is disabled")
+ end
+
+ unless ::License.feature_available?(:remote_development)
+ forbidden!('"remote_development" licensed feature is not available')
+ end
+
+ service = ::RemoteDevelopment::Workspaces::ReconcileService.new
+ service_response = service.execute(agent: agent, params: params)
+
+ if service_response.success?
+ service_response.payload
+ else
+ render_api_error!({ error: service_response.message }, service_response.http_status)
+ end
+ end
+ end
+
namespace 'modules/starboard_vulnerability' do
desc 'PUT starboard vulnerability' do
detail 'Idempotently creates a security vulnerability from starboard'
diff --git a/ee/lib/remote_development/agent_config/update_processor.rb b/ee/lib/remote_development/agent_config/update_processor.rb
new file mode 100644
index 0000000000000000000000000000000000000000..db25e9593d16820b6ad814550bbb357baea5cd1e
--- /dev/null
+++ b/ee/lib/remote_development/agent_config/update_processor.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module RemoteDevelopment
+ module AgentConfig
+ class UpdateProcessor
+ def process(agent:, config:)
+ config_from_agent_config_file = config[:remote_development]
+
+ return [nil, nil] unless config_from_agent_config_file
+
+ model_instance = RemoteDevelopmentAgentConfig.find_or_initialize_by(agent: agent) # rubocop:disable CodeReuse/ActiveRecord
+ model_instance.enabled = config_from_agent_config_file[:enabled]
+ # noinspection RubyResolve
+ model_instance.dns_zone = config_from_agent_config_file[:dns_zone]
+
+ if model_instance.save
+ payload = { remote_development_agent_config: model_instance }
+ [payload, nil]
+ else
+ err_msg = "Error(s) updating RemoteDevelopmentAgentConfig: #{model_instance.errors.full_messages.join(', ')}"
+ error = Error.new(message: err_msg, reason: :bad_request)
+ [nil, error]
+ end
+ end
+ end
+ end
+end
diff --git a/ee/lib/remote_development/error.rb b/ee/lib/remote_development/error.rb
new file mode 100644
index 0000000000000000000000000000000000000000..c9d8b058bf6dcca1c929a21038469366be90933b
--- /dev/null
+++ b/ee/lib/remote_development/error.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module RemoteDevelopment
+ class Error
+ attr_accessor :message, :reason
+
+ def initialize(message:, reason:)
+ @message = message
+ @reason = reason
+ end
+ end
+end
diff --git a/ee/lib/remote_development/logger.rb b/ee/lib/remote_development/logger.rb
new file mode 100644
index 0000000000000000000000000000000000000000..ed767b6b59d2efd81e1b3bece7d0bbed8debb46e
--- /dev/null
+++ b/ee/lib/remote_development/logger.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module RemoteDevelopment
+ class Logger < ::Gitlab::JsonLogger
+ def self.file_name_noext
+ 'remote_development'
+ end
+ end
+end
diff --git a/ee/lib/remote_development/workspaces/create/create_processor.rb b/ee/lib/remote_development/workspaces/create/create_processor.rb
new file mode 100644
index 0000000000000000000000000000000000000000..982d20842e0ccd0e47afc2cbc718cb69427eb03c
--- /dev/null
+++ b/ee/lib/remote_development/workspaces/create/create_processor.rb
@@ -0,0 +1,84 @@
+# frozen_string_literal: true
+
+module RemoteDevelopment
+ module Workspaces
+ module Create
+ # noinspection RubyResolve
+ class CreateProcessor
+ include States
+
+ def process(params:)
+ agent = params.fetch(:agent)
+ unless agent.remote_development_agent_config
+ return nil, Error.new(
+ message: "No RemoteDevelopmentAgentConfig found for agent '#{agent.name}'",
+ reason: :bad_request
+ )
+ end
+
+ project = params.fetch(:project)
+ devfile_ref = params.fetch(:devfile_ref)
+ devfile_path = params.fetch(:devfile_path)
+ repository = project.repository
+ devfile = repository.blob_at_branch(devfile_ref, devfile_path)&.data
+
+ unless devfile
+ error = Error.new(message: 'Devfile not found in project', reason: :bad_request)
+ return nil, error
+ end
+
+ # workspace_root is set to /projects as devfile parser uses this value when setting env vars
+ # PROJECTS_ROOT and PROJECT_SOURCE that are available within the spawned containers
+ # hence, workspace_root will be used across containers/initContainers as the place for user data
+ #
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/408450
+ # explore in depth implications of PROJECTS_ROOT and PROJECT_SOURCE env vars with devfile team
+ # and update devfile processing to use them idiomatically / conform to devfile specifications
+ workspace_root = "/projects"
+
+ begin
+ processed_devfile = DevfileProcessor.new.process(
+ devfile: devfile,
+ editor: params.fetch(:editor),
+ project: project,
+ workspace_root: workspace_root
+ )
+ # TODO: https://gitlab.com/groups/gitlab-org/-/epics/10461
+ # handle other errors that can occurs apart from the ArgumentError raised from devfile_validator.rb
+ rescue ArgumentError => e
+ # Return early if any errors are detected with processing(and validating) the devfile
+ error = Error.new(message: "Invalid devfile: #{e.message}", reason: :bad_request)
+ return nil, error
+ end
+
+ workspace = project.workspaces.build(params)
+
+ workspace.devfile = devfile
+ workspace.processed_devfile = processed_devfile
+ workspace.actual_state = CREATION_REQUESTED
+
+ user = params.fetch(:user)
+ random_string = SecureRandom.alphanumeric(6).downcase
+ workspace.namespace = "gl-rd-ns-#{agent.id}-#{user.id}-#{random_string}"
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/409774
+ # We can come maybe come up with a better/cooler way to get a unique name, for now this works
+ workspace.name = "workspace-#{agent.id}-#{user.id}-#{random_string}"
+
+ project_dir = "#{workspace_root}/#{project.path}"
+ workspace_host = "60001-#{workspace.name}.#{workspace.dns_zone}"
+ workspace.url = URI::HTTPS.build({
+ host: workspace_host,
+ query: {
+ folder: project_dir
+ }.to_query
+ }).to_s
+
+ workspace.save!
+
+ payload = { workspace: workspace }
+ [payload, nil]
+ end
+ end
+ end
+ end
+end
diff --git a/ee/lib/remote_development/workspaces/create/devfile_processor.rb b/ee/lib/remote_development/workspaces/create/devfile_processor.rb
new file mode 100644
index 0000000000000000000000000000000000000000..9bfd00e399abc4e0e5eb7db1fc66ea0c7d6cd1b9
--- /dev/null
+++ b/ee/lib/remote_development/workspaces/create/devfile_processor.rb
@@ -0,0 +1,209 @@
+# frozen_string_literal: true
+
+require 'devfile'
+
+module RemoteDevelopment
+ module Workspaces
+ module Create
+ class DevfileProcessor
+ WORKSPACE_VOLUME = 'gl-workspace-data'
+
+ def process(devfile:, editor:, project:, workspace_root:)
+ flattened_devfile_yaml = Devfile::Parser.flatten(devfile)
+ flattened_devfile = YAML.safe_load(flattened_devfile_yaml)
+ DevfileValidator.new.validate(flattened_devfile: flattened_devfile)
+
+ flattened_devfile = add_workspace_volume(flattened_devfile: flattened_devfile, volume_name: WORKSPACE_VOLUME)
+ flattened_devfile = add_editor(
+ flattened_devfile: flattened_devfile,
+ editor: editor,
+ volume_reference: WORKSPACE_VOLUME,
+ volume_mount_dir: workspace_root
+ )
+ flattened_devfile = add_project_cloner(
+ flattened_devfile: flattened_devfile,
+ project: project,
+ volume_reference: WORKSPACE_VOLUME,
+ volume_mount_dir: workspace_root
+ )
+
+ YAML.dump(flattened_devfile)
+ end
+
+ private
+
+ # noinspection RubyUnusedLocalVariable
+ # rubocop:disable Lint/UnusedMethodArgument
+ def add_editor(flattened_devfile:, editor:, volume_reference:, volume_mount_dir:)
+ editor_port = 60001
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/409775 - choose image based on which editor is passed.
+ image_name = 'registry.gitlab.com/gitlab-org/gitlab-web-ide-vscode-fork/web-ide-injector'
+ image_tag = '1'
+ editor_components = [
+ {
+ 'name' => 'gl-editor-injector',
+ 'container' => {
+ 'image' => "#{image_name}:#{image_tag}",
+ 'volumeMounts' => [{ 'name' => volume_reference, 'path' => volume_mount_dir }],
+ 'env' => [
+ {
+ 'name' => 'EDITOR_VOLUME_DIR',
+ 'value' => "#{volume_mount_dir}/.gl-editor"
+ },
+ {
+ 'name' => 'EDITOR_PORT',
+ 'value' => editor_port.to_s
+ }
+ ],
+ 'memoryLimit' => '128Mi',
+ 'memoryRequest' => '32Mi',
+ 'cpuLimit' => '500m',
+ 'cpuRequest' => '30m'
+ }
+ }
+ ]
+
+ editor_component_found = false
+ flattened_devfile['components'].map do |component|
+ next unless component.dig('attributes', 'gl/inject-editor')
+ next if editor_component_found
+
+ editor_component_found = true
+ # This overrides the main container's command
+ # Open issue to support both starting the editor and running the default command:
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/392853
+ component['container']['command'] = ["#{volume_mount_dir}/.gl-editor/start_server.sh"]
+
+ component['container']['volumeMounts'] = [] if component['container']['volumeMounts'].nil?
+
+ component['container']['volumeMounts'] += [{ 'name' => volume_reference, 'path' => volume_mount_dir }]
+
+ component['container']['env'] = [] if component['container']['env'].nil?
+
+ component['container']['env'] += [
+ {
+ 'name' => 'EDITOR_VOLUME_DIR',
+ 'value' => "#{volume_mount_dir}/.gl-editor"
+ },
+ {
+ 'name' => 'EDITOR_PORT',
+ 'value' => editor_port.to_s
+ }
+ ]
+
+ component['container']['endpoints'] = [] if component['container']['endpoints'].nil?
+
+ component['container']['endpoints'].append(
+ {
+ 'name' => 'editor-server',
+ 'targetPort' => editor_port,
+ 'exposure' => 'public',
+ 'secure' => true,
+ 'protocol' => 'https'
+ }
+ )
+
+ component
+ end
+
+ # TODO: figure out what to do when no editor injection component is found
+ if editor_component_found
+ flattened_devfile['components'] += editor_components
+
+ flattened_devfile['commands'] = [] if flattened_devfile['commands'].nil?
+
+ flattened_devfile['commands'] += [{
+ 'id' => 'gl-editor-injector-command',
+ 'apply' => {
+ 'component' => 'gl-editor-injector'
+ }
+ }]
+
+ flattened_devfile['events'] = {} if flattened_devfile['events'].nil?
+
+ flattened_devfile['events']['preStart'] = [] if flattened_devfile['events']['preStart'].nil?
+
+ flattened_devfile['events']['preStart'] += ['gl-editor-injector-command']
+ end
+
+ flattened_devfile
+ end
+ # rubocop:enable Lint/UnusedMethodArgument
+
+ def add_project_cloner(flattened_devfile:, project:, volume_reference:, volume_mount_dir:)
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/408448
+ # replace the alpine/git docker image with one that is published by gitlab for security / reliability
+ # reasons
+ image_name = 'alpine/git'
+ image_tag = '2.36.3'
+
+ clone_dir = "#{volume_mount_dir}/#{project.path}"
+
+ # project is cloned only if one doesn't exist already
+ # this done to avoid resetting user's modifications to the workspace
+ project_url = project.http_url_to_repo
+ project_ref = project.default_branch
+ container_args = <<~SH.chomp
+ if [ ! -d '#{clone_dir}' ];
+ then
+ git clone --branch #{Shellwords.shellescape(project_ref)} #{Shellwords.shellescape(project_url)} #{Shellwords.shellescape(clone_dir)};
+ fi
+ SH
+
+ # TODO: https://gitlab.com/groups/gitlab-org/-/epics/10461
+ # implement better error handling to allow cloner to be able to deal with different categories of errors
+ # issue: https://gitlab.com/gitlab-org/gitlab/-/issues/408451
+ cloner_component = {
+ 'name' => 'gl-cloner-injector',
+ 'container' => {
+ 'image' => "#{image_name}:#{image_tag}",
+ 'volumeMounts' => [{ 'name' => volume_reference, 'path' => volume_mount_dir }],
+ 'args' => [container_args],
+ # command has been overridden here as the default command in the alpine/git
+ # container invokes git directly
+ 'command' => %w[/bin/sh -c],
+ 'memoryLimit' => '128Mi',
+ 'memoryRequest' => '32Mi',
+ 'cpuLimit' => '500m',
+ 'cpuRequest' => '30m'
+ }
+ }
+
+ flattened_devfile['components'] ||= []
+ flattened_devfile['components'] << cloner_component
+
+ # create a command that will invoke the cloner
+ cloner_command = {
+ 'id' => 'gl-cloner-injector-command',
+ 'apply' => {
+ 'component' => cloner_component['name']
+ }
+ }
+ flattened_devfile['commands'] ||= []
+ flattened_devfile['commands'] << cloner_command
+
+ # configure the workspace to run the cloner command upon workspace start
+ flattened_devfile['events'] ||= {}
+ flattened_devfile['events']['preStart'] ||= []
+ flattened_devfile['events']['preStart'] << cloner_command['id']
+
+ flattened_devfile
+ end
+
+ def add_workspace_volume(flattened_devfile:, volume_name:)
+ component = {
+ 'name' => volume_name,
+ 'volume' => {
+ 'size' => '15Gi'
+ }
+ }
+
+ flattened_devfile['components'] ||= []
+ flattened_devfile['components'] << component
+
+ flattened_devfile
+ end
+ end
+ end
+ end
+end
diff --git a/ee/lib/remote_development/workspaces/create/devfile_validator.rb b/ee/lib/remote_development/workspaces/create/devfile_validator.rb
new file mode 100644
index 0000000000000000000000000000000000000000..ed3b636fbd7aa457f3c6accbf6ef3af21f5307ab
--- /dev/null
+++ b/ee/lib/remote_development/workspaces/create/devfile_validator.rb
@@ -0,0 +1,166 @@
+# frozen_string_literal: true
+
+require 'devfile'
+
+module RemoteDevelopment
+ module Workspaces
+ module Create
+ class DevfileValidator
+ # Since this is called after flattening the devfile, we can safely assume that it has valid syntax
+ # as per devfile standard. If you are validating something that is not available across all devfile versions,
+ # add additional guard clauses.
+ # Devfile standard only allows name/id to be of the format /'^[a-z0-9]([-a-z0-9]*[a-z0-9])?$'/
+ # Hence, we do no need to restrict the prefix `gl_`.
+ # However, we do that for the 'variables' in the flattened_devfile since they do not have any such restriction
+ RESTRICTED_PREFIX = 'gl-'
+
+ # Currently, we only support 'container' and 'volume' type components.
+ # For container components, ensure no endpoint name starts with restricted_prefix
+ UNSUPPORTED_COMPONENT_TYPES = %w[kubernetes openshift image].freeze
+
+ # Currently, we only support 'exec' and 'apply' for validation
+ SUPPORTED_COMMAND_TYPES = %w[exec apply].freeze
+
+ def validate(flattened_devfile:)
+ validate_parent(flattened_devfile: flattened_devfile)
+ validate_components(
+ flattened_devfile: flattened_devfile,
+ restricted_prefix: RESTRICTED_PREFIX,
+ unsupported_component_types: UNSUPPORTED_COMPONENT_TYPES
+ )
+ validate_commands(
+ flattened_devfile: flattened_devfile,
+ restricted_prefix: RESTRICTED_PREFIX,
+ supported_command_types: SUPPORTED_COMMAND_TYPES
+ )
+ validate_events(flattened_devfile: flattened_devfile, restricted_prefix: RESTRICTED_PREFIX)
+ validate_variables(flattened_devfile: flattened_devfile, restricted_prefix: RESTRICTED_PREFIX)
+ end
+
+ private
+
+ def validate_parent(flattened_devfile:)
+ raise ArgumentError, _('Inheriting from parent is not yet supported') if flattened_devfile['parent']
+ end
+
+ def validate_components(flattened_devfile:, restricted_prefix:, unsupported_component_types:)
+ components = flattened_devfile['components']
+
+ raise ArgumentError, _('No components present in the devfile') if components.nil?
+
+ inject_editor_components = components.select do |component|
+ component.dig('attributes', 'gl/inject-editor')
+ end
+
+ raise ArgumentError, _("No component has 'gl/inject-editor' attribute") if inject_editor_components.empty?
+
+ if inject_editor_components.length > 1
+ error_str = format(
+ "Multiple components(%s) have 'gl/inject-editor' attribute",
+ inject_editor_components.pluck('name') # rubocop:disable CodeReuse/ActiveRecord - Oh you silly CodeReuse/ActiveRecord, this pluck isn't even from ActiveRecord, it's from ActiveSupport!!!
+ )
+ raise ArgumentError, _(error_str)
+ end
+
+ # Ensure no component name starts with restricted_prefix
+ components.each do |component|
+ component_name = component['name']
+ if component_name.downcase.start_with?(restricted_prefix)
+ error_str = format("Component name '%s' starts with '%s'", component_name, restricted_prefix)
+ raise ArgumentError, _(error_str)
+ end
+
+ unsupported_component_types.each do |unsupported_component_type|
+ unless component[unsupported_component_type].nil?
+ error_str = format("Component type '%s' is not yet supported", unsupported_component_type)
+ raise ArgumentError, _(error_str)
+ end
+ end
+
+ container = component['container']
+ # Choosing to disable rubocop rule since we might add validations for other component types in the future.
+ # Add adding a guard clause now might create a regression later
+ # since we have only validate each component type if they are present.
+ # rubocop:disable Style/Next
+ unless container.nil?
+ next if container['endpoints'].nil?
+
+ container['endpoints'].map do |endpoint|
+ endpoint_name = endpoint['name']
+ if endpoint_name.downcase.start_with?(restricted_prefix)
+ error_str = format(
+ "Endpoint name '%s' of component '%s' starts with '%s'",
+ endpoint_name, component_name, restricted_prefix
+ )
+ raise ArgumentError, _(error_str)
+ end
+ end
+ end
+ # rubocop:enable Style/Next
+ end
+ end
+
+ def validate_commands(flattened_devfile:, restricted_prefix:, supported_command_types:)
+ commands = flattened_devfile['commands']
+ return if commands.nil?
+
+ # Ensure no command name starts with restricted_prefix
+ commands.each do |command|
+ command_id = command['id']
+ if command_id.downcase.start_with?(restricted_prefix)
+ error_str = format("Command id '%s' starts with '%s'", command_id, restricted_prefix)
+ raise ArgumentError, _(error_str)
+ end
+
+ # Ensure no command is referring to a component with restricted_prefix
+ supported_command_types.each do |supported_command_type|
+ command_type = command[supported_command_type]
+ next if command_type.nil?
+
+ component_name = command_type['component']
+ next unless component_name.downcase.start_with?(restricted_prefix)
+
+ error_str = format(
+ "Component name '%s' for command id '%s' starts with '%s'",
+ component_name, command_id, restricted_prefix
+ )
+ raise ArgumentError, _(error_str)
+ end
+ end
+ end
+
+ def validate_events(flattened_devfile:, restricted_prefix:)
+ events = flattened_devfile['events']
+ return if events.nil?
+
+ # Ensure no event starts with restricted_prefix
+ events.map do |event_type, event_type_events|
+ event_type_events.each do |event|
+ if event.downcase.start_with?(restricted_prefix)
+ error_str = format("Event '%s' of type '%s' starts with '%s'", event, event_type, restricted_prefix)
+ raise ArgumentError, _(error_str)
+ end
+ end
+ end
+ end
+
+ def validate_variables(flattened_devfile:, restricted_prefix:)
+ variables = flattened_devfile['variables']
+ return if variables.nil?
+
+ restricted_prefix_underscore = restricted_prefix.tr("-", "_")
+
+ # Ensure no variables name starts with restricted_prefix
+ variables.map do |variable, _|
+ [restricted_prefix, restricted_prefix_underscore].each do |prefix|
+ if variable.downcase.start_with?(prefix)
+ error_str = format("Variable name '%s' starts with '%s'", variable, prefix)
+ raise ArgumentError, _(error_str)
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/ee/lib/remote_development/workspaces/reconcile/actual_state_calculator.rb b/ee/lib/remote_development/workspaces/reconcile/actual_state_calculator.rb
new file mode 100644
index 0000000000000000000000000000000000000000..9a816aef67cbdc4e0b06d805e73be59d0d75afad
--- /dev/null
+++ b/ee/lib/remote_development/workspaces/reconcile/actual_state_calculator.rb
@@ -0,0 +1,180 @@
+# frozen_string_literal: true
+
+module RemoteDevelopment
+ module Workspaces
+ module Reconcile
+ class ActualStateCalculator
+ include States
+
+ TERMINATION_PROGRESS_TERMINATING = 'Terminating'
+ TERMINATION_PROGRESS_TERMINATED = 'Terminated'
+
+ CONDITION_TYPE_PROGRESSING = 'Progressing'
+ CONDITION_TYPE_AVAILABLE = 'Available'
+ PROGRESSING_CONDITION_REASON_NEW_REPLICA_SET_CREATED = 'NewReplicaSetCreated'
+ PROGRESSING_CONDITION_REASON_FOUND_NEW_REPLICA_SET = 'FoundNewReplicaSet'
+ PROGRESSING_CONDITION_REASON_REPLICA_SET_UPDATED = 'ReplicaSetUpdated'
+ PROGRESSING_CONDITION_REASON_NEW_REPLICA_SET_AVAILABLE = 'NewReplicaSetAvailable'
+ PROGRESSING_CONDITION_REASON_PROGRESS_DEADLINE_EXCEEDED = 'ProgressDeadlineExceeded'
+ AVAILABLE_CONDITION_REASON_MINIMUM_REPLICAS_UNAVAILABLE = 'MinimumReplicasUnavailable'
+ AVAILABLE_CONDITION_REASON_MINIMUM_REPLICAS_AVAILABLE = 'MinimumReplicasAvailable'
+
+ DEPLOYMENT_PROGRESSING_STATUS_PROGRESSING = [
+ PROGRESSING_CONDITION_REASON_NEW_REPLICA_SET_CREATED,
+ PROGRESSING_CONDITION_REASON_FOUND_NEW_REPLICA_SET,
+ PROGRESSING_CONDITION_REASON_REPLICA_SET_UPDATED
+ ].freeze
+
+ DEPLOYMENT_PROGRESSING_STATUS_COMPLETE = [
+ PROGRESSING_CONDITION_REASON_NEW_REPLICA_SET_AVAILABLE
+ ].freeze
+
+ DEPLOYMENT_PROGRESSING_STATUS_FAILED = [
+ PROGRESSING_CONDITION_REASON_PROGRESS_DEADLINE_EXCEEDED
+ ].freeze
+
+ # rubocop: disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
+ def calculate_actual_state(latest_k8s_deployment_info:, termination_progress: nil)
+ return TERMINATING if termination_progress == TERMINATION_PROGRESS_TERMINATING
+ return TERMINATED if termination_progress == TERMINATION_PROGRESS_TERMINATED
+
+ # if latest_k8s_deployment_info is missing, but workspace isn't Terminated or Terminating, this is an Unknown
+ # state and should likely be accompanied by a value in the Error field, as this should be detectable by
+ # agentk. At that point, this may not be necessary, and we can detect the error state earlier and return in a
+ # guard clause before this point.
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/396882#note_1377670883
+ # Error field is not yet implemented, double check the above comment once it is implemented
+ return UNKNOWN unless latest_k8s_deployment_info
+
+ spec = latest_k8s_deployment_info['spec']
+ status = latest_k8s_deployment_info['status']
+ conditions = status&.[]('conditions')
+ return UNKNOWN unless spec && status && conditions
+
+ progressing_condition = conditions.detect do |condition|
+ condition['type'] == CONDITION_TYPE_PROGRESSING
+ end
+ return UNKNOWN if progressing_condition.nil?
+
+ progressing_reason = progressing_condition['reason']
+ spec_replicas = spec['replicas']
+ return UNKNOWN if progressing_reason.nil? || spec_replicas.nil?
+
+ # https://kubernetes.io/docs/concepts/workloads/controllers/deployment/#deployment-status
+
+ # If the deployment has been marked failed, we know that the workspace has failed
+ # A deployment is failed if
+ # - Insufficient quota
+ # - Readiness probe failures
+ # - Image pull errors
+ # - Insufficient permissions
+ # - Limit ranges
+ # - Application runtime misconfiguration
+ return FAILED if DEPLOYMENT_PROGRESSING_STATUS_FAILED.include?(progressing_reason)
+
+ # If the deployment is still in progress, the workspace can only be either starting or stopping
+ # A deployment is in progress if
+ # - The Deployment creates a new ReplicaSet.
+ # - The Deployment is scaling up its newest ReplicaSet.
+ # - The Deployment is scaling down its older ReplicaSet(s).
+ # - New Pods become ready or available (ready for at least MinReadySeconds).
+ if DEPLOYMENT_PROGRESSING_STATUS_PROGRESSING.include?(progressing_reason)
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/409777
+ # This does not appear to be the normal STOPPING or STARTING scenario, because the progressing_reason
+ # always remains 'NewReplicaSetAvailable' even when transitioning between Running and Stopped.
+ return STOPPING if spec_replicas == 0
+ return STARTING if spec_replicas == 1
+ end
+
+ # https://github.com/kubernetes/kubernetes/blob/3615291/pkg/controller/deployment/sync.go#L513-L516
+ status_available_replicas = status.fetch('availableReplicas', 0)
+ status_unavailable_replicas = status.fetch('unavailableReplicas', 0)
+
+ available_condition = conditions.detect do |condition|
+ condition['type'] == CONDITION_TYPE_AVAILABLE
+ end
+ return UNKNOWN if available_condition.nil?
+
+ available_reason = available_condition['reason']
+ return UNKNOWN if available_reason.nil?
+
+ # If a deployment has been marked complete, the workspace state needs to be further calculated
+ # A deployment is complete if
+ # - All of the replicas associated with the Deployment have been updated to the latest version
+ # you've specified, meaning any updates you've requested have been completed.
+ # - All of the replicas associated with the Deployment are available.
+ # - No old replicas for the Deployment are running.
+ if DEPLOYMENT_PROGRESSING_STATUS_COMPLETE.include?(progressing_reason)
+ # rubocop:disable Layout/MultilineOperationIndentation - Currently can't override default RubyMine formatting
+
+ # If a deployment is complete and the desired and available replicas are 0, the workspace is stopped
+ if available_reason == AVAILABLE_CONDITION_REASON_MINIMUM_REPLICAS_AVAILABLE &&
+ spec_replicas == 0 && status_available_replicas == 0
+ return STOPPED
+ end
+
+ # If a deployment is complete and the Available condition has reason MinimumReplicasAvailable
+ # and the desired and available replicas are equal
+ # and there are no unavailable replicas
+ # then the workspace is running
+ if available_reason == AVAILABLE_CONDITION_REASON_MINIMUM_REPLICAS_AVAILABLE &&
+ spec_replicas == status_available_replicas &&
+ status_unavailable_replicas == 0
+ return RUNNING
+ end
+
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/409777
+ # This appears to be the normal STOPPING scenario, because the progressing_reason always remains
+ # 'NewReplicaSetAvailable' when transitioning between Running and Stopped. Confirm if different
+ # handling of STOPPING status above is also necessary.
+ # In normal usage (at least in local dev), this transition always happens so fast that this
+ # state is never sent in a reconciliation request, even with a 1-second polling interval.
+ # It always stopped immediately in under a second, and thus the next poll after a Stopped
+ # request always ends up with spec_replicas == 0 && status_available_replicas == 0 and
+ # matches the STOPPED state above.
+ if available_reason == AVAILABLE_CONDITION_REASON_MINIMUM_REPLICAS_AVAILABLE &&
+ spec_replicas == 0 && status_available_replicas == 1
+ return STOPPING
+ end
+
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/409777
+ # This appears to be the normal STARTING scenario, because the progressing_reason always remains
+ # 'NewReplicaSetAvailable' and available_reason is either 'MinimumReplicasAvailable' or
+ # 'MinimumReplicasUnavailable' when transitioning between Stopped and Running. Confirm if different
+ # handling of STARTING status above is also necessary.
+ if [
+ AVAILABLE_CONDITION_REASON_MINIMUM_REPLICAS_AVAILABLE,
+ AVAILABLE_CONDITION_REASON_MINIMUM_REPLICAS_UNAVAILABLE
+ ].include?(available_reason) &&
+ spec_replicas == 1 && status_available_replicas == 0
+ return STARTING
+ end
+
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/409777
+ # This is unreachable by any of the currently implemented fixture scenarios, because it matches the
+ # normal behavior when transioning between Stopped and Running. We need to determine what
+ # a failure scenario actually looks like and how it differs, if at all, from a normal STARTING
+ # scenario. Logic is commented out to avoid undercoverage failure. See related TODOs above.
+ # If a deployment is complete and the Available condition has reason MinimumReplicasUnavailable
+ # and the desired and available replicas are not equal
+ # and there are unavailable replicas
+ # then the workspace is failed
+ # Example: Deployment is completed and the ReplicaSet is available and up-to-date.
+ # But the Pods of the ReplicaSet are not available as they are in CrashLoopBackOff
+ # if available_reason == AVAILABLE_CONDITION_REASON_MINIMUM_REPLICAS_UNAVAILABLE &&
+ # spec_replicas != status_available_replicas &&
+ # status_unavailable_replicas != 0
+ # return FAILED
+ # end
+
+ # rubocop:enable Layout/MultilineOperationIndentation - Currently can't override default RubyMine formatting
+ end
+
+ UNKNOWN
+ end
+
+ # rubocop: enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
+ end
+ end
+ end
+end
diff --git a/ee/lib/remote_development/workspaces/reconcile/agent_info.rb b/ee/lib/remote_development/workspaces/reconcile/agent_info.rb
new file mode 100644
index 0000000000000000000000000000000000000000..7e2ddebce26dc93504f7a4afb44942f715cbf63e
--- /dev/null
+++ b/ee/lib/remote_development/workspaces/reconcile/agent_info.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module RemoteDevelopment
+ module Workspaces
+ module Reconcile
+ class AgentInfo
+ attr_reader :name, :namespace, :actual_state, :deployment_resource_version
+
+ def initialize(name:, namespace:, actual_state:, deployment_resource_version:)
+ @name = name
+ @namespace = namespace
+ @actual_state = actual_state
+ @deployment_resource_version = deployment_resource_version
+ end
+
+ def ==(other)
+ return false unless other && self.class == other.class
+
+ other.name == name &&
+ other.namespace == namespace &&
+ other.actual_state == actual_state &&
+ other.deployment_resource_version == deployment_resource_version
+ end
+ end
+ end
+ end
+end
diff --git a/ee/lib/remote_development/workspaces/reconcile/agent_info_parser.rb b/ee/lib/remote_development/workspaces/reconcile/agent_info_parser.rb
new file mode 100644
index 0000000000000000000000000000000000000000..94affb1604eb4912942ffe081a31f1a7a6d9152e
--- /dev/null
+++ b/ee/lib/remote_development/workspaces/reconcile/agent_info_parser.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+module RemoteDevelopment
+ module Workspaces
+ module Reconcile
+ class AgentInfoParser
+ def parse(workspace_agent_info:)
+ # workspace_agent_info.fetch('latest_k8s_deployment_info') is not used since the field may not be present
+ latest_k8s_deployment_info = workspace_agent_info['latest_k8s_deployment_info']
+
+ actual_state = ActualStateCalculator.new.calculate_actual_state(
+ latest_k8s_deployment_info: latest_k8s_deployment_info,
+ # workspace_agent_info.fetch('termination_progress') is not used since the field may not be present
+ termination_progress: workspace_agent_info['termination_progress']
+ )
+
+ # If the actual state of the workspace is Terminated, the only keys which will be put into the
+ # AgentInfo object are name and actual_state
+ info = {
+ name: workspace_agent_info.fetch('name'),
+ namespace: nil,
+ actual_state: actual_state,
+ deployment_resource_version: nil
+ }
+
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/409778
+ # We probably need to eventually handle States::ERROR and States::FAILURE here too. Will they possibly
+ # be missing the namespace or deployment_resource_version too?
+ unless [States::TERMINATING, States::TERMINATED, States::UNKNOWN].include?(actual_state)
+ # Unless the workspace is already terminated, the other workspace_agent_info entries should be populated
+ info[:namespace] = workspace_agent_info.fetch('namespace')
+ deployment_resource_version = latest_k8s_deployment_info.fetch('metadata').fetch('resourceVersion')
+ # NOTE: Kubernetes updates the deployment_resource_version every time a new config is applied, even if
+ # that config is identical to the currently running configuration and results in no changes.
+ info[:deployment_resource_version] = deployment_resource_version
+ end
+
+ AgentInfo.new(**info)
+ end
+ end
+ end
+ end
+end
diff --git a/ee/lib/remote_development/workspaces/reconcile/desired_config_generator.rb b/ee/lib/remote_development/workspaces/reconcile/desired_config_generator.rb
new file mode 100644
index 0000000000000000000000000000000000000000..8414924bead1ec8af520aaa5b4e6103cedf1dc1b
--- /dev/null
+++ b/ee/lib/remote_development/workspaces/reconcile/desired_config_generator.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+module RemoteDevelopment
+ module Workspaces
+ module Reconcile
+ # noinspection RubyResolve
+ class DesiredConfigGenerator
+ include States
+
+ def generate_desired_config(workspace:)
+ name = workspace.name
+ namespace = workspace.namespace
+ agent = workspace.agent
+ desired_state = workspace.desired_state
+
+ domain_template = "{{.port}}-#{name}.#{workspace.dns_zone}"
+
+ workspace_inventory_config_map, owning_inventory =
+ create_workspace_inventory_config_map(name: name, namespace: namespace, agent_id: agent.id)
+ replicas = get_workspace_replicas(desired_state: desired_state)
+ labels, annotations = get_labels_and_annotations(
+ agent_id: agent.id,
+ owning_inventory: owning_inventory,
+ domain_template: domain_template,
+ workspace_id: workspace.id
+ )
+
+ # TODO: https://gitlab.com/groups/gitlab-org/-/epics/10461 - handle error
+ workspace_resources = DevfileParser.new.get_all(
+ processed_devfile: workspace.processed_devfile,
+ name: name,
+ namespace: namespace,
+ replicas: replicas,
+ domain_template: domain_template,
+ labels: labels,
+ annotations: annotations
+ )
+ workspace_resources.insert(0, workspace_inventory_config_map)
+
+ workspace_resources
+ end
+
+ private
+
+ def get_workspace_replicas(desired_state:)
+ return 1 if [
+ CREATION_REQUESTED,
+ RUNNING
+ ].include?(desired_state)
+
+ 0
+ end
+
+ # noinspection RubyInstanceMethodNamingConvention
+ def create_workspace_inventory_config_map(name:, namespace:, agent_id:)
+ owning_inventory = "#{name}-workspace-inventory"
+ workspace_inventory_config_map = {
+ kind: 'ConfigMap',
+ apiVersion: 'v1',
+ metadata: {
+ name: owning_inventory,
+ namespace: namespace,
+ labels: {
+ 'cli-utils.sigs.k8s.io/inventory-id': owning_inventory,
+ 'agent.gitlab.com/id': agent_id.to_s
+ }
+ }
+ }.deep_stringify_keys
+ [workspace_inventory_config_map, owning_inventory]
+ end
+
+ def get_labels_and_annotations(agent_id:, owning_inventory:, domain_template:, workspace_id:)
+ labels = {
+ 'agent.gitlab.com/id' => agent_id.to_s
+ }
+ annotations = {
+ 'config.k8s.io/owning-inventory' => owning_inventory.to_s,
+ 'workspaces.gitlab.com/host-template' => domain_template.to_s,
+ 'workspaces.gitlab.com/id' => workspace_id.to_s
+ }
+ [labels, annotations]
+ end
+ end
+ end
+ end
+end
diff --git a/ee/lib/remote_development/workspaces/reconcile/devfile_parser.rb b/ee/lib/remote_development/workspaces/reconcile/devfile_parser.rb
new file mode 100644
index 0000000000000000000000000000000000000000..72573af2dac7fbf1d37eec663c2158545f61b3f0
--- /dev/null
+++ b/ee/lib/remote_development/workspaces/reconcile/devfile_parser.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+require 'devfile'
+
+module RemoteDevelopment
+ module Workspaces
+ module Reconcile
+ class DevfileParser
+ def get_all(processed_devfile:, name:, namespace:, replicas:, domain_template:, labels:, annotations:)
+ workspace_resources_yaml = Devfile::Parser.get_all(
+ processed_devfile,
+ name,
+ namespace,
+ YAML.dump(labels),
+ YAML.dump(annotations),
+ replicas,
+ domain_template,
+ 'none'
+ )
+ workspace_resources = YAML.load_stream(workspace_resources_yaml)
+ set_security_context(workspace_resources: workspace_resources)
+ end
+
+ private
+
+ # Devfile library allows specifying the security context of pods/containers as mentioned in
+ # https://github.com/devfile/api/issues/920 through `pod-overrides` and `container-overrides` attributes.
+ # However, https://github.com/devfile/library/pull/158 which is implementing this feature,
+ # is not part of v2.2.0 which is the latest release of the devfile which is being used in the devfile-gem.
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/409189
+ # Once devfile library releases a new version, update the devfile-gem and
+ # move the logic of setting the security context in the `devfile_processor` as part of workspace creation.
+ RUN_AS_USER = 5001
+
+ def set_security_context(workspace_resources:)
+ workspace_resources.each do |workspace_resource|
+ next unless workspace_resource['kind'] == 'Deployment'
+
+ pod_spec = workspace_resource['spec']['template']['spec']
+ # Explicitly set security context for the pod
+ pod_spec['securityContext'] = {
+ 'runAsNonRoot' => true,
+ 'runAsUser' => RUN_AS_USER
+ }
+ # Explicitly set security context for all containers
+ pod_spec['containers'].each do |container|
+ container['securityContext'] = {
+ 'allowPrivilegeEscalation' => false,
+ 'privileged' => false,
+ 'runAsNonRoot' => true,
+ 'runAsUser' => RUN_AS_USER
+ }
+ end
+ # Explicitly set security context for all init containers
+ pod_spec['initContainers'].each do |init_container|
+ init_container['securityContext'] = {
+ 'allowPrivilegeEscalation' => false,
+ 'privileged' => false,
+ 'runAsNonRoot' => true,
+ 'runAsUser' => RUN_AS_USER
+ }
+ end
+ end
+ workspace_resources
+ end
+ end
+ end
+ end
+end
diff --git a/ee/lib/remote_development/workspaces/reconcile/params_parser.rb b/ee/lib/remote_development/workspaces/reconcile/params_parser.rb
new file mode 100644
index 0000000000000000000000000000000000000000..4cf4089058084b073d65112aa7ce307a1a350b56
--- /dev/null
+++ b/ee/lib/remote_development/workspaces/reconcile/params_parser.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+require 'json_schemer'
+
+module RemoteDevelopment
+ module Workspaces
+ module Reconcile
+ class ParamsParser
+ include UpdateType
+
+ def parse(params:)
+ error_message = validate_params(params)
+
+ if error_message
+ error = Error.new(
+ message: error_message,
+ reason: :unprocessable_entity
+ )
+ return [nil, error]
+ end
+
+ parsed_params = {
+ workspace_agent_infos: params.fetch('workspace_agent_infos'),
+ update_type: params.fetch('update_type')
+ }
+ [
+ parsed_params,
+ nil
+ ]
+ end
+
+ private
+
+ def validate_params(params)
+ schema = JSONSchemer.schema({
+ "type" => "object",
+ "required" => %w[update_type workspace_agent_infos],
+ "properties" => {
+ "update_type" => {
+ "type" => "string",
+ "enum" => [PARTIAL, FULL]
+ },
+ "workspace_agent_infos" => {
+ "type" => "array"
+ }
+ }
+ })
+
+ errs = schema.validate(params)
+ return if errs.none?
+
+ errs.map { |err| JSONSchemer::Errors.pretty(err) }.join(". ")
+ end
+ end
+ end
+ end
+end
diff --git a/ee/lib/remote_development/workspaces/reconcile/reconcile_processor.rb b/ee/lib/remote_development/workspaces/reconcile/reconcile_processor.rb
new file mode 100644
index 0000000000000000000000000000000000000000..dbf5f545fe3aaf9e03c14486085f9ea9f4b11f07
--- /dev/null
+++ b/ee/lib/remote_development/workspaces/reconcile/reconcile_processor.rb
@@ -0,0 +1,208 @@
+# frozen_string_literal: true
+
+# noinspection RubyResolve
+module RemoteDevelopment
+ module Workspaces
+ module Reconcile
+ class ReconcileProcessor
+ include UpdateType
+
+ # rubocop:disable Metrics/AbcSize
+ def process(agent:, workspace_agent_infos:, update_type:)
+ logger.debug(
+ message: 'Beginning ReconcileProcessor',
+ agent_id: agent.id,
+ update_type: update_type
+ )
+ # parse an array of AgentInfo objects from the workspace_agent_infos array
+ workspace_agent_infos_by_name = workspace_agent_infos.each_with_object({}) do |workspace_agent_info, hash|
+ info = AgentInfoParser.new.parse(workspace_agent_info: workspace_agent_info)
+ hash[info.name] = info
+
+ next unless [States::UNKNOWN, States::ERROR].include? info.actual_state
+
+ logger.warn(
+ message: 'Abnormal workspace actual_state',
+ error_type: 'abnormal_workspace_state',
+ actual_state: info.actual_state,
+ workspace_deployment_status: workspace_agent_info['latest_k8s_deployment_info']&.fetch('status', {}).to_s
+ )
+ end
+ names_from_agent_infos = workspace_agent_infos_by_name.keys
+
+ logger.debug(
+ message: 'Parsed workspaces from workspace_agent_infos',
+ agent_id: agent.id,
+ update_type: update_type,
+ count: names_from_agent_infos.length,
+ workspace_agent_infos: workspace_agent_infos_by_name.values.map do |agent_info|
+ {
+ name: agent_info.name,
+ namespace: agent_info.namespace,
+ actual_state: agent_info.actual_state,
+ deployment_resource_version: agent_info.deployment_resource_version
+ }
+ end
+ )
+
+ persisted_workspaces_from_agent_infos = agent.workspaces.where(name: names_from_agent_infos) # rubocop:disable CodeReuse/ActiveRecord
+
+ check_for_orphaned_workspaces(
+ workspace_agent_infos_by_name: workspace_agent_infos_by_name,
+ persisted_workspace_names: persisted_workspaces_from_agent_infos.map(&:name),
+ agent_id: agent.id,
+ update_type: update_type
+ )
+
+ # Update persisted workspaces which match the names of the workspaces in the AgentInfo objects array
+ persisted_workspaces_from_agent_infos.each do |persisted_workspace|
+ workspace_agent_info = workspace_agent_infos_by_name[persisted_workspace.name]
+ # Update the persisted workspaces with the latest info from the AgentInfo objects we received
+ update_persisted_workspace_with_latest_info(
+ persisted_workspace: persisted_workspace,
+ deployment_resource_version: workspace_agent_info.deployment_resource_version,
+ actual_state: workspace_agent_info.actual_state
+ )
+ end
+
+ if update_type == FULL
+ # For a FULL update, return all workspaces for the agent which exist in the database
+ workspaces_to_return_in_rails_infos_query = agent.workspaces.all
+ else
+ # For a PARTIAL update, return:
+ # 1. Workspaces with_desired_state_updated_more_recently_than_last_response_to_agent
+ # 2. Workspaces which we received from the agent in the agent_infos array
+ workspaces_from_agent_infos_ids = persisted_workspaces_from_agent_infos.map(&:id)
+ workspaces_to_return_in_rails_infos_query =
+ agent
+ .workspaces
+ .with_desired_state_updated_more_recently_than_last_response_to_agent
+ .or(agent.workspaces.id_in(workspaces_from_agent_infos_ids))
+ end
+
+ workspaces_to_return_in_rails_infos = workspaces_to_return_in_rails_infos_query.to_a
+
+ # Create an array workspace_rails_info hashes based on the workspaces. These indicate the desired updates
+ # to the workspace, which will be returned in the payload to the agent to be applied to kubernetes
+ workspace_rails_infos = workspaces_to_return_in_rails_infos.map do |workspace|
+ workspace_rails_info = {
+ name: workspace.name,
+ namespace: workspace.namespace,
+ desired_state: workspace.desired_state,
+ actual_state: workspace.actual_state,
+ deployment_resource_version: workspace.deployment_resource_version,
+ # NOTE: config_to_apply will be null if there is no config to apply, i.e. if a guard clause returned false
+ config_to_apply: config_to_apply(workspace: workspace, update_type: update_type)
+ }
+
+ workspace_rails_info
+ end
+
+ # Update the responded_to_agent_at at this point, after we have already done all the calculations
+ # related to state. Do it outside of the loop so it will be a single query, and also so that they
+ # will all have the same timestamp.
+ # noinspection RailsParamDefResolve
+ workspaces_to_return_in_rails_infos_query.touch_all(:responded_to_agent_at)
+
+ payload = { workspace_rails_infos: workspace_rails_infos }
+
+ logger.debug(
+ message: 'Returning workspace_rails_infos',
+ agent_id: agent.id,
+ update_type: update_type,
+ count: workspace_rails_infos.length,
+ workspace_rails_infos: workspace_rails_infos.map do |rails_info|
+ {
+ name: rails_info.fetch(:name),
+ namespace: rails_info.fetch(:namespace),
+ desired_state: rails_info.fetch(:desired_state),
+ actual_state: rails_info.fetch(:actual_state),
+ deployment_resource_version: rails_info.fetch(:deployment_resource_version)
+ }
+ end
+ )
+
+ [payload, nil]
+ end
+ # rubocop:enable Metrics/AbcSize
+
+ private
+
+ def config_to_apply(workspace:, update_type:)
+ # NOTE: If update_type==FULL, we always return the config.
+ return if update_type == PARTIAL &&
+ !workspace.desired_state_updated_more_recently_than_last_response_to_agent?
+
+ workspace_resources = DesiredConfigGenerator.new.generate_desired_config(workspace: workspace)
+
+ desired_config_to_apply_array = workspace_resources.map do |resource|
+ YAML.dump(resource)
+ end
+
+ return unless desired_config_to_apply_array.present?
+
+ desired_config_to_apply_array.join
+ end
+
+ def check_for_orphaned_workspaces(
+ workspace_agent_infos_by_name:,
+ persisted_workspace_names:,
+ agent_id:,
+ update_type:
+ )
+ orphaned_workspace_agent_infos = workspace_agent_infos_by_name.reject do |name, _|
+ persisted_workspace_names.include?(name)
+ end.values
+
+ return unless orphaned_workspace_agent_infos.present?
+
+ logger.warn(
+ message:
+ 'Received orphaned workspace agent info for workspace(s) where no persisted workspace record exists',
+ error_type: 'orphaned_workspace',
+ agent_id: agent_id,
+ update_type: update_type,
+ count: orphaned_workspace_agent_infos.length,
+ orphaned_workspace_names: orphaned_workspace_agent_infos.map(&:name),
+ orphaned_workspace_namespaces: orphaned_workspace_agent_infos.map(&:namespace)
+ )
+ end
+
+ def update_persisted_workspace_with_latest_info(
+ persisted_workspace:,
+ deployment_resource_version:,
+ actual_state:
+ )
+ # Handle the special case of RESTART_REQUESTED. desired_state is only set to 'RESTART_REQUESTED' until the
+ # actual_state is detected as 'STOPPED', then we switch the desired_state to 'RUNNING' so it will restart.
+ # See: https://gitlab.com/gitlab-org/remote-development/gitlab-remote-development-docs/blob/main/doc/architecture.md?plain=0#possible-desired_state-values
+ if persisted_workspace.desired_state == States::RESTART_REQUESTED && actual_state == States::STOPPED
+ persisted_workspace.desired_state = States::RUNNING
+ end
+
+ # Ensure workspaces are terminated after a max time-to-live. This is a temporary approach, we eventually want
+ # to replace this with some mechanism to detect workspace activity and only shut down inactive workspaces.
+ # Until then, this is the workaround to ensure workspaces don't live indefinitely.
+ # See https://gitlab.com/gitlab-org/gitlab/-/issues/390597
+ if persisted_workspace.created_at + persisted_workspace.max_hours_before_termination.hours < Time.current
+ persisted_workspace.desired_state = States::TERMINATED
+ end
+
+ persisted_workspace.actual_state = actual_state
+
+ # In some cases a deployment resource version may not be present, e.g. if the initial creation request for the
+ # workspace creation resulted in an Error.
+ persisted_workspace.deployment_resource_version = deployment_resource_version if deployment_resource_version
+
+ persisted_workspace.save!
+ end
+
+ # TODO: https://gitlab.com/groups/gitlab-org/-/epics/10461
+ # Dry up memoized logger factory to a shared concern
+ def logger
+ @logger ||= RemoteDevelopment::Logger.build
+ end
+ end
+ end
+ end
+end
diff --git a/ee/lib/remote_development/workspaces/reconcile/update_type.rb b/ee/lib/remote_development/workspaces/reconcile/update_type.rb
new file mode 100644
index 0000000000000000000000000000000000000000..073fd0a4c84c67641b10c5cb1f012eecfc28ec19
--- /dev/null
+++ b/ee/lib/remote_development/workspaces/reconcile/update_type.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module RemoteDevelopment
+ module Workspaces
+ module Reconcile
+ module UpdateType
+ PARTIAL = 'partial'
+ FULL = 'full'
+ end
+ end
+ end
+end
diff --git a/ee/lib/remote_development/workspaces/states.rb b/ee/lib/remote_development/workspaces/states.rb
new file mode 100644
index 0000000000000000000000000000000000000000..7530f8485271cbe387ebcc52ce8e0082f497d0dd
--- /dev/null
+++ b/ee/lib/remote_development/workspaces/states.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+# See: https://gitlab.com/gitlab-org/remote-development/gitlab-remote-development-docs/blob/main/doc/architecture.md?plain=0#workspace-states
+module RemoteDevelopment
+ module Workspaces
+ module States
+ CREATION_REQUESTED = 'CreationRequested'
+ STARTING = 'Starting'
+ RESTART_REQUESTED = 'RestartRequested'
+ RUNNING = 'Running'
+ STOPPING = 'Stopping'
+ STOPPED = 'Stopped'
+ TERMINATING = 'Terminating'
+ TERMINATED = 'Terminated'
+ FAILED = 'Failed'
+ ERROR = 'Error'
+ UNKNOWN = 'Unknown'
+
+ VALID_DESIRED_STATES = [
+ RUNNING,
+ RESTART_REQUESTED,
+ STOPPED,
+ TERMINATED
+ ].freeze
+
+ VALID_ACTUAL_STATES = [
+ CREATION_REQUESTED, # Default initial actual_state for new workspace until we first receive it back from agentk
+ STARTING,
+ RUNNING,
+ STOPPING,
+ STOPPED,
+ TERMINATING,
+ TERMINATED,
+ FAILED,
+ ERROR,
+ UNKNOWN # NOTE: This is used if agentk couldn't determine the state, e.g. if informer does not provide the phase
+ ].freeze
+
+ def valid_desired_state?(string)
+ VALID_DESIRED_STATES.include?(string)
+ end
+
+ def valid_actual_state?(string)
+ VALID_ACTUAL_STATES.include?(string)
+ end
+ end
+ end
+end
diff --git a/ee/lib/remote_development/workspaces/update/update_processor.rb b/ee/lib/remote_development/workspaces/update/update_processor.rb
new file mode 100644
index 0000000000000000000000000000000000000000..6cc660b7267ebe317f2c3f27d27965b1aff877fa
--- /dev/null
+++ b/ee/lib/remote_development/workspaces/update/update_processor.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module RemoteDevelopment
+ module Workspaces
+ module Update
+ class UpdateProcessor
+ def process(workspace:, params:)
+ if workspace.update(params)
+ payload = { workspace: workspace }
+ [payload, nil]
+ else
+ err_msg = "Error(s) updating Workspace: #{workspace.errors.full_messages.join(', ')}"
+ error = Error.new(message: err_msg, reason: :bad_request)
+ [nil, error]
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/ee/spec/factories/clusters/agents.rb b/ee/spec/factories/clusters/agents.rb
new file mode 100644
index 0000000000000000000000000000000000000000..ee57ddcef9d5327eae5f77f812c1b01b961ac1a5
--- /dev/null
+++ b/ee/spec/factories/clusters/agents.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :ee_cluster_agent, class: 'Clusters::Agent', parent: :cluster_agent do
+ trait :with_remote_development_agent_config do
+ remote_development_agent_config
+ end
+ end
+end
diff --git a/ee/spec/factories/remote_development/remote_development_agent_configs.rb b/ee/spec/factories/remote_development/remote_development_agent_configs.rb
new file mode 100644
index 0000000000000000000000000000000000000000..4a43f089e1df4fd86559b0ea28f7244280dbd493
--- /dev/null
+++ b/ee/spec/factories/remote_development/remote_development_agent_configs.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :remote_development_agent_config, class: 'RemoteDevelopment::RemoteDevelopmentAgentConfig' do
+ agent factory: :cluster_agent
+ enabled { true }
+ # noinspection RubyResolve
+ dns_zone { 'workspaces.localdev.me' }
+ end
+end
diff --git a/ee/spec/factories/remote_development/workspaces.rb b/ee/spec/factories/remote_development/workspaces.rb
new file mode 100644
index 0000000000000000000000000000000000000000..4b93e6ca42c8fb98940e4ece2fe859e6b66f8e49
--- /dev/null
+++ b/ee/spec/factories/remote_development/workspaces.rb
@@ -0,0 +1,102 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :workspace, class: 'RemoteDevelopment::Workspace' do
+ # noinspection RailsParamDefResolve
+ project factory: [:project, :public, :in_group]
+ user
+ agent factory: [:ee_cluster_agent, :with_remote_development_agent_config]
+
+ random_string = SecureRandom.alphanumeric(6).downcase
+ name { "workspace-#{agent.id}-#{user.id}-#{random_string}" }
+
+ add_attribute(:namespace) { "gl-rd-ns-#{agent.id}-#{user.id}-#{random_string}" }
+
+ desired_state { RemoteDevelopment::Workspaces::States::STOPPED }
+ actual_state { RemoteDevelopment::Workspaces::States::STOPPED }
+ deployment_resource_version { 2 }
+ editor { 'webide' }
+ # noinspection RubyResolve
+ max_hours_before_termination { 24 }
+
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/409779
+ # Can we make factorybot retrieve the dns_zone from the agent's remote_development_agent_config
+ # so we can interpolate it here and ensure it is consistent?
+ url { "https://60001-#{name}.workspaces.localdev.me" }
+
+ # noinspection RubyResolve
+ devfile_ref { 'main' }
+ # noinspection RubyResolve
+ devfile_path { '.devfile.yaml' }
+
+ # noinspection RubyResolve
+ devfile do
+ # noinspection RubyMismatchedArgumentType
+ File.read(Rails.root.join('ee/spec/fixtures/remote_development/example.devfile.yaml'))
+ end
+
+ # noinspection RubyResolve
+ processed_devfile do
+ # noinspection RubyMismatchedArgumentType
+ File.read(Rails.root.join('ee/spec/fixtures/remote_development/example.processed-devfile.yaml'))
+ end
+
+ transient do
+ # noinspection RubyResolve
+ skip_realistic_after_create_timestamp_updates { false }
+ end
+
+ # Use this trait if you want to directly control any timestamp fields when invoking the factory.
+ trait :without_realistic_after_create_timestamp_updates do
+ transient do
+ # noinspection RubyResolve
+ skip_realistic_after_create_timestamp_updates { true }
+ end
+ end
+
+ after(:build) do |workspace, _|
+ user = workspace.user
+ workspace.project.add_developer(user)
+ workspace.agent.project.add_developer(user)
+ end
+
+ after(:create) do |workspace, evaluator|
+ # noinspection RubyResolve
+ if evaluator.skip_realistic_after_create_timestamp_updates
+ # noinspection RubyResolve
+ # Set responded_to_agent_at to a non-nil value unless it has already been set
+ workspace.update!(responded_to_agent_at: workspace.updated_at) unless workspace.responded_to_agent_at
+ else
+ if workspace.desired_state == workspace.actual_state
+ # The most recent activity was a poll that reconciled the desired and actual state.
+ desired_state_updated_at = 2.seconds.ago
+ responded_to_agent_at = 1.second.ago
+ else
+ # The most recent activity was a user action which updated the desired state to be different
+ # than the actual state.
+ desired_state_updated_at = 1.second.ago
+ responded_to_agent_at = 2.seconds.ago
+ end
+
+ workspace.update!(
+ # NOTE: created_at and updated_at are not currently used in any logic, but we set them to be
+ # before desired_state_updated_at or responded_to_agent_at to ensure the record represents
+ # a realistic condition.
+ created_at: 3.seconds.ago,
+ updated_at: 3.seconds.ago,
+
+ desired_state_updated_at: desired_state_updated_at,
+ responded_to_agent_at: responded_to_agent_at
+ )
+ end
+ end
+
+ trait :unprovisioned do
+ desired_state { RemoteDevelopment::Workspaces::States::RUNNING }
+ actual_state { RemoteDevelopment::Workspaces::States::CREATION_REQUESTED }
+ # noinspection RubyResolve
+ responded_to_agent_at { nil }
+ deployment_resource_version { nil }
+ end
+ end
+end
diff --git a/ee/spec/features/remote_development/workspaces_spec.rb b/ee/spec/features/remote_development/workspaces_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..876326a8ec46db3c82b0228b81de7354cc9cd8d9
--- /dev/null
+++ b/ee/spec/features/remote_development/workspaces_spec.rb
@@ -0,0 +1,188 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Remote Development workspaces', :api, :js, feature_category: :remote_development do
+ include_context 'with remote development shared fixtures'
+ include_context 'file upload requests helpers'
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:group) { create(:group, name: 'test-group') }
+ let_it_be(:devfile_path) { '.devfile.yaml' }
+
+ let_it_be(:project) do
+ files = { devfile_path => example_devfile }
+ create(:project, :public, :in_group, :custom_repo, path: 'test-project', files: files, namespace: group)
+ end
+
+ let_it_be(:agent) do
+ create(:ee_cluster_agent, :with_remote_development_agent_config, project: project, created_by_user: user)
+ end
+
+ let_it_be(:agent_token) { create(:cluster_agent_token, agent: agent, created_by_user: user) }
+
+ let(:reconcile_url) { capybara_url(api('/internal/kubernetes/modules/remote_development/reconcile', user)) }
+
+ before do
+ stub_licensed_features(remote_development: true)
+
+ group.add_developer(user)
+ allow(Gitlab::Kas).to receive(:verify_api_request).and_return(true)
+
+ # rubocop:disable RSpec/AnyInstanceOf - It's NOT the next instance...
+ allow_any_instance_of(Gitlab::Auth::AuthFinders)
+ .to receive(:cluster_agent_token_from_authorization_token) { agent_token }
+ # rubocop:enable RSpec/AnyInstanceOf
+
+ sign_in(user)
+ wait_for_requests
+ end
+
+ describe 'workspaces' do
+ context 'when creating' do
+ it 'creates a workspace' do
+ # Tips:
+ # use live_debug to pause when WEBDRIVER_HEADLESS=0
+ # live_debug
+
+ # NAVIGATE TO WORKSPACES PAGE
+
+ # noinspection RubyResolve
+ visit remote_development_workspaces_path
+ wait_for_requests
+
+ # CREATE WORKSPACE
+
+ click_link 'New workspace', match: :first
+ click_button 'Select a project'
+ page.find("li[data-testid='listbox-item-#{project.full_path}']").click
+ wait_for_requests
+ select agent.name, from: 'Select cluster agent'
+ click_button 'Create workspace'
+
+ # We look for the project GID because that's all we know about the workspace at this point. For the new UI,
+ # we will have to either expose this as a field on the new workspaces UI, or else come up
+ # with some more clever finder to assert on the workspace showing up in the list after a refresh.
+ page.find('td', text: project.name_with_namespace)
+
+ # GET NAME AND NAMESPACE OF NEW WORKSPACE
+ workspaces = RemoteDevelopment::Workspace.all.to_a
+ expect(workspaces.length).to eq(1)
+ workspace = workspaces[0]
+ id = workspace.id
+ name = workspace.name
+ namespace = workspace.namespace
+
+ # ASSERT ON NEW WORKSPACE IN LIST
+ page.find('td', text: name)
+
+ # ASSERT WORKSPACE STATE BEFORE POLLING NEW STATES
+ expect_workspace_state_indicator('Creating')
+
+ # ASSERT TERMINATE BUTTON IS AVAILABLE
+ expect(page).to have_button('Terminate')
+
+ # SIMULATE FIRST POLL FROM AGENTK TO PICK UP NEW WORKSPACE
+ simulate_first_poll(id: id, name: name, namespace: namespace)
+
+ # SIMULATE SECOND POLL FROM AGENTK TO UPDATE WORKSPACE TO RUNNING STATE
+
+ simulate_second_poll(id: id, name: name, namespace: namespace)
+
+ # ASSERT WORKSPACE SHOWS RUNNING STATE IN UI AND UPDATES URL
+ expect_workspace_state_indicator(RemoteDevelopment::Workspaces::States::RUNNING)
+ expect(page).to have_selector('a', text: workspace.url)
+
+ # ASSERT ACTION BUTTONS ARE CORRECT FOR RUNNING STATE
+ expect(page).to have_button('Restart')
+ expect(page).to have_button('Stop')
+ expect(page).to have_button('Terminate')
+
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/409780 - Add state transition to TERMINATED
+ end
+
+ def simulate_first_poll(id:, name:, namespace:)
+ # SIMULATE FIRST POLL REQUEST FROM AGENTK TO GET NEW WORKSPACE
+
+ reconcile_post_response = simulate_agentk_reconcile_post(workspace_agent_infos: [])
+
+ # ASSERT ON RESPONSE TO FIRST POLL REQUEST CONTAINING NEW WORKSPACE
+
+ expect(reconcile_post_response.code).to eq(HTTP::Status::CREATED)
+ infos = Gitlab::Json.parse(reconcile_post_response.body).deep_symbolize_keys[:workspace_rails_infos]
+ expect(infos.length).to eq(1)
+ info = infos.first
+
+ expect(info.fetch(:name)).to eq(name)
+ expect(info.fetch(:namespace)).to eq(namespace)
+ expect(info.fetch(:desired_state)).to eq(RemoteDevelopment::Workspaces::States::RUNNING)
+ expect(info.fetch(:actual_state)).to eq(RemoteDevelopment::Workspaces::States::CREATION_REQUESTED)
+ expect(info.fetch(:deployment_resource_version)).to be_nil
+
+ expected_config_to_apply = create_config_to_apply(
+ workspace_id: id,
+ workspace_name: name,
+ workspace_namespace: namespace,
+ agent_id: agent.id,
+ owning_inventory: "#{name}-workspace-inventory",
+ started: true
+ )
+
+ config_to_apply = info.fetch(:config_to_apply)
+ expect(config_to_apply).to eq(expected_config_to_apply)
+ end
+
+ def simulate_second_poll(id:, name:, namespace:)
+ # SIMULATE SECOND POLL REQUEST FROM AGENTK TO UPDATE WORKSPACE TO RUNNING STATE
+
+ resource_version = '1'
+ workspace_agent_info = create_workspace_agent_info(
+ workspace_id: id,
+ workspace_name: name,
+ workspace_namespace: namespace,
+ agent_id: agent.id,
+ owning_inventory: "#{name}-workspace-inventory",
+ resource_version: resource_version,
+ previous_actual_state: RemoteDevelopment::Workspaces::States::STARTING,
+ current_actual_state: RemoteDevelopment::Workspaces::States::RUNNING,
+ workspace_exists: false
+ )
+ reconcile_post_response =
+ simulate_agentk_reconcile_post(workspace_agent_infos: [workspace_agent_info])
+
+ # ASSERT ON RESPONSE TO SECOND POLL REQUEST
+
+ expect(reconcile_post_response.code).to eq(HTTP::Status::CREATED)
+ infos = Gitlab::Json.parse(reconcile_post_response.body).deep_symbolize_keys[:workspace_rails_infos]
+ expect(infos.length).to eq(1)
+ info = infos.first
+
+ expect(info.fetch(:name)).to eq(name)
+ expect(info.fetch(:namespace)).to eq(namespace)
+ expect(info.fetch(:desired_state)).to eq(RemoteDevelopment::Workspaces::States::RUNNING)
+ expect(info.fetch(:actual_state)).to eq(RemoteDevelopment::Workspaces::States::RUNNING)
+ expect(info.fetch(:deployment_resource_version)).to eq(resource_version)
+ expect(info.fetch(:config_to_apply)).to be_nil
+ end
+
+ def expect_workspace_state_indicator(state)
+ expect(page).to have_selector("svg[data-testid='workspace-state-indicator'][title='#{state}']")
+ end
+
+ def simulate_agentk_reconcile_post(workspace_agent_infos:)
+ post_params = {
+ update_type: 'partial',
+ workspace_agent_infos: workspace_agent_infos
+ }
+
+ # Note: HTTParty doesn't handle empty arrays right, so we have to be explicit with content type and send JSON.
+ # See https://github.com/jnunemaker/httparty/issues/494
+ HTTParty.post(
+ reconcile_url,
+ headers: { 'Content-Type' => 'application/json' },
+ body: post_params.compact.to_json
+ )
+ end
+ end
+ end
+end
diff --git a/ee/spec/finders/remote_development/workspaces_finder_spec.rb b/ee/spec/finders/remote_development/workspaces_finder_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..114c9d2f7802a28ceea1c09a169bd791fc7a66fd
--- /dev/null
+++ b/ee/spec/finders/remote_development/workspaces_finder_spec.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe RemoteDevelopment::WorkspacesFinder, feature_category: :remote_development do
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:workspace_a) { create(:workspace, user: current_user, updated_at: 2.days.ago) }
+ let_it_be(:workspace_b) { create(:workspace, user: current_user, updated_at: 1.day.ago) }
+
+ subject { described_class.new(current_user, params).execute }
+
+ context 'with valid license' do
+ before do
+ stub_licensed_features(remote_development: true)
+ end
+
+ context 'with blank params' do
+ let(:params) { {} }
+
+ it "returns current user's workspaces sorted by last updated time (most recent first)" do
+ workspaces = subject.to_ary
+
+ # The assertions below can be replaced concisely with
+ # eq([workspace_b, workspace_a])
+ # However, doing so results in a dangerbot warning that is difficult to suppress
+ # Unfortunatly there isn't an exact-order array matcher that doesn't result in a dangerbot warning.
+ expect(workspaces.length).to eq(2)
+ # noinspection RubyResolve
+ expect(workspaces.first).to eq(workspace_b)
+ # noinspection RubyResolve
+ expect(workspaces.last).to eq(workspace_a)
+ end
+ end
+
+ context 'with id in params' do
+ # noinspection RubyResolve
+ let(:params) { { ids: [workspace_a.id] } }
+
+ it "returns only current user's workspaces matching the specified IDs" do
+ # noinspection RubyResolve
+ expect(subject).to contain_exactly(workspace_a)
+ end
+ end
+
+ context 'without current user' do
+ subject { described_class.new(nil, params).execute }
+
+ # noinspection RubyResolve
+ let(:params) { { ids: [workspace_a.id] } }
+
+ it 'returns none' do
+ expect(subject).to be_blank
+ end
+ end
+ end
+
+ context 'without valid license' do
+ let(:params) { {} }
+
+ it 'returns none' do
+ expect(subject).to be_blank
+ end
+ end
+end
diff --git a/ee/spec/fixtures/remote_development/example.devfile.yaml b/ee/spec/fixtures/remote_development/example.devfile.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..b0b397aef5458a97acac74eef849c7f4f95ebbd0
--- /dev/null
+++ b/ee/spec/fixtures/remote_development/example.devfile.yaml
@@ -0,0 +1,8 @@
+---
+schemaVersion: 2.2.0
+components:
+ - name: tooling-container
+ attributes:
+ gl/inject-editor: true
+ container:
+ image: quay.io/mloriedo/universal-developer-image:ubi8-dw-demo
diff --git a/ee/spec/fixtures/remote_development/example.invalid-attributes-editor-injector-absent-devfile.yaml b/ee/spec/fixtures/remote_development/example.invalid-attributes-editor-injector-absent-devfile.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..3b26e99b12019321744ab0be1c91e48e3bf0242a
--- /dev/null
+++ b/ee/spec/fixtures/remote_development/example.invalid-attributes-editor-injector-absent-devfile.yaml
@@ -0,0 +1,6 @@
+---
+schemaVersion: 2.2.0
+components:
+ - name: tooling-container
+ container:
+ image: quay.io/mloriedo/universal-developer-image:ubi8-dw-demo
diff --git a/ee/spec/fixtures/remote_development/example.invalid-attributes-editor-injector-multiple-devfile.yaml b/ee/spec/fixtures/remote_development/example.invalid-attributes-editor-injector-multiple-devfile.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..555f6714c3944a7979a0ea21580f1c51661bb824
--- /dev/null
+++ b/ee/spec/fixtures/remote_development/example.invalid-attributes-editor-injector-multiple-devfile.yaml
@@ -0,0 +1,13 @@
+---
+schemaVersion: 2.2.0
+components:
+ - name: tooling-container
+ attributes:
+ gl/inject-editor: true
+ container:
+ image: quay.io/mloriedo/universal-developer-image:ubi8-dw-demo
+ - name: tooling-container-2
+ attributes:
+ gl/inject-editor: true
+ container:
+ image: quay.io/mloriedo/universal-developer-image:ubi8-dw-demo
diff --git a/ee/spec/fixtures/remote_development/example.invalid-no-components-devfile.yaml b/ee/spec/fixtures/remote_development/example.invalid-no-components-devfile.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..06ae3366eee5ab00c4ae833660284ec86d0d342a
--- /dev/null
+++ b/ee/spec/fixtures/remote_development/example.invalid-no-components-devfile.yaml
@@ -0,0 +1,2 @@
+---
+schemaVersion: 2.2.0
diff --git a/ee/spec/fixtures/remote_development/example.invalid-restricted-prefix-command-apply-component-name-devfile.yaml b/ee/spec/fixtures/remote_development/example.invalid-restricted-prefix-command-apply-component-name-devfile.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..c0cc9298ca12f1a86762872e74163c0fa1a518e5
--- /dev/null
+++ b/ee/spec/fixtures/remote_development/example.invalid-restricted-prefix-command-apply-component-name-devfile.yaml
@@ -0,0 +1,12 @@
+---
+schemaVersion: 2.2.0
+components:
+ - name: example
+ attributes:
+ gl/inject-editor: true
+ container:
+ image: quay.io/mloriedo/universal-developer-image:ubi8-dw-demo
+commands:
+ - id: example
+ apply:
+ component: gl-example
diff --git a/ee/spec/fixtures/remote_development/example.invalid-restricted-prefix-command-exec-component-name-devfile.yaml b/ee/spec/fixtures/remote_development/example.invalid-restricted-prefix-command-exec-component-name-devfile.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..ab31818fd9ed597563ff33624aab8702c0e32d7a
--- /dev/null
+++ b/ee/spec/fixtures/remote_development/example.invalid-restricted-prefix-command-exec-component-name-devfile.yaml
@@ -0,0 +1,14 @@
+---
+schemaVersion: 2.2.0
+components:
+ - name: example
+ attributes:
+ gl/inject-editor: true
+ container:
+ image: quay.io/mloriedo/universal-developer-image:ubi8-dw-demo
+commands:
+ - id: example
+ exec:
+ component: gl-example
+ commandLine: mvn clean
+ workingDir: /projects/spring-petclinic
diff --git a/ee/spec/fixtures/remote_development/example.invalid-restricted-prefix-command-name-devfile.yaml b/ee/spec/fixtures/remote_development/example.invalid-restricted-prefix-command-name-devfile.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..e646c33691f4935ff71416537b03f9447aeba8c4
--- /dev/null
+++ b/ee/spec/fixtures/remote_development/example.invalid-restricted-prefix-command-name-devfile.yaml
@@ -0,0 +1,14 @@
+---
+schemaVersion: 2.2.0
+components:
+ - name: example
+ attributes:
+ gl/inject-editor: true
+ container:
+ image: quay.io/mloriedo/universal-developer-image:ubi8-dw-demo
+commands:
+ - id: gl-example
+ exec:
+ component: example
+ commandLine: mvn clean
+ workingDir: /projects/spring-petclinic
diff --git a/ee/spec/fixtures/remote_development/example.invalid-restricted-prefix-component-container-endpoint-name-devfile.yaml b/ee/spec/fixtures/remote_development/example.invalid-restricted-prefix-component-container-endpoint-name-devfile.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..c6d2811ffc16bf6cf23060df4080c33f45cabaf0
--- /dev/null
+++ b/ee/spec/fixtures/remote_development/example.invalid-restricted-prefix-component-container-endpoint-name-devfile.yaml
@@ -0,0 +1,13 @@
+---
+schemaVersion: 2.2.0
+components:
+ - name: example
+ attributes:
+ gl/inject-editor: true
+ container:
+ image: quay.io/mloriedo/universal-developer-image:ubi8-dw-demo
+ endpoints:
+ - name: gl-example
+ targetPort: 3101
+ protocol: https
+ exposure: none
diff --git a/ee/spec/fixtures/remote_development/example.invalid-restricted-prefix-component-name-devfile.yaml b/ee/spec/fixtures/remote_development/example.invalid-restricted-prefix-component-name-devfile.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..15d6d5777fa3b88f0a637212b155259c0b8fa89e
--- /dev/null
+++ b/ee/spec/fixtures/remote_development/example.invalid-restricted-prefix-component-name-devfile.yaml
@@ -0,0 +1,8 @@
+---
+schemaVersion: 2.2.0
+components:
+ - name: gl-example
+ attributes:
+ gl/inject-editor: true
+ container:
+ image: quay.io/mloriedo/universal-developer-image:ubi8-dw-demo
diff --git a/ee/spec/fixtures/remote_development/example.invalid-restricted-prefix-event-type-poststart-name-devfile.yaml b/ee/spec/fixtures/remote_development/example.invalid-restricted-prefix-event-type-poststart-name-devfile.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..e035558b538de0c7bcce2de1bc124a668aa94def
--- /dev/null
+++ b/ee/spec/fixtures/remote_development/example.invalid-restricted-prefix-event-type-poststart-name-devfile.yaml
@@ -0,0 +1,18 @@
+---
+schemaVersion: 2.2.0
+components:
+ - name: example
+ attributes:
+ gl/inject-editor: true
+ container:
+ image: quay.io/mloriedo/universal-developer-image:ubi8-dw-demo
+commands:
+ - id: example
+ exec:
+ component: example
+ commandLine: mvn clean
+ workingDir: /projects/spring-petclinic
+events:
+ postStart:
+ - example
+ - gl-example
diff --git a/ee/spec/fixtures/remote_development/example.invalid-restricted-prefix-event-type-prestart-name-devfile.yaml b/ee/spec/fixtures/remote_development/example.invalid-restricted-prefix-event-type-prestart-name-devfile.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..9a731c506ccf2925a347569f950118cd813095b3
--- /dev/null
+++ b/ee/spec/fixtures/remote_development/example.invalid-restricted-prefix-event-type-prestart-name-devfile.yaml
@@ -0,0 +1,18 @@
+---
+schemaVersion: 2.2.0
+components:
+ - name: example
+ attributes:
+ gl/inject-editor: true
+ container:
+ image: quay.io/mloriedo/universal-developer-image:ubi8-dw-demo
+commands:
+ - id: example
+ exec:
+ component: example
+ commandLine: mvn clean
+ workingDir: /projects/spring-petclinic
+events:
+ preStart:
+ - example
+ - gl-example
diff --git a/ee/spec/fixtures/remote_development/example.invalid-restricted-prefix-event-type-prestop-name-devfile.yaml b/ee/spec/fixtures/remote_development/example.invalid-restricted-prefix-event-type-prestop-name-devfile.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..218a480a3b583dc3f5821a8a02ffbd23f84af477
--- /dev/null
+++ b/ee/spec/fixtures/remote_development/example.invalid-restricted-prefix-event-type-prestop-name-devfile.yaml
@@ -0,0 +1,18 @@
+---
+schemaVersion: 2.2.0
+components:
+ - name: example
+ attributes:
+ gl/inject-editor: true
+ container:
+ image: quay.io/mloriedo/universal-developer-image:ubi8-dw-demo
+commands:
+ - id: example
+ exec:
+ component: example
+ commandLine: mvn clean
+ workingDir: /projects/spring-petclinic
+events:
+ preStop:
+ - example
+ - gl-example
diff --git a/ee/spec/fixtures/remote_development/example.invalid-restricted-prefix-variable-name-devfile.yaml b/ee/spec/fixtures/remote_development/example.invalid-restricted-prefix-variable-name-devfile.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..0d28d092f76409f57dc3cf0763c051cd0166683d
--- /dev/null
+++ b/ee/spec/fixtures/remote_development/example.invalid-restricted-prefix-variable-name-devfile.yaml
@@ -0,0 +1,10 @@
+---
+schemaVersion: 2.2.0
+components:
+ - name: example
+ attributes:
+ gl/inject-editor: true
+ container:
+ image: quay.io/mloriedo/universal-developer-image:ubi8-dw-demo
+variables:
+ gl-example: "abc"
diff --git a/ee/spec/fixtures/remote_development/example.invalid-restricted-prefix-variable-name-with-underscore-devfile.yaml b/ee/spec/fixtures/remote_development/example.invalid-restricted-prefix-variable-name-with-underscore-devfile.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..0b9e88a68fbd0ee6d881ddd22f53eefad9a46048
--- /dev/null
+++ b/ee/spec/fixtures/remote_development/example.invalid-restricted-prefix-variable-name-with-underscore-devfile.yaml
@@ -0,0 +1,10 @@
+---
+schemaVersion: 2.2.0
+components:
+ - name: example
+ attributes:
+ gl/inject-editor: true
+ container:
+ image: quay.io/mloriedo/universal-developer-image:ubi8-dw-demo
+variables:
+ gl_example: "abc"
diff --git a/ee/spec/fixtures/remote_development/example.invalid-unsupported-component-type-image-devfile.yaml b/ee/spec/fixtures/remote_development/example.invalid-unsupported-component-type-image-devfile.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..63d39dbb0f6ce1ebfa6363d20d8ee18304aa072f
--- /dev/null
+++ b/ee/spec/fixtures/remote_development/example.invalid-unsupported-component-type-image-devfile.yaml
@@ -0,0 +1,15 @@
+---
+schemaVersion: 2.2.0
+components:
+ - name: example
+ attributes:
+ gl/inject-editor: true
+ image:
+ imageName: python-image:latest
+ autoBuild: true
+ dockerfile:
+ uri: docker/Dockerfile
+ args:
+ - 'MY_ENV=/home/path'
+ buildContext: .
+ rootRequired: false
diff --git a/ee/spec/fixtures/remote_development/example.invalid-unsupported-component-type-kubernetes-devfile.yaml b/ee/spec/fixtures/remote_development/example.invalid-unsupported-component-type-kubernetes-devfile.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..ff729b338db0f9bcb4d49ec58989dafacac34c93
--- /dev/null
+++ b/ee/spec/fixtures/remote_development/example.invalid-unsupported-component-type-kubernetes-devfile.yaml
@@ -0,0 +1,20 @@
+---
+schemaVersion: 2.2.0
+components:
+ - name: example
+ attributes:
+ gl/inject-editor: true
+ kubernetes:
+ inlined: |
+ apiVersion: batch/v1
+ kind: Job
+ metadata:
+ name: pi
+ spec:
+ template:
+ spec:
+ containers:
+ - name: job
+ image: myimage
+ command: ["some", "command"]
+ restartPolicy: Never
diff --git a/ee/spec/fixtures/remote_development/example.invalid-unsupported-component-type-openshift-devfile.yaml b/ee/spec/fixtures/remote_development/example.invalid-unsupported-component-type-openshift-devfile.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..a0f7812e98058510746e4dbbce5908e7f8059bad
--- /dev/null
+++ b/ee/spec/fixtures/remote_development/example.invalid-unsupported-component-type-openshift-devfile.yaml
@@ -0,0 +1,20 @@
+---
+schemaVersion: 2.2.0
+components:
+ - name: example
+ attributes:
+ gl/inject-editor: true
+ openshift:
+ inlined: |
+ apiVersion: batch/v1
+ kind: Job
+ metadata:
+ name: pi
+ spec:
+ template:
+ spec:
+ containers:
+ - name: job
+ image: myimage
+ command: ["some", "command"]
+ restartPolicy: Never
diff --git a/ee/spec/fixtures/remote_development/example.invalid-unsupported-parent-inheritance-devfile.yaml b/ee/spec/fixtures/remote_development/example.invalid-unsupported-parent-inheritance-devfile.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..ca1e7f122397bb8260056e1e1cb5188e5dd7bf30
--- /dev/null
+++ b/ee/spec/fixtures/remote_development/example.invalid-unsupported-parent-inheritance-devfile.yaml
@@ -0,0 +1,11 @@
+---
+schemaVersion: 2.2.0
+parent:
+ id: go
+ registryUrl: 'https://registry.devfile.io'
+components:
+ - name: example
+ attributes:
+ gl/inject-editor: true
+ container:
+ image: quay.io/mloriedo/universal-developer-image:ubi8-dw-demo
diff --git a/ee/spec/fixtures/remote_development/example.processed-devfile.yaml b/ee/spec/fixtures/remote_development/example.processed-devfile.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..817b960f0db1eb908b5ccdf22fc57c2c978bdaa0
--- /dev/null
+++ b/ee/spec/fixtures/remote_development/example.processed-devfile.yaml
@@ -0,0 +1,75 @@
+---
+schemaVersion: 2.2.0
+metadata: {}
+components:
+ - name: tooling-container
+ attributes:
+ gl/inject-editor: true
+ container:
+ image: quay.io/mloriedo/universal-developer-image:ubi8-dw-demo
+ command:
+ - /projects/.gl-editor/start_server.sh
+ volumeMounts:
+ - name: gl-workspace-data
+ path: /projects
+ env:
+ - name: EDITOR_VOLUME_DIR
+ value: "/projects/.gl-editor"
+ - name: EDITOR_PORT
+ value: "60001"
+ endpoints:
+ - name: editor-server
+ targetPort: 60001
+ exposure: public
+ secure: true
+ protocol: https
+ dedicatedPod: false
+ mountSources: true
+ - name: gl-workspace-data
+ volume:
+ size: 15Gi
+ - name: gl-editor-injector
+ container:
+ image: registry.gitlab.com/gitlab-org/gitlab-web-ide-vscode-fork/web-ide-injector:1
+ volumeMounts:
+ - name: gl-workspace-data
+ path: /projects
+ env:
+ - name: EDITOR_VOLUME_DIR
+ value: "/projects/.gl-editor"
+ - name: EDITOR_PORT
+ value: "60001"
+ memoryLimit: 128Mi
+ memoryRequest: 32Mi
+ cpuLimit: 500m
+ cpuRequest: 30m
+ - name: gl-cloner-injector
+ container:
+ image: alpine/git:2.36.3
+ volumeMounts:
+ - name: gl-workspace-data
+ path: "/projects"
+ args:
+ - |-
+ if [ ! -d '/projects/test-project' ];
+ then
+ git clone --branch master http://localhost/test-group/test-project.git /projects/test-project;
+ fi
+ command:
+ - "/bin/sh"
+ - "-c"
+ memoryLimit: 128Mi
+ memoryRequest: 32Mi
+ cpuLimit: 500m
+ cpuRequest: 30m
+events:
+ preStart:
+ - gl-editor-injector-command
+ - gl-cloner-injector-command
+commands:
+ - id: gl-editor-injector-command
+ apply:
+ component: gl-editor-injector
+ - id: gl-cloner-injector-command
+ apply:
+ component: gl-cloner-injector
diff --git a/ee/spec/frontend/remote_development/components/list/workspaces_table_spec.js b/ee/spec/frontend/remote_development/components/list/workspaces_table_spec.js
index efc4e554775332dbe8de9a06eb385f0dcdcdbfbb..15ce6ea2bc46762050e16314ccf62dbc614e3553 100644
--- a/ee/spec/frontend/remote_development/components/list/workspaces_table_spec.js
+++ b/ee/spec/frontend/remote_development/components/list/workspaces_table_spec.js
@@ -8,6 +8,7 @@ import { convertToGraphQLId } from '~/graphql_shared/utils';
import { TYPE_WORKSPACE } from '~/graphql_shared/constants';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
+import workspaceUpdateMutation from 'ee/remote_development/graphql/mutations/workspace_update.mutation.graphql';
import WorkspacesTable, { i18n } from 'ee/remote_development/components/list/workspaces_table.vue';
import WorkspaceActions from 'ee/remote_development/components/list/workspace_actions.vue';
import WorkspaceStateIndicator from 'ee/remote_development/components/list/workspace_state_indicator.vue';
@@ -58,11 +59,9 @@ describe('remote_development/components/list/workspaces_table.vue', () => {
workspaceUpdateMutationHandler = jest.fn();
workspaceUpdateMutationHandler.mockResolvedValueOnce(WORKSPACE_UPDATE_MUTATION_RESULT);
- const mockApollo = createMockApollo([], {
- Mutation: {
- workspaceUpdate: workspaceUpdateMutationHandler,
- },
- });
+ const mockApollo = createMockApollo([
+ [workspaceUpdateMutation, workspaceUpdateMutationHandler],
+ ]);
wrapper = mount(WorkspacesTable, {
apolloProvider: mockApollo,
@@ -153,17 +152,12 @@ describe('remote_development/components/list/workspaces_table.vue', () => {
await waitForPromises();
- expect(workspaceUpdateMutationHandler).toHaveBeenCalledWith(
- expect.any(Object),
- {
- input: {
- desiredState: TEST_DESIRED_STATE,
- id: convertToGraphQLId(TYPE_WORKSPACE, workspace.id),
- },
+ expect(workspaceUpdateMutationHandler).toHaveBeenCalledWith({
+ input: {
+ desiredState: TEST_DESIRED_STATE,
+ id: convertToGraphQLId(TYPE_WORKSPACE, workspace.id),
},
- expect.any(Object),
- expect.any(Object),
- );
+ });
});
describe('when the workspaceUpdate mutation returns an error response', () => {
@@ -175,7 +169,7 @@ describe('remote_development/components/list/workspaces_table.vue', () => {
errorResponse.data.workspaceUpdate.errors = [errorMessage];
workspaceUpdateMutationHandler.mockReset();
- workspaceUpdateMutationHandler.mockResolvedValueOnce(errorResponse.data.workspaceUpdate);
+ workspaceUpdateMutationHandler.mockResolvedValueOnce(errorResponse);
workspaceActions.vm.$emit('click', TEST_DESIRED_STATE);
diff --git a/ee/spec/frontend/remote_development/mock_data/index.js b/ee/spec/frontend/remote_development/mock_data/index.js
index 3387efbd83c4d36ca010d46483ad4bb32bafeedd..3dd4094afbbd60181f292c88933893ec23017ffb 100644
--- a/ee/spec/frontend/remote_development/mock_data/index.js
+++ b/ee/spec/frontend/remote_development/mock_data/index.js
@@ -33,7 +33,7 @@ export const USER_WORKSPACES_QUERY_RESULT = {
namespace: 'gl-rd-ns-1-1-idmi02',
desiredState: 'Stopped',
actualState: 'CreationRequested',
- url: 'http://8000-workspace-1-1-idmi02.workspaces.localdev.me?tkn=password',
+ url: 'https://8000-workspace-1-1-idmi02.workspaces.localdev.me?tkn=password',
devfileRef: 'main',
devfilePath: '.devfile.yaml',
projectId: 'gid://gitlab/Project/2',
@@ -45,7 +45,7 @@ export const USER_WORKSPACES_QUERY_RESULT = {
namespace: 'gl-rd-ns-1-1-rfu27q',
desiredState: 'Running',
actualState: 'Running',
- url: 'http://8000-workspace-1-1-rfu27q.workspaces.localdev.me?tkn=password',
+ url: 'https://8000-workspace-1-1-rfu27q.workspaces.localdev.me?tkn=password',
devfileRef: 'main',
devfilePath: '.devfile.yaml',
projectId: 'gid://gitlab/Project/2',
diff --git a/ee/spec/frontend/remote_development/pages/create_spec.js b/ee/spec/frontend/remote_development/pages/create_spec.js
index 4d3a8faa76712010752f65e1aa40c6588d3cee34..12505d7bc75ba764f052537b5f05c29aa0f6863b 100644
--- a/ee/spec/frontend/remote_development/pages/create_spec.js
+++ b/ee/spec/frontend/remote_development/pages/create_spec.js
@@ -14,10 +14,12 @@ import {
DEFAULT_DEVFILE_PATH,
ROUTES,
PROJECT_VISIBILITY,
+ WORKSPACES_LIST_PAGE_SIZE,
} from 'ee/remote_development/constants';
import waitForPromises from 'helpers/wait_for_promises';
import { logError } from '~/lib/logger';
import { createAlert } from '~/alert';
+import workspaceCreateMutation from 'ee/remote_development/graphql/mutations/workspace_create.mutation.graphql';
import userWorkspacesQuery from 'ee/remote_development/graphql/queries/user_workspaces_list.query.graphql';
import {
GET_PROJECT_DETAILS_QUERY_RESULT,
@@ -51,38 +53,39 @@ describe('remote_development/pages/create.vue', () => {
const buildMockApollo = () => {
workspaceCreateMutationHandler = jest.fn();
- workspaceCreateMutationHandler.mockResolvedValueOnce(
- WORKSPACE_CREATE_MUTATION_RESULT.data.workspaceCreate,
- );
- mockApollo = createMockApollo([], {
- Mutation: {
- workspaceCreate: workspaceCreateMutationHandler,
- },
- });
+ workspaceCreateMutationHandler.mockResolvedValueOnce(WORKSPACE_CREATE_MUTATION_RESULT);
+ mockApollo = createMockApollo([[workspaceCreateMutation, workspaceCreateMutationHandler]]);
};
const readCachedWorkspaces = () => {
const apolloClient = mockApollo.clients.defaultClient;
- const result = apolloClient.readQuery({ query: userWorkspacesQuery });
+ const result = apolloClient.readQuery({
+ query: userWorkspacesQuery,
+ variables: {
+ before: null,
+ after: null,
+ first: WORKSPACES_LIST_PAGE_SIZE,
+ },
+ });
return result?.currentUser.workspaces.nodes;
};
- const writeCachedWorkspaces = (worksspaces) => {
+ const writeCachedWorkspaces = (workspaces) => {
const apolloClient = mockApollo.clients.defaultClient;
apolloClient.writeQuery({
query: userWorkspacesQuery,
+ variables: {
+ before: null,
+ after: null,
+ first: WORKSPACES_LIST_PAGE_SIZE,
+ },
data: {
currentUser: {
...USER_WORKSPACES_QUERY_RESULT.data.currentUser,
workspaces: {
- nodes: worksspaces,
- pageInfo: {
- endCursor: null,
- startCursor: null,
- hasNextPage: false,
- hasPreviousPage: false,
- },
+ nodes: workspaces,
+ pageInfo: USER_WORKSPACES_QUERY_RESULT.data.currentUser.workspaces.pageInfo,
},
},
},
@@ -284,22 +287,17 @@ describe('remote_development/pages/create.vue', () => {
await nextTick();
await submitCreateWorkspaceForm();
- expect(workspaceCreateMutationHandler).toHaveBeenCalledWith(
- expect.any(Object),
- {
- input: {
- clusterAgentId: selectedClusterAgentIDFixture,
- projectId: GET_PROJECT_DETAILS_QUERY_RESULT.data.project.id,
- editor: DEFAULT_EDITOR,
- desiredState: DEFAULT_DESIRED_STATE,
- devfilePath: DEFAULT_DEVFILE_PATH,
- maxHoursBeforeTermination,
- devfileRef: rootRefFixture,
- },
+ expect(workspaceCreateMutationHandler).toHaveBeenCalledWith({
+ input: {
+ clusterAgentId: selectedClusterAgentIDFixture,
+ projectId: GET_PROJECT_DETAILS_QUERY_RESULT.data.project.id,
+ editor: DEFAULT_EDITOR,
+ desiredState: DEFAULT_DESIRED_STATE,
+ devfilePath: DEFAULT_DEVFILE_PATH,
+ maxHoursBeforeTermination,
+ devfileRef: rootRefFixture,
},
- expect.any(Object),
- expect.any(Object),
- );
+ });
});
it('sets Create Workspace button as loading', async () => {
@@ -346,9 +344,7 @@ describe('remote_development/pages/create.vue', () => {
customMutationResponse.data.workspaceCreate.errors.push(error);
workspaceCreateMutationHandler.mockReset();
- workspaceCreateMutationHandler.mockResolvedValueOnce(
- customMutationResponse.data.workspaceCreate,
- );
+ workspaceCreateMutationHandler.mockResolvedValueOnce(customMutationResponse);
await submitCreateWorkspaceForm();
await waitForPromises();
diff --git a/ee/spec/graphql/api/workspace_spec.rb b/ee/spec/graphql/api/workspace_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..8901ce50a95c4cd738465f3881fbf8f31bcc04b8
--- /dev/null
+++ b/ee/spec/graphql/api/workspace_spec.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'workspaces', feature_category: :remote_development do
+ let_it_be(:group) { create(:group, :private) }
+ let(:result) { execute['data'] }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:workspace) { create(:workspace, user: user, updated_at: 2.days.ago) }
+
+ let(:query) do
+ %(
+ query {
+ workspace(id: "gid://gitlab/RemoteDevelopment::Workspace/#{workspace.id}") {
+ id
+ name
+ }
+ }
+ )
+ end
+
+ subject(:execute) { GitlabSchema.execute(query, context: { current_user: current_user }).as_json }
+
+ before do
+ stub_licensed_features(remote_development: true)
+ end
+
+ describe 'workspace' do
+ let(:current_user) { user }
+
+ it 'finds the workspace' do
+ expect(result["workspace"]["name"]).to eq(workspace.name)
+ end
+
+ context 'when not authorized' do
+ let(:current_user) { create :user }
+
+ it 'does not find the workspace' do
+ expect(result["workspace"]).to be_nil
+ end
+ end
+ end
+end
diff --git a/ee/spec/graphql/types/query_type_spec.rb b/ee/spec/graphql/types/query_type_spec.rb
index c9f18cb33e5890992d8eb88d2a1f195112c25006..5e0d128490cbf63949452f7590a154c308396f65 100644
--- a/ee/spec/graphql/types/query_type_spec.rb
+++ b/ee/spec/graphql/types/query_type_spec.rb
@@ -20,6 +20,8 @@
:vulnerabilities,
:vulnerabilities_count_by_day,
:vulnerability,
+ :workspace,
+ :workspaces,
:instance_external_audit_event_destinations
]
diff --git a/ee/spec/graphql/types/remote_development/workspace_type_spec.rb b/ee/spec/graphql/types/remote_development/workspace_type_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..4e1ff9f775be4c0d09c6ee433ba9e51f5ebb9c8d
--- /dev/null
+++ b/ee/spec/graphql/types/remote_development/workspace_type_spec.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['Workspace'], feature_category: :remote_development do
+ let(:fields) do
+ %i[
+ id cluster_agent project_id user name namespace max_hours_before_termination
+ desired_state desired_state_updated_at actual_state responded_to_agent_at
+ url editor devfile_ref devfile_path devfile processed_devfile deployment_resource_version created_at updated_at
+ ]
+ end
+
+ specify { expect(described_class.graphql_name).to eq('Workspace') }
+
+ specify { expect(described_class).to have_graphql_fields(fields) }
+
+ specify { expect(described_class).to require_graphql_authorizations(:read_workspace) }
+end
diff --git a/ee/spec/graphql/types/user_type_spec.rb b/ee/spec/graphql/types/user_type_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..13945fce12dfe6c66428e4260873758ba1964b69
--- /dev/null
+++ b/ee/spec/graphql/types/user_type_spec.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['User'], feature_category: :user_profile do
+ it 'has the expected fields' do
+ expected_fields = %w[
+ workspaces
+ ]
+
+ expect(described_class).to include_graphql_fields(*expected_fields)
+ end
+end
diff --git a/ee/spec/lib/remote_development/agent_config/update_processor_spec.rb b/ee/spec/lib/remote_development/agent_config/update_processor_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..f5ad206b3f9401b43bed34ec3b3ccbac9f03a2e7
--- /dev/null
+++ b/ee/spec/lib/remote_development/agent_config/update_processor_spec.rb
@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ::RemoteDevelopment::AgentConfig::UpdateProcessor, feature_category: :remote_development do
+ let(:enabled) { true }
+ let(:dns_zone) { 'my-awesome-domain.me' }
+ let_it_be(:agent) { create(:cluster_agent) }
+
+ let(:config) do
+ {
+ remote_development: {
+ enabled: enabled,
+ dns_zone: dns_zone
+ }
+ }
+ end
+
+ describe '#process' do
+ subject(:results) do
+ described_class.new.process(agent: agent, config: config)
+ end
+
+ context 'when config passed is empty' do
+ let(:config) { {} }
+
+ it { is_expected.to match_array([nil, nil]) }
+
+ it 'does not create a config record' do
+ expect { subject }.to not_change { RemoteDevelopment::RemoteDevelopmentAgentConfig.count }
+ expect(subject[0]).to be_nil
+ expect(subject[1]).to be_nil
+ end
+ end
+
+ context 'when config passed is not empty' do
+ it do
+ is_expected.to eq(
+ [
+ { remote_development_agent_config: agent.reload.remote_development_agent_config },
+ nil
+ ]
+ )
+ end
+
+ it 'creates a config record' do
+ subject
+
+ config_instance = agent.reload.remote_development_agent_config
+ expect(config_instance.enabled).to eq(enabled)
+ expect(config_instance.dns_zone).to eq(dns_zone)
+ end
+ end
+
+ context 'when config record cannot be created' do
+ context 'when enabled is not valid' do
+ let(:enabled) { false }
+
+ it 'does not create the record and returns error' do
+ result = subject
+
+ expect(result[0]).to be_nil
+ expect(result[1].message).to match(/Error\(s\) updating RemoteDevelopmentAgentConfig.*is currently immutable/)
+ expect(result[1].reason).to eq(:bad_request)
+
+ config_instance = agent.reload.remote_development_agent_config
+ expect(config_instance).to be_nil
+ end
+ end
+ end
+ end
+end
diff --git a/ee/spec/lib/remote_development/workspaces/create/create_processor_spec.rb b/ee/spec/lib/remote_development/workspaces/create/create_processor_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..31f064d8cedb269d08d87f786103991295ea82c9
--- /dev/null
+++ b/ee/spec/lib/remote_development/workspaces/create/create_processor_spec.rb
@@ -0,0 +1,124 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ::RemoteDevelopment::Workspaces::Create::CreateProcessor, :freeze_time, feature_category: :remote_development do
+ include_context 'with remote development shared fixtures'
+
+ describe '#process' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project, :public, :in_group, :repository) }
+ let_it_be(:agent) { create(:ee_cluster_agent, :with_remote_development_agent_config) }
+ let(:random_string) { 'abcdef' }
+ let(:devfile_path) { '.devfile.yaml' }
+ let(:fake_devfile_name) { 'example.devfile.yaml' }
+ let(:fake_devfile) { read_devfile(fake_devfile_name) }
+ let(:editor) { 'webide' }
+ let(:workspace_root) { '/projects' }
+ let(:params) do
+ {
+ agent: agent,
+ user: user,
+ project: project,
+ editor: editor,
+ max_hours_before_termination: 24,
+ desired_state: RemoteDevelopment::Workspaces::States::RUNNING,
+ devfile_ref: 'main',
+ devfile_path: devfile_path
+ }
+ end
+
+ subject do
+ described_class.new
+ end
+
+ context 'when params are valid' do
+ context 'when devfile is valid' do
+ before do
+ allow(SecureRandom).to receive(:alphanumeric) { random_string }
+ allow(project.repository).to receive_message_chain(:blob_at_branch, :data) { fake_devfile }
+ allow_next_instance_of(RemoteDevelopment::Workspaces::Create::DevfileProcessor) do |processor|
+ allow(processor)
+ .to receive(:process).with(
+ devfile: fake_devfile,
+ editor: editor,
+ project: project,
+ workspace_root: workspace_root
+ ).and_return(example_processed_devfile)
+ end
+ end
+
+ it 'creates a new workspace' do
+ payload, error = subject.process(params: params)
+ expect(error).to be_nil
+
+ workspace = payload.fetch(:workspace)
+ expect(workspace).not_to be_nil
+ expect(workspace.user).to eq(user)
+ expect(workspace.agent).to eq(agent)
+ expect(workspace.desired_state).to eq(RemoteDevelopment::Workspaces::States::RUNNING)
+ # noinspection RubyResolve
+ expect(workspace.desired_state_updated_at).to eq(Time.current)
+ expect(workspace.actual_state).to eq(RemoteDevelopment::Workspaces::States::CREATION_REQUESTED)
+ expect(workspace.name).to eq("workspace-#{agent.id}-#{user.id}-#{random_string}")
+ expect(workspace.namespace).to eq("gl-rd-ns-#{agent.id}-#{user.id}-#{random_string}")
+ expect(workspace.editor).to eq('webide')
+ expect(workspace.url).to eq(URI::HTTPS.build({
+ host: "60001-#{workspace.name}.#{workspace.dns_zone}",
+ query: {
+ folder: "#{workspace_root}/#{project.path}"
+ }.to_query
+ }).to_s)
+ # noinspection RubyResolve
+ expect(workspace.devfile).to eq(fake_devfile)
+ end
+ end
+
+ context 'when devfile is not valid' do
+ let(:fake_devfile_name) { 'example.invalid-no-components-devfile.yaml' }
+
+ before do
+ allow(project.repository).to receive_message_chain(:blob_at_branch, :data) { fake_devfile }
+ end
+
+ it 'returns an error' do
+ payload, error = subject.process(params: params)
+ expect(payload).to be_nil
+ expect(error.message).to eq('Invalid devfile: No components present in the devfile')
+ expect(error.reason).to eq(:bad_request)
+ end
+ end
+ end
+
+ context 'when params are invalid' do
+ context 'when devfile is not found' do
+ let(:devfile_path) { 'not-found.yaml' }
+
+ before do
+ allow(project.repository).to receive(:blob_at_branch).and_return(nil)
+ end
+
+ it 'returns an error' do
+ payload, error = subject.process(params: params)
+ expect(payload).to be_nil
+ expect(error.message).to eq('Devfile not found in project')
+ expect(error.reason).to eq(:bad_request)
+ end
+ end
+
+ context 'when agent has no associated config' do
+ let_it_be(:agent) { create(:cluster_agent) }
+
+ it 'returns an error' do
+ # sanity check on fixture
+ expect(agent.remote_development_agent_config).to be_nil
+
+ payload, error = subject.process(params: params)
+ expect(payload).to be_nil
+ expect(error.message).to eq("No RemoteDevelopmentAgentConfig found for agent '#{agent.name}'")
+ expect(error.reason).to eq(:bad_request)
+ end
+ end
+ end
+ end
+end
diff --git a/ee/spec/lib/remote_development/workspaces/create/devfile_processor_spec.rb b/ee/spec/lib/remote_development/workspaces/create/devfile_processor_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..cb17cf8aff410284ad144c4f21ddc89116b9da46
--- /dev/null
+++ b/ee/spec/lib/remote_development/workspaces/create/devfile_processor_spec.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe RemoteDevelopment::Workspaces::Create::DevfileProcessor, feature_category: :remote_development do
+ include_context 'with remote development shared fixtures'
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:group) { create(:group, name: "test-group") }
+ let_it_be(:agent) { create(:ee_cluster_agent, :with_remote_development_agent_config) }
+ let_it_be(:workspace) { create(:workspace, agent: agent, user: user) }
+ let_it_be(:project) { create(:project, :public, :in_group, :repository, path: "test-project", namespace: group) }
+
+ let(:owning_inventory) { "#{workspace.name}-workspace-inventory" }
+ let(:workspace_root) { "/projects" }
+
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/409781
+ # Add more test coverage for conditionals, using different example processed devfile fixtures.
+ describe '#process' do
+ let(:expected_devfile) { YAML.safe_load(example_processed_devfile) }
+
+ subject do
+ described_class.new
+ end
+
+ it 'returns expected processed devfile yaml' do
+ # noinspection RubyResolve
+ processed_devfile_yaml = subject.process(
+ devfile: workspace.devfile,
+ editor: workspace.editor,
+ project: project,
+ workspace_root: workspace_root
+ )
+ processed_devfile = YAML.safe_load(processed_devfile_yaml)
+
+ # Perform individual expectations to make it easier to debug failures
+ expect(processed_devfile.fetch('components')).to eq(expected_devfile.fetch('components'))
+ expect(processed_devfile).to eq(expected_devfile)
+ end
+ end
+end
diff --git a/ee/spec/lib/remote_development/workspaces/create/devfile_validator_spec.rb b/ee/spec/lib/remote_development/workspaces/create/devfile_validator_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..fe83b4d8488aa6d07fd5e17bea278d2c8fe198ab
--- /dev/null
+++ b/ee/spec/lib/remote_development/workspaces/create/devfile_validator_spec.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe RemoteDevelopment::Workspaces::Create::DevfileValidator, feature_category: :remote_development do
+ include_context 'with remote development shared fixtures'
+
+ let(:devfile_name) { 'example.devfile.yaml' }
+ let(:devfile) { YAML.safe_load(read_devfile(devfile_name)) }
+
+ describe '#validate' do
+ subject do
+ described_class.new
+ end
+
+ context 'for devfiles containing no violations' do
+ # noinspection RubyResolve
+ it 'does not raises an error' do
+ expect { subject.validate(flattened_devfile: devfile) }.not_to raise_error
+ end
+ end
+
+ context 'for devfiles containing violations' do
+ using RSpec::Parameterized::TableSyntax
+
+ # rubocop:disable Layout/LineLength
+ where(:devfile_name, :error_str) do
+ 'example.invalid-restricted-prefix-command-apply-component-name-devfile.yaml' | "Component name 'gl-example' for command id 'example' starts with 'gl-'"
+ 'example.invalid-restricted-prefix-command-exec-component-name-devfile.yaml' | "Component name 'gl-example' for command id 'example' starts with 'gl-'"
+ 'example.invalid-restricted-prefix-command-name-devfile.yaml' | "Command id 'gl-example' starts with 'gl-'"
+ 'example.invalid-restricted-prefix-component-container-endpoint-name-devfile.yaml' | "Endpoint name 'gl-example' of component 'example' starts with 'gl-'"
+ 'example.invalid-restricted-prefix-component-name-devfile.yaml' | "Component name 'gl-example' starts with 'gl-'"
+ 'example.invalid-restricted-prefix-event-type-poststart-name-devfile.yaml' | "Event 'gl-example' of type 'postStart' starts with 'gl-'"
+ 'example.invalid-restricted-prefix-event-type-prestart-name-devfile.yaml' | "Event 'gl-example' of type 'preStart' starts with 'gl-'"
+ 'example.invalid-restricted-prefix-event-type-prestop-name-devfile.yaml' | "Event 'gl-example' of type 'preStop' starts with 'gl-'"
+ 'example.invalid-restricted-prefix-variable-name-devfile.yaml' | "Variable name 'gl-example' starts with 'gl-'"
+ 'example.invalid-restricted-prefix-variable-name-with-underscore-devfile.yaml' | "Variable name 'gl_example' starts with 'gl_'"
+ 'example.invalid-unsupported-component-type-image-devfile.yaml' | "Component type 'image' is not yet supported"
+ 'example.invalid-unsupported-component-type-kubernetes-devfile.yaml' | "Component type 'kubernetes' is not yet supported"
+ 'example.invalid-unsupported-component-type-openshift-devfile.yaml' | "Component type 'openshift' is not yet supported"
+ 'example.invalid-no-components-devfile.yaml' | "No components present in the devfile"
+ 'example.invalid-attributes-editor-injector-absent-devfile.yaml' | "No component has 'gl/inject-editor' attribute"
+ 'example.invalid-attributes-editor-injector-multiple-devfile.yaml' | "Multiple components([\"tooling-container\", \"tooling-container-2\"]) have 'gl/inject-editor' attribute"
+ 'example.invalid-unsupported-parent-inheritance-devfile.yaml' | "Inheriting from parent is not yet supported"
+ end
+ # rubocop:enable Layout/LineLength
+ with_them do
+ # noinspection RubyResolve
+ it 'raises an error' do
+ expect { subject.validate(flattened_devfile: devfile) }.to raise_error(ArgumentError, error_str)
+ end
+ end
+ end
+ end
+end
diff --git a/ee/spec/lib/remote_development/workspaces/reconcile/actual_state_calculator_spec.rb b/ee/spec/lib/remote_development/workspaces/reconcile/actual_state_calculator_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..ad3647ce7cb174b4d7d5090209dbfd755e925ade
--- /dev/null
+++ b/ee/spec/lib/remote_development/workspaces/reconcile/actual_state_calculator_spec.rb
@@ -0,0 +1,481 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe RemoteDevelopment::Workspaces::Reconcile::ActualStateCalculator, feature_category: :remote_development do
+ include_context 'with remote development shared fixtures'
+
+ describe '.calculate_actual_state' do
+ subject do
+ described_class.new
+ end
+
+ context 'with cases parameterized from shared fixtures' do
+ where(:previous_actual_state, :current_actual_state, :workspace_exists) do
+ [
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/409783
+ # These are currently taken from only the currently supported cases in
+ # remote_development_shared_contexts.rb#create_workspace_agent_info,
+ # but we should ensure they are providing full and
+ # realistic coverage of all possible relevant states.
+ # Note that `nil` is passed when the argument will not be used by
+ # remote_development_shared_contexts.rb
+ [RemoteDevelopment::Workspaces::States::CREATION_REQUESTED, RemoteDevelopment::Workspaces::States::STARTING,
+ nil],
+ [RemoteDevelopment::Workspaces::States::STARTING, RemoteDevelopment::Workspaces::States::STARTING, false],
+ [RemoteDevelopment::Workspaces::States::STARTING, RemoteDevelopment::Workspaces::States::RUNNING, false],
+ [RemoteDevelopment::Workspaces::States::STARTING, RemoteDevelopment::Workspaces::States::FAILED, false],
+ [RemoteDevelopment::Workspaces::States::FAILED, RemoteDevelopment::Workspaces::States::STARTING, false],
+ [RemoteDevelopment::Workspaces::States::RUNNING, RemoteDevelopment::Workspaces::States::FAILED, nil],
+ [RemoteDevelopment::Workspaces::States::RUNNING, RemoteDevelopment::Workspaces::States::STOPPING, nil],
+ [RemoteDevelopment::Workspaces::States::STOPPING, RemoteDevelopment::Workspaces::States::STOPPED, nil],
+ [RemoteDevelopment::Workspaces::States::STOPPING, RemoteDevelopment::Workspaces::States::FAILED, nil],
+ [RemoteDevelopment::Workspaces::States::STOPPED, RemoteDevelopment::Workspaces::States::STARTING, nil],
+ [RemoteDevelopment::Workspaces::States::STOPPED, RemoteDevelopment::Workspaces::States::FAILED, nil],
+ [RemoteDevelopment::Workspaces::States::STARTING, RemoteDevelopment::Workspaces::States::STARTING, true],
+ [RemoteDevelopment::Workspaces::States::STARTING, RemoteDevelopment::Workspaces::States::RUNNING, true],
+ [RemoteDevelopment::Workspaces::States::STARTING, RemoteDevelopment::Workspaces::States::FAILED, true],
+ [RemoteDevelopment::Workspaces::States::FAILED, RemoteDevelopment::Workspaces::States::STARTING, true],
+ [RemoteDevelopment::Workspaces::States::FAILED, RemoteDevelopment::Workspaces::States::STOPPING, nil],
+ [nil, RemoteDevelopment::Workspaces::States::FAILED, nil]
+ ]
+ end
+
+ with_them do
+ let(:latest_k8s_deployment_info) do
+ workspace_agent_info = create_workspace_agent_info(
+ workspace_id: 1,
+ workspace_name: 'name',
+ workspace_namespace: 'namespace',
+ agent_id: 1,
+ owning_inventory: 'owning_inventory',
+ resource_version: 1,
+ previous_actual_state: previous_actual_state,
+ current_actual_state: current_actual_state,
+ workspace_exists: workspace_exists
+ )
+ workspace_agent_info.fetch('latest_k8s_deployment_info')
+ end
+
+ it 'calculates correct actual state' do
+ calculated_actual_state = nil
+ begin
+ calculated_actual_state = subject.calculate_actual_state(
+ latest_k8s_deployment_info: latest_k8s_deployment_info
+ )
+ rescue RemoteDevelopment::AgentInfoStatusFixtureNotImplementedError
+ skip 'TODO: Properly implement the agent info status fixture for ' \
+ "previous_actual_state: #{previous_actual_state}, " \
+ "current_actual_state: #{current_actual_state}, " \
+ "workspace_exists: #{workspace_exists}"
+ end
+ expect(calculated_actual_state).to be(current_actual_state) if calculated_actual_state
+ end
+ end
+ end
+
+ # NOTE: The remaining examples below in this file existed before we added the RSpec parameterized
+ # section above with tests based on create_workspace_agent_info. Some of them may be
+ # redundant now.
+
+ context 'when the deployment is completed successfully' do
+ context 'when new workspace has been created or existing workspace has been scaled up' do
+ let(:expected_actual_state) { RemoteDevelopment::Workspaces::States::RUNNING }
+ let(:latest_k8s_deployment_info) do
+ YAML.safe_load(
+ <<~WORKSPACE_STATUS_YAML
+ spec:
+ replicas: 1
+ status:
+ availableReplicas: 1
+ conditions:
+ - reason: MinimumReplicasAvailable
+ type: Available
+ - reason: NewReplicaSetAvailable
+ type: Progressing
+ WORKSPACE_STATUS_YAML
+ )
+ end
+
+ it 'returns the expected actual state' do
+ expect(subject.calculate_actual_state(latest_k8s_deployment_info: latest_k8s_deployment_info))
+ .to be(expected_actual_state)
+ end
+ end
+
+ context 'when existing workspace has been scaled down' do
+ let(:expected_actual_state) { RemoteDevelopment::Workspaces::States::STOPPED }
+ let(:latest_k8s_deployment_info) do
+ YAML.safe_load(
+ <<~WORKSPACE_STATUS_YAML
+ spec:
+ replicas: 0
+ status:
+ conditions:
+ - reason: MinimumReplicasAvailable
+ type: Available
+ - reason: NewReplicaSetAvailable
+ type: Progressing
+ WORKSPACE_STATUS_YAML
+ )
+ end
+
+ it 'returns the expected actual state' do
+ expect(subject.calculate_actual_state(latest_k8s_deployment_info: latest_k8s_deployment_info))
+ .to be(expected_actual_state)
+ end
+ end
+
+ context 'when status does not contain required information' do
+ let(:expected_actual_state) { RemoteDevelopment::Workspaces::States::UNKNOWN }
+ let(:latest_k8s_deployment_info) do
+ YAML.safe_load(
+ <<~WORKSPACE_STATUS_YAML
+ spec:
+ test: 0
+ status:
+ conditions:
+ - reason: MinimumReplicasAvailable
+ type: Available
+ - reason: NewReplicaSetAvailable
+ type: Progressing
+ WORKSPACE_STATUS_YAML
+ )
+ end
+
+ it 'returns the expected actual state' do
+ expect(subject.calculate_actual_state(latest_k8s_deployment_info: latest_k8s_deployment_info))
+ .to be(expected_actual_state)
+ end
+ end
+ end
+
+ context 'when the deployment is in progress' do
+ context 'when new workspace has been created' do
+ let(:expected_actual_state) { RemoteDevelopment::Workspaces::States::STARTING }
+ let(:latest_k8s_deployment_info) do
+ YAML.safe_load(
+ <<~WORKSPACE_STATUS_YAML
+ spec:
+ replicas: 1
+ status:
+ conditions:
+ - reason: NewReplicaSetCreated
+ type: Progressing
+ WORKSPACE_STATUS_YAML
+ )
+ end
+
+ it 'returns the expected actual state' do
+ expect(subject.calculate_actual_state(latest_k8s_deployment_info: latest_k8s_deployment_info))
+ .to be(expected_actual_state)
+ end
+ end
+
+ context 'when existing workspace has been updated' do
+ let(:expected_actual_state) { RemoteDevelopment::Workspaces::States::STARTING }
+ let(:latest_k8s_deployment_info) do
+ YAML.safe_load(
+ <<~WORKSPACE_STATUS_YAML
+ spec:
+ replicas: 1
+ status:
+ conditions:
+ - reason: FoundNewReplicaSet
+ type: Progressing
+ WORKSPACE_STATUS_YAML
+ )
+ end
+
+ it 'returns the expected actual state' do
+ expect(subject.calculate_actual_state(latest_k8s_deployment_info: latest_k8s_deployment_info))
+ .to be(expected_actual_state)
+ end
+ end
+
+ context 'when existing workspace has been scaled up' do
+ let(:expected_actual_state) { RemoteDevelopment::Workspaces::States::STARTING }
+ let(:latest_k8s_deployment_info) do
+ YAML.safe_load(
+ <<~WORKSPACE_STATUS_YAML
+ spec:
+ replicas: 1
+ status:
+ conditions:
+ - reason: ReplicaSetUpdated
+ type: Progressing
+ WORKSPACE_STATUS_YAML
+ )
+ end
+
+ it 'returns the expected actual state' do
+ expect(subject.calculate_actual_state(latest_k8s_deployment_info: latest_k8s_deployment_info))
+ .to be(expected_actual_state)
+ end
+ end
+
+ context 'when existing workspace has been scaled down' do
+ let(:expected_actual_state) { RemoteDevelopment::Workspaces::States::STOPPING }
+ let(:latest_k8s_deployment_info) do
+ YAML.safe_load(
+ <<~WORKSPACE_STATUS_YAML
+ spec:
+ replicas: 0
+ status:
+ conditions:
+ - reason: ReplicaSetUpdated
+ type: Progressing
+ WORKSPACE_STATUS_YAML
+ )
+ end
+
+ it 'returns the expected actual state' do
+ expect(subject.calculate_actual_state(latest_k8s_deployment_info: latest_k8s_deployment_info))
+ .to be(expected_actual_state)
+ end
+ end
+
+ context 'when spec replicas is more than 1' do
+ let(:expected_actual_state) { RemoteDevelopment::Workspaces::States::UNKNOWN }
+ let(:latest_k8s_deployment_info) do
+ YAML.safe_load(
+ <<~WORKSPACE_STATUS_YAML
+ spec:
+ replicas: 2
+ status:
+ conditions:
+ - reason: ReplicaSetUpdated
+ type: Progressing
+ WORKSPACE_STATUS_YAML
+ )
+ end
+
+ it 'returns the expected actual state' do
+ expect(subject.calculate_actual_state(latest_k8s_deployment_info: latest_k8s_deployment_info))
+ .to be(expected_actual_state)
+ end
+ end
+
+ context 'when status does not contain required information' do
+ let(:expected_actual_state) { RemoteDevelopment::Workspaces::States::UNKNOWN }
+ let(:latest_k8s_deployment_info) do
+ YAML.safe_load(
+ <<~WORKSPACE_STATUS_YAML
+ spec:
+ replicas: 1
+ status:
+ conditions:
+ - reason: test
+ type: test
+ WORKSPACE_STATUS_YAML
+ )
+ end
+
+ it 'returns the expected actual state' do
+ expect(subject.calculate_actual_state(latest_k8s_deployment_info: latest_k8s_deployment_info))
+ .to be(expected_actual_state)
+ end
+ end
+ end
+
+ context 'when the deployment is failed' do
+ context 'when new workspace has been created or existing workspace has been scaled up' do
+ let(:expected_actual_state) { RemoteDevelopment::Workspaces::States::FAILED }
+ let(:latest_k8s_deployment_info) do
+ YAML.safe_load(
+ <<~WORKSPACE_STATUS_YAML
+ spec:
+ replicas: 1
+ status:
+ conditions:
+ - reason: MinimumReplicasUnavailable
+ type: Available
+ - reason: ProgressDeadlineExceeded
+ type: Progressing
+ unavailableReplicas: 1
+ WORKSPACE_STATUS_YAML
+ )
+ end
+
+ it 'returns the expected actual state' do
+ expect(subject.calculate_actual_state(latest_k8s_deployment_info: latest_k8s_deployment_info))
+ .to be(expected_actual_state)
+ end
+ end
+
+ context 'when existing scaled down workspace which was failing has been scaled up' do
+ let(:expected_actual_state) { RemoteDevelopment::Workspaces::States::FAILED }
+ let(:latest_k8s_deployment_info) do
+ YAML.safe_load(
+ <<~WORKSPACE_STATUS_YAML
+ spec:
+ replicas: 1
+ status:
+ conditions:
+ - reason: MinimumReplicasUnavailable
+ type: Available
+ - reason: NewReplicaSetAvailable
+ type: Progressing
+ unavailableReplicas: 1
+ WORKSPACE_STATUS_YAML
+ )
+ end
+
+ it 'returns the expected actual state' do
+ pending "This currently returns STARTING state. See related TODOs in the relevant code."
+ expect(subject.calculate_actual_state(latest_k8s_deployment_info: latest_k8s_deployment_info))
+ .to be(expected_actual_state)
+ end
+ end
+ end
+
+ context 'when the deployment status is unknown' do
+ let(:expected_actual_state) { RemoteDevelopment::Workspaces::States::UNKNOWN }
+
+ context 'when spec is missing' do
+ let(:latest_k8s_deployment_info) do
+ YAML.safe_load(
+ <<~WORKSPACE_STATUS_YAML
+ test:
+ replicas: 0
+ status:
+ conditions:
+ - reason: ReplicaSetUpdated
+ type: Progressing
+ WORKSPACE_STATUS_YAML
+ )
+ end
+
+ it 'returns the expected actual state' do
+ expect(subject.calculate_actual_state(latest_k8s_deployment_info: latest_k8s_deployment_info))
+ .to be(expected_actual_state)
+ end
+ end
+
+ context 'when spec replicas is missing' do
+ let(:latest_k8s_deployment_info) do
+ YAML.safe_load(
+ <<~WORKSPACE_STATUS_YAML
+ spec:
+ test: 0
+ status:
+ conditions:
+ - reason: ReplicaSetUpdated
+ type: Progressing
+ WORKSPACE_STATUS_YAML
+ )
+ end
+
+ it 'returns the expected actual state' do
+ expect(subject.calculate_actual_state(latest_k8s_deployment_info: latest_k8s_deployment_info))
+ .to be(expected_actual_state)
+ end
+ end
+
+ context 'when status is missing' do
+ let(:latest_k8s_deployment_info) do
+ YAML.safe_load(
+ <<~WORKSPACE_STATUS_YAML
+ spec:
+ replicas: 0
+ WORKSPACE_STATUS_YAML
+ )
+ end
+
+ it 'returns the expected actual state' do
+ expect(subject.calculate_actual_state(latest_k8s_deployment_info: latest_k8s_deployment_info))
+ .to be(expected_actual_state)
+ end
+ end
+
+ context 'when status conditions is missing' do
+ let(:latest_k8s_deployment_info) do
+ YAML.safe_load(
+ <<~WORKSPACE_STATUS_YAML
+ spec:
+ replicas: 0
+ status:
+ test:
+ - reason: ReplicaSetUpdated
+ type: Progressing
+ WORKSPACE_STATUS_YAML
+ )
+ end
+
+ it 'returns the expected actual state' do
+ expect(subject.calculate_actual_state(latest_k8s_deployment_info: latest_k8s_deployment_info))
+ .to be(expected_actual_state)
+ end
+ end
+
+ context 'when status conditions reason is missing' do
+ let(:latest_k8s_deployment_info) do
+ YAML.safe_load(
+ <<~WORKSPACE_STATUS_YAML
+ spec:
+ replicas: 0
+ status:
+ conditions:
+ - type: Progressing
+ WORKSPACE_STATUS_YAML
+ )
+ end
+
+ it 'returns the expected actual state' do
+ expect(subject.calculate_actual_state(latest_k8s_deployment_info: latest_k8s_deployment_info))
+ .to be(expected_actual_state)
+ end
+ end
+
+ context 'when status progressing and available conditions are unrecognized' do
+ let(:latest_k8s_deployment_info) do
+ YAML.safe_load(
+ <<~WORKSPACE_STATUS_YAML
+ spec:
+ replicas: 0
+ status:
+ conditions:
+ - reason: unrecognized
+ type: Available
+ - reason: unrecognized
+ type: Progressing
+ WORKSPACE_STATUS_YAML
+ )
+ end
+
+ it 'returns the expected actual state' do
+ expect(subject.calculate_actual_state(latest_k8s_deployment_info: latest_k8s_deployment_info))
+ .to be(expected_actual_state)
+ end
+ end
+ end
+
+ context 'when termination_progress is Terminating' do
+ let(:expected_actual_state) { RemoteDevelopment::Workspaces::States::TERMINATING }
+ let(:termination_progress) { RemoteDevelopment::Workspaces::Reconcile::ActualStateCalculator::TERMINATING }
+
+ it 'returns the expected actual state' do
+ expect(
+ subject.calculate_actual_state(
+ latest_k8s_deployment_info: nil,
+ termination_progress: termination_progress
+ )
+ ).to be(expected_actual_state)
+ end
+ end
+
+ context 'when termination_progress is Terminated' do
+ let(:expected_actual_state) { RemoteDevelopment::Workspaces::States::TERMINATED }
+ let(:termination_progress) { RemoteDevelopment::Workspaces::Reconcile::ActualStateCalculator::TERMINATED }
+
+ it 'returns the expected actual state' do
+ expect(
+ subject.calculate_actual_state(
+ latest_k8s_deployment_info: nil,
+ termination_progress: termination_progress
+ )
+ ).to be(expected_actual_state)
+ end
+ end
+ end
+end
diff --git a/ee/spec/lib/remote_development/workspaces/reconcile/agent_info_parser_spec.rb b/ee/spec/lib/remote_development/workspaces/reconcile/agent_info_parser_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..b334559b499173bac05abf9bad73a96579775ce7
--- /dev/null
+++ b/ee/spec/lib/remote_development/workspaces/reconcile/agent_info_parser_spec.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe RemoteDevelopment::Workspaces::Reconcile::AgentInfoParser, feature_category: :remote_development do
+ include_context 'with remote development shared fixtures'
+
+ let(:workspace) { create(:workspace) }
+
+ let(:workspace_agent_info) do
+ create_workspace_agent_info(
+ workspace_id: workspace.id,
+ workspace_name: workspace.name,
+ workspace_namespace: workspace.namespace,
+ agent_id: workspace.agent.id,
+ owning_inventory: "#{workspace.name}-workspace-inventory",
+ resource_version: '1',
+ previous_actual_state: previous_actual_state,
+ current_actual_state: current_actual_state,
+ workspace_exists: false
+ )
+ end
+
+ let(:expected_namespace) { workspace.namespace }
+ let(:expected_deployment_resource_version) { '1' }
+
+ let(:expected_agent_info) do
+ ::RemoteDevelopment::Workspaces::Reconcile::AgentInfo.new(
+ name: workspace.name,
+ namespace: expected_namespace,
+ actual_state: current_actual_state,
+ deployment_resource_version: expected_deployment_resource_version
+ )
+ end
+
+ subject do
+ described_class.new.parse(workspace_agent_info: workspace_agent_info)
+ end
+
+ before do
+ allow_next_instance_of(::RemoteDevelopment::Workspaces::Reconcile::ActualStateCalculator) do |instance|
+ # rubocop:disable RSpec/ExpectInHook
+ expect(instance).to receive(:calculate_actual_state).with(
+ latest_k8s_deployment_info: workspace_agent_info['latest_k8s_deployment_info'],
+ termination_progress: termination_progress
+ ) { current_actual_state }
+ # rubocop:enable RSpec/ExpectInHook
+ end
+ end
+
+ describe '#parse' do
+ context 'when current actual state is not Terminated or Unknown' do
+ let(:previous_actual_state) { ::RemoteDevelopment::Workspaces::States::STARTING }
+ let(:current_actual_state) { ::RemoteDevelopment::Workspaces::States::RUNNING }
+ let(:termination_progress) { nil }
+
+ it 'returns an AgentInfo object with namespace and deployment_resource_version populated' do
+ expect(subject).to eq(expected_agent_info)
+ end
+ end
+
+ context 'when current actual state is Terminating' do
+ let(:previous_actual_state) { ::RemoteDevelopment::Workspaces::States::RUNNING }
+ let(:current_actual_state) { ::RemoteDevelopment::Workspaces::States::TERMINATING }
+ let(:termination_progress) { RemoteDevelopment::Workspaces::Reconcile::ActualStateCalculator::TERMINATING }
+ let(:expected_namespace) { nil }
+ let(:expected_deployment_resource_version) { nil }
+
+ it 'returns an AgentInfo object without namespace and deployment_resource_version populated' do
+ expect(subject).to eq(expected_agent_info)
+ end
+ end
+
+ context 'when current actual state is Terminated' do
+ let(:previous_actual_state) { ::RemoteDevelopment::Workspaces::States::TERMINATING }
+ let(:current_actual_state) { ::RemoteDevelopment::Workspaces::States::TERMINATED }
+ let(:termination_progress) { RemoteDevelopment::Workspaces::Reconcile::ActualStateCalculator::TERMINATED }
+ let(:expected_namespace) { nil }
+ let(:expected_deployment_resource_version) { nil }
+
+ it 'returns an AgentInfo object without namespace and deployment_resource_version populated' do
+ expect(subject).to eq(expected_agent_info)
+ end
+ end
+ end
+end
diff --git a/ee/spec/lib/remote_development/workspaces/reconcile/agent_info_spec.rb b/ee/spec/lib/remote_development/workspaces/reconcile/agent_info_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..e98fb9816d09a6f80958a28918dca5bf8833d6bb
--- /dev/null
+++ b/ee/spec/lib/remote_development/workspaces/reconcile/agent_info_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+
+RSpec.describe RemoteDevelopment::Workspaces::Reconcile::AgentInfo, feature_category: :remote_development do
+ let(:agent_info_constructor_args) do
+ {
+ name: 'name',
+ namespace: 'namespace',
+ actual_state: ::RemoteDevelopment::Workspaces::States::RUNNING,
+ deployment_resource_version: '1'
+ }
+ end
+
+ let(:other) { described_class.new(**agent_info_constructor_args) }
+
+ subject do
+ described_class.new(**agent_info_constructor_args)
+ end
+
+ describe '#==' do
+ context 'when objects are equal' do
+ it 'returns true' do
+ expect(subject).to eq(other)
+ end
+ end
+
+ context 'when objects are not equal' do
+ it 'returns false' do
+ other.instance_variable_set(:@name, 'other_name')
+ expect(subject).not_to eq(other)
+ end
+ end
+ end
+end
diff --git a/ee/spec/lib/remote_development/workspaces/reconcile/desired_config_generator_spec.rb b/ee/spec/lib/remote_development/workspaces/reconcile/desired_config_generator_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..eed9e8de52bbd1b5a547dbb24b9ff1d71cf32a69
--- /dev/null
+++ b/ee/spec/lib/remote_development/workspaces/reconcile/desired_config_generator_spec.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe RemoteDevelopment::Workspaces::Reconcile::DesiredConfigGenerator, :freeze_time, feature_category: :remote_development do
+ include_context 'with remote development shared fixtures'
+
+ describe '#generate_desired_config' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:agent) { create(:ee_cluster_agent, :with_remote_development_agent_config) }
+ let(:desired_state) { RemoteDevelopment::Workspaces::States::RUNNING }
+ let(:actual_state) { RemoteDevelopment::Workspaces::States::STOPPED }
+ let(:deployment_resource_version_from_agent) { workspace.deployment_resource_version }
+ let(:owning_inventory) { "#{workspace.name}-workspace-inventory" }
+
+ let(:workspace) do
+ create(
+ :workspace, agent: agent, user: user,
+ desired_state: desired_state, actual_state: actual_state
+ )
+ end
+
+ let(:expected_config) do
+ YAML.load_stream(
+ create_config_to_apply(
+ workspace_id: workspace.id,
+ workspace_name: workspace.name,
+ workspace_namespace: workspace.namespace,
+ agent_id: workspace.agent.id,
+ owning_inventory: owning_inventory,
+ started: started
+ )
+ )
+ end
+
+ subject do
+ described_class.new
+ end
+
+ context 'when desired_state results in started=true' do
+ let(:started) { true }
+
+ it 'returns expected config' do
+ workspace_resources = subject.generate_desired_config(workspace: workspace)
+
+ expect(workspace_resources).to eq(expected_config)
+ end
+ end
+
+ context 'when desired_state results in started=false' do
+ let(:desired_state) { RemoteDevelopment::Workspaces::States::STOPPED }
+ let(:started) { false }
+
+ it 'returns expected config' do
+ workspace_resources = subject.generate_desired_config(workspace: workspace)
+
+ expect(workspace_resources).to eq(expected_config)
+ end
+ end
+ end
+end
diff --git a/ee/spec/lib/remote_development/workspaces/reconcile/devfile_parser_spec.rb b/ee/spec/lib/remote_development/workspaces/reconcile/devfile_parser_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..0f7280206b226567a8df95d3877e7922f8c04f3c
--- /dev/null
+++ b/ee/spec/lib/remote_development/workspaces/reconcile/devfile_parser_spec.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe RemoteDevelopment::Workspaces::Reconcile::DevfileParser, feature_category: :remote_development do
+ include_context 'with remote development shared fixtures'
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:agent) { create(:ee_cluster_agent, :with_remote_development_agent_config) }
+ let_it_be(:workspace) { create(:workspace, agent: agent, user: user) }
+ let(:owning_inventory) { "#{workspace.name}-workspace-inventory" }
+
+ let(:domain_template) { "{{.port}}-#{workspace.name}.#{workspace.dns_zone}" }
+
+ describe '#get_all' do
+ let(:expected_workspace_resources) do
+ YAML.load_stream(
+ create_config_to_apply(
+ workspace_id: workspace.id,
+ workspace_name: workspace.name,
+ workspace_namespace: workspace.namespace,
+ agent_id: workspace.agent.id,
+ owning_inventory: owning_inventory,
+ started: true,
+ include_inventory: false
+ )
+ )
+ end
+
+ subject do
+ described_class.new
+ end
+
+ it 'returns workspace_resources' do
+ workspace_resources = subject.get_all(
+ processed_devfile: example_processed_devfile,
+ name: workspace.name,
+ namespace: workspace.namespace,
+ replicas: 1,
+ domain_template: domain_template,
+ labels: { 'agent.gitlab.com/id' => workspace.agent.id },
+ annotations: {
+ 'config.k8s.io/owning-inventory' => owning_inventory,
+ 'workspaces.gitlab.com/host-template' => domain_template,
+ 'workspaces.gitlab.com/id' => workspace.id
+ }
+ )
+
+ # noinspection RubyResolve
+ expect(workspace_resources).to eq(expected_workspace_resources)
+ end
+ end
+end
diff --git a/ee/spec/lib/remote_development/workspaces/reconcile/params_parser_spec.rb b/ee/spec/lib/remote_development/workspaces/reconcile/params_parser_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..746d22da182bb1aad5c8ad9c65f8b1af8cf0ca5a
--- /dev/null
+++ b/ee/spec/lib/remote_development/workspaces/reconcile/params_parser_spec.rb
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+
+RSpec.describe ::RemoteDevelopment::Workspaces::Reconcile::ParamsParser, :freeze_time, feature_category: :remote_development do
+ let(:update_type) { 'full' }
+ let(:workspace_agent_infos) { [] }
+ let(:params) do
+ {
+ 'update_type' => update_type,
+ 'workspace_agent_infos' => workspace_agent_infos
+ }
+ end
+
+ subject { described_class.new.parse(params: params) }
+
+ context 'when the params are valid' do
+ it 'returns the parsed params' do
+ expect(subject).to eq(
+ [
+ {
+ workspace_agent_infos: workspace_agent_infos,
+ update_type: update_type
+
+ },
+ nil
+ ]
+ )
+ end
+ end
+
+ context 'when the params are invalid' do
+ shared_examples 'returns nil params and an error' do
+ it 'returns nil params and an error' do
+ params, error = subject
+ expect(params).to be_nil
+ expect(error.message).to eq(expected_error_message)
+ expect(error.reason).to eq(expected_error_reason)
+ end
+ end
+
+ let(:expected_error_reason) { :unprocessable_entity }
+
+ context 'when workspace_agent_info is missing' do
+ let(:expected_error_message) { 'root is missing required keys: workspace_agent_infos' }
+ let(:params) do
+ {
+ 'update_type' => update_type
+ }
+ end
+
+ it_behaves_like 'returns nil params and an error'
+ end
+
+ context 'for update_type' do
+ context 'when missing' do
+ let(:expected_error_message) { 'root is missing required keys: update_type' }
+ let(:params) do
+ {
+ 'workspace_agent_infos' => workspace_agent_infos
+ }
+ end
+
+ it_behaves_like 'returns nil params and an error'
+ end
+
+ context 'when invalid' do
+ let(:expected_error_message) { %(property '/update_type' is not one of: ["partial", "full"]) }
+ let(:params) do
+ {
+ 'update_type' => 'invalid_update_type',
+ 'workspace_agent_infos' => workspace_agent_infos
+ }
+ end
+
+ it_behaves_like 'returns nil params and an error'
+ end
+ end
+ end
+end
diff --git a/ee/spec/lib/remote_development/workspaces/reconcile/reconcile_processor_scenarios_spec.rb b/ee/spec/lib/remote_development/workspaces/reconcile/reconcile_processor_scenarios_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..e2582e366725bc6a44419d46c4335a3455ef43e5
--- /dev/null
+++ b/ee/spec/lib/remote_development/workspaces/reconcile/reconcile_processor_scenarios_spec.rb
@@ -0,0 +1,240 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+# TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/409784 - This spec is dense and cryptic. Make it better.
+# TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/409784 - Several scenarios from
+# https://gitlab.com/gitlab-org/remote-development/gitlab-remote-development-docs/-/blob/main/doc/workspace-updates.md
+# are not yet implemented - most or all are related to ERROR or FAILURE states, because the fixtures are not yet
+# implemented.
+RSpec.describe ::RemoteDevelopment::Workspaces::Reconcile::ReconcileProcessor, 'Partial Update Scenarios', feature_category: :remote_development do
+ include_context 'with remote development shared fixtures'
+
+ # See following documentation for details on all scenarios:
+ #
+ # https://gitlab.com/gitlab-org/remote-development/gitlab-remote-development-docs/-/blob/main/doc/workspace-updates.md
+ #
+ # Columns:
+ #
+ # initial_db_state: Initial state of workspace in DB. nil for new workspace, 2-tuple of [desired_state, actual_state]
+ # for existing workspace.
+ #
+ # user_desired_state_update: Optional first request event. nil if there is no user action, symbol for state if there
+ # is a user action.
+ #
+ # agent_actual_state_updates: Array of actual state updates from agent. nil if agent reports no info for workspace,
+ # otherwise an array of [previous_actual_state, current_actual_state, workspace_exists]
+ # to be used as args when calling #create_workspace_agent_info to generate the workspace agent info fixture.
+ #
+ # response_expectations: Array corresponding to entries in agent_actual_state_updates, representing
+ # expected rails_info hash response to agent for the workspace. Array is a 2-tuple of booleans for
+ # [config_to_apply_present?, deployment_resource_version_present?].
+ #
+ # db_expectations: Array corresponding to entries in
+ # (initial_db_state + user_desired_state_update + agent_actual_state_updates).
+ # Array entry is nil, or 2-tuple of symbols for [desired_state, actual_state].
+
+ # rubocop:disable Layout/LineLength, Style/TrailingCommaInArrayLiteral - for ease of reading and editing
+ where(:initial_db_state, :user_desired_state_update, :agent_actual_state_updates, :response_expectations, :db_expectations) do
+ [
+ #
+ # desired: Running / actual: CreationRequested -> desired: Running / actual: Running
+ [nil, :running, [nil, [:creation_requested, :starting, false], [:starting, :running, true]], [[true, false], [false, true], [false, true]], [[:running, :creation_requested], [:running, :creation_requested], [:running, :starting], [:running, :running]]],
+ #
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/409784 - Fixture not yet implemented...
+ # desired: Running / actual: CreationRequested -> desired: Running / actual: Failed
+ # [nil, :running, [nil, [:creation_requested, :starting, false], [:starting, :failed, false]], [[true, false], [false, true], [false, true]], [[:running, :creation_requested], [:running, :creation_requested], [:running, :starting], [:running, :failed]]],
+ #
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/409784 - Fixture not yet implemented...
+ # desired: Running / actual: CreationRequested -> desired: Running / actual: Error
+ #
+ # desired: Running / actual: Running -> desired: Stopped / actual: Stopped
+ [[:running, :running], :stopped, [nil, [:running, :stopping, true], [:stopping, :stopped, false]], [[true, true], [false, true], [false, true]], [[:running, :running], [:stopped, :running], [:stopped, :running], [:stopped, :stopping], [:stopped, :stopped]]],
+ #
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/409784 - Fixture not yet implemented...
+ # desired: Running / actual: Running -> desired: Stopped / actual: Failed
+ #
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/409784 - Fixture not yet implemented...
+ # desired: Running / actual: Running -> desired: Stopped / actual: Error
+ #
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/409784 -
+ # This should be able to pass once https://gitlab.com/gitlab-org/gitlab/-/issues/406565 is fixed.
+ # We may need to update ee/spec/support/shared_contexts/remote_development/remote_development_shared_contexts.rb
+ # and/or ee/lib/remote_development/actual_state_calculator.rb to make this pass, but need to see how it
+ # behaves in reality after the above issue is fixed in order to make the fixtures reflect reality.
+ # desired: Running / actual: Running -> desired: Terminated / actual: Terminated
+ # [[:running, :running], :terminated, [nil, [:running, :terminated, false]], [[true, true], [false, true]], [[:running, :running], [:terminated, :running], [:terminated, :running], [:terminated, :terminated]]],
+ #
+ # desired: Stopped / actual: Stopped -> desired: Running / actual: Running
+ [[:stopped, :stopped], :running, [nil, [:stopped, :starting, false], [:starting, :running, true]], [[true, true], [false, true], [false, true]], [[:stopped, :stopped], [:running, :stopped], [:running, :stopped], [:running, :starting], [:running, :running]]],
+ #
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/409784 - Fixture not yet implemented...
+ # desired: Running / actual: Running -> desired: Terminated / actual: Failed
+ #
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/409784 - Fixture not yet implemented...
+ # desired: Running / actual: Running -> desired: Terminated / actual: Error
+ #
+ # desired: Stopped / actual: Stopped -> desired: Running / actual: Running
+ [[:stopped, :stopped], :running, [nil, [:stopped, :starting, false], [:starting, :running, true]], [[true, true], [false, true], [false, true]], [[:stopped, :stopped], [:running, :stopped], [:running, :stopped], [:running, :starting], [:running, :running]]],
+ #
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/409784 - Fixture not yet implemented...
+ # desired: Stopped / actual: Stopped -> desired: Running / actual: Failed
+ #
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/409784 - Fixture not yet implemented...
+ # desired: Stopped / actual: Stopped -> desired: Running / actual: Error
+ #
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/409784 - This should be able to pass once
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/406565 is fixed.
+ # We may need to update ee/spec/support/shared_contexts/remote_development/remote_development_shared_contexts.rb
+ # and/or ee/lib/remote_development/actual_state_calculator.rb to make this pass, but need to see how it
+ # behaves in reality after the above issue is fixed in order to make the fixtures reflect reality.
+ # desired: Stopped / actual: Stopped -> desired: Terminated / actual: Terminated
+ # [[:stopped, :stopped], :terminated, [nil, [:stopped, :terminated, false]], [[true, true], [false, true]], [[:stopped, :stopped], [:terminated, :stopped], [:terminated, :stopped], [:terminated, :terminated]]],
+ #
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/409784 - Fixture not yet implemented...
+ # desired: Stopped / actual: Stopped -> desired: Terminated / actual: Failed
+ #
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/409784 - Fixture not yet implemented...
+ # desired: Stopped / actual: Stopped -> desired: Terminated / actual: Error
+ #
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/409784 - Fixture not yet implemented...
+ # desired: Running / actual: Failed -> desired: Running / actual: Running
+ #
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/409784 - Fixture not yet implemented...
+ # desired: Running / actual: Failed -> desired: Stopped / actual: Stopped
+ #
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/409784 - Fixture not yet implemented...
+ # desired: Running / actual: Failed -> desired: Stopped / actual: Failed
+ #
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/409784 - Fixture not yet implemented...
+ # desired: Running / actual: Failed -> desired: Stopped / actual: Error
+ #
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/409784 - Fixture not yet implemented...
+ # desired: Running / actual: Failed -> desired: Terminated / actual: Terminated
+ #
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/409784 - Fixture not yet implemented...
+ # desired: Running / actual: Failed -> desired: Terminated / actual: Failed
+ #
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/409784 - Fixture not yet implemented...
+ # desired: Running / actual: Failed -> desired: Terminated / actual: Error
+ #
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/409784 - Fixture not yet implemented...
+ # desired: Running / actual: Error -> desired: Stopped / actual: Error
+ #
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/409784 - Fixture not yet implemented...
+ # desired: Running / actual: Error -> desired: Terminated / actual: Error
+ #
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/409784 - Fixture not yet implemented...
+ # Agent reports update for a workspace and user has also updated desired state of the workspace
+ #
+ # Restarting a workspace
+ [[:running, :running], :restart_requested, [nil, [:running, :stopping, true], [:stopping, :stopped, false], [:stopped, :starting, false], [:starting, :running, true]], [[true, true], [false, true], [true, true], [false, true], [false, true]], [[:running, :running], [:restart_requested, :running], [:restart_requested, :running], [:restart_requested, :stopping], [:running, :stopped], [:running, :starting], [:running, :running]]],
+ #
+ # No update for workspace from agentk or from user
+ #
+ ]
+ end
+ # rubocop:enable Layout/LineLength, Style/TrailingCommaInArrayLiteral
+
+ with_them do
+ it 'behaves as expected' do
+ # noinspection RubyResolve
+ expected_db_expectations_length =
+ (initial_db_state ? 1 : 0) + (user_desired_state_update ? 1 : 0) + agent_actual_state_updates.length
+ # noinspection RubyResolve
+ expect(db_expectations.length).to eq(expected_db_expectations_length)
+
+ workspace = nil
+ db_expectation_index = 0
+ initial_resource_version = '1'
+
+ # Handle initial db state, if necessary
+ # noinspection RubyResolve
+ if initial_db_state
+ workspace = create(
+ :workspace,
+ desired_state: initial_db_state[0].to_s.camelize,
+ actual_state: initial_db_state[1].to_s.camelize,
+ deployment_resource_version: initial_resource_version
+ )
+
+ # assert on the workspace state in the db after initial creation
+ expect(workspace.slice(:desired_state, :actual_state).values)
+ .to eq(db_expectations[db_expectation_index].map(&:to_s).map(&:camelize))
+ db_expectation_index += 1
+ end
+
+ # handle user desired state update, if necessary
+ # noinspection RubyResolve
+ if user_desired_state_update
+ if workspace
+ # noinspection RubyResolve
+ workspace.update!(desired_state: user_desired_state_update.to_s.camelize)
+ else
+ workspace = create(:workspace, :unprovisioned)
+ end
+
+ # assert on the workspace state in the db after user desired state update
+ expect(workspace.slice(:desired_state, :actual_state).values)
+ .to eq(db_expectations[db_expectation_index].map(&:to_s).map(&:camelize))
+ db_expectation_index += 1
+ end
+
+ raise 'Must have workspace by now, either from initial_db_state or user_desired_state_update' unless workspace
+
+ # Handle agent updates
+ # noinspection RubyResolve
+ agent_actual_state_updates.each_with_index do |actual_state_update_fixture_args, response_expectations_index|
+ update_type = RemoteDevelopment::Workspaces::Reconcile::UpdateType::PARTIAL
+ deployment_resource_version_from_agent ||= initial_resource_version
+
+ workspace_agent_infos =
+ if actual_state_update_fixture_args
+ previous_actual_state = actual_state_update_fixture_args[0].to_s.camelize
+ current_actual_state = actual_state_update_fixture_args[1].to_s.camelize
+ workspace_exists = actual_state_update_fixture_args[2]
+ deployment_resource_version_from_agent = (deployment_resource_version_from_agent.to_i + 1).to_s
+ [
+ create_workspace_agent_info(
+ workspace_id: workspace.id,
+ workspace_name: workspace.name,
+ workspace_namespace: workspace.namespace,
+ agent_id: workspace.agent.id,
+ owning_inventory: "#{workspace.name}-workspace-inventory",
+ resource_version: deployment_resource_version_from_agent,
+ current_actual_state: current_actual_state,
+ previous_actual_state: previous_actual_state,
+ workspace_exists: workspace_exists
+ )
+ ]
+ else
+ []
+ end
+
+ result = described_class.new.process(
+ agent: workspace.agent,
+ workspace_agent_infos: workspace_agent_infos,
+ update_type: update_type
+ )
+ workspace_rails_infos = result[0].fetch(:workspace_rails_infos)
+
+ # assert on the rails_info response to the agent
+ expect(workspace_rails_infos.size).to eq(1)
+ response_expectation = response_expectations[response_expectations_index]
+ # assert on the config_to_apply presence
+ expected_config_to_apply_present = response_expectation[0]
+ expect(workspace_rails_infos[0].fetch(:config_to_apply).present?).to eq(expected_config_to_apply_present)
+ # assert on the deployment_resource_version presence/value
+ expected_deployment_resource_version =
+ response_expectation[1] ? deployment_resource_version_from_agent : nil
+ deployment_resource_version = workspace_rails_infos[0].fetch(:deployment_resource_version)
+ expect(deployment_resource_version).to eq(expected_deployment_resource_version)
+
+ # assert on the workspace state in the db after processing the agent update
+ expect(workspace.reload.slice(:desired_state, :actual_state).values)
+ .to eq(db_expectations[db_expectation_index].map(&:to_s).map(&:camelize))
+ db_expectation_index += 1
+ end
+ end
+ end
+end
diff --git a/ee/spec/lib/remote_development/workspaces/reconcile/reconcile_processor_spec.rb b/ee/spec/lib/remote_development/workspaces/reconcile/reconcile_processor_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..3bc025852934f8cccc7fe6ccf12e889bb6e6df13
--- /dev/null
+++ b/ee/spec/lib/remote_development/workspaces/reconcile/reconcile_processor_spec.rb
@@ -0,0 +1,461 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ::RemoteDevelopment::Workspaces::Reconcile::ReconcileProcessor, :freeze_time, feature_category: :remote_development do
+ include_context 'with remote development shared fixtures'
+
+ describe '#process' do
+ shared_examples 'max_hours_before_termination handling' do
+ it 'sets desired_state to Terminated' do
+ _, error = subject.process(agent: agent, workspace_agent_infos: workspace_agent_infos, update_type: update_type)
+ expect(error).to be_nil
+
+ expect(workspace.reload.desired_state).to eq(RemoteDevelopment::Workspaces::States::TERMINATED)
+ end
+ end
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:agent) { create(:ee_cluster_agent, :with_remote_development_agent_config) }
+ let(:expected_value_for_started) { true }
+
+ subject do
+ described_class.new
+ end
+
+ context 'when update_type is full' do
+ let(:update_type) { RemoteDevelopment::Workspaces::Reconcile::UpdateType::FULL }
+
+ it 'updates workspace record and returns proper workspace_rails_info entry' do
+ create(:workspace, agent: agent, user: user)
+ payload, error = subject.process(agent: agent, workspace_agent_infos: [], update_type: update_type)
+ expect(error).to be_nil
+ workspace_rails_infos = payload.fetch(:workspace_rails_infos)
+ expect(workspace_rails_infos.length).to eq(1)
+ workspace_rails_info = workspace_rails_infos.first
+
+ # NOTE: We don't care about any specific expectations, just that the existing workspace
+ # still has a config returned in the rails_info response even though it was not sent by the agent.
+ expect(workspace_rails_info[:config_to_apply]).not_to be_nil
+ end
+ end
+
+ context 'when update_type is partial' do
+ let(:update_type) { RemoteDevelopment::Workspaces::Reconcile::UpdateType::PARTIAL }
+
+ context 'when receiving agent updates for a workspace which exists in the db' do
+ let(:desired_state) { RemoteDevelopment::Workspaces::States::STOPPED }
+ let(:actual_state) { current_actual_state }
+ let(:previous_actual_state) { RemoteDevelopment::Workspaces::States::STOPPING }
+ let(:current_actual_state) { RemoteDevelopment::Workspaces::States::STOPPED }
+ let(:workspace_exists) { false }
+ let(:deployment_resource_version_from_agent) { '2' }
+ let(:expected_desired_state) { desired_state }
+ let(:expected_actual_state) { actual_state }
+ let(:expected_deployment_resource_version) { deployment_resource_version_from_agent }
+ let(:expected_config_to_apply) { nil }
+ let(:owning_inventory) { "#{workspace.name}-workspace-inventory" }
+
+ let(:workspace_agent_info) do
+ create_workspace_agent_info(
+ workspace_id: workspace.id,
+ workspace_name: workspace.name,
+ workspace_namespace: workspace.namespace,
+ agent_id: workspace.agent.id,
+ owning_inventory: owning_inventory,
+ resource_version: deployment_resource_version_from_agent,
+ previous_actual_state: previous_actual_state,
+ current_actual_state: current_actual_state,
+ workspace_exists: workspace_exists
+ )
+ end
+
+ let(:workspace_agent_infos) { [workspace_agent_info] }
+
+ let(:expected_workspace_rails_info) do
+ {
+ name: workspace.name,
+ namespace: workspace.namespace,
+ desired_state: expected_desired_state,
+ actual_state: expected_actual_state,
+ deployment_resource_version: expected_deployment_resource_version,
+ config_to_apply: expected_config_to_apply
+ }
+ end
+
+ let(:expected_workspace_rails_infos) { [expected_workspace_rails_info] }
+
+ let(:workspace) do
+ create(
+ :workspace,
+ agent: agent,
+ user: user,
+ desired_state: desired_state,
+ actual_state: actual_state
+ )
+ end
+
+ context 'with max_hours_before_termination expired' do
+ let(:workspace) do
+ create(
+ :workspace,
+ :without_realistic_after_create_timestamp_updates,
+ agent: agent,
+ user: user,
+ desired_state: desired_state,
+ actual_state: actual_state,
+ max_hours_before_termination: 24,
+ created_at: 25.hours.ago
+ )
+ end
+
+ context 'when state would otherwise be sent' do
+ let(:desired_state) { RemoteDevelopment::Workspaces::States::STOPPED }
+ let(:actual_state) { RemoteDevelopment::Workspaces::States::RUNNING }
+
+ it_behaves_like 'max_hours_before_termination handling'
+ end
+
+ context 'when desired_state is RestartRequested and actual_state is Stopped' do
+ let(:desired_state) { RemoteDevelopment::Workspaces::States::RESTART_REQUESTED }
+ let(:actual_state) { RemoteDevelopment::Workspaces::States::STOPPED }
+
+ it_behaves_like 'max_hours_before_termination handling'
+ end
+ end
+
+ context 'with timestamp precondition checks' do
+ # NOTE: rubocop:disable RSpec/ExpectInHook could be avoided with a helper method or custom expectation,
+ # but this works for now.
+ # rubocop:disable RSpec/ExpectInHook
+ before do
+ # Ensure that both desired_state_updated_at and responded_to_agent_at are before Time.current,
+ # so that we can test for any necessary differences after processing updates them
+ # noinspection RubyResolve
+ expect(workspace.desired_state_updated_at).to be_before(Time.current)
+ # noinspection RubyResolve
+ expect(workspace.responded_to_agent_at).to be_before(Time.current)
+ end
+
+ after do
+ # After processing, the responded_to_agent_at should always have been updated
+ workspace.reload
+ # noinspection RubyResolve
+ expect(workspace.responded_to_agent_at)
+ .not_to be_before(workspace.desired_state_updated_at)
+ end
+ # rubocop:enable RSpec/ExpectInHook
+
+ context 'when desired_state matches actual_state' do
+ # rubocop:disable RSpec/ExpectInHook
+ before do
+ # noinspection RubyResolve
+ expect(workspace.responded_to_agent_at)
+ .to be_after(workspace.desired_state_updated_at)
+ end
+ # rubocop:enable RSpec/ExpectInHook
+
+ context 'when state is Stopped' do
+ let(:desired_state) { RemoteDevelopment::Workspaces::States::STOPPED }
+
+ it 'updates workspace record and returns proper workspace_rails_info entry' do
+ # verify initial states in db (sanity check of match between factory and fixtures)
+ expect(workspace.desired_state).to eq(desired_state)
+ expect(workspace.actual_state).to eq(actual_state)
+
+ payload, error = subject.process(
+ agent: agent,
+ workspace_agent_infos: workspace_agent_infos,
+ update_type: update_type
+ )
+ expect(error).to be_nil
+ workspace_rails_infos = payload.fetch(:workspace_rails_infos)
+ expect(workspace_rails_infos.length).to eq(1)
+
+ workspace.reload
+
+ expect(workspace.desired_state).to eq(workspace.actual_state)
+ expect(workspace.deployment_resource_version)
+ .to eq(expected_deployment_resource_version)
+
+ expect(workspace_rails_infos).to eq(expected_workspace_rails_infos)
+ end
+ end
+
+ context 'when state is Terminated' do
+ let(:desired_state) { RemoteDevelopment::Workspaces::States::TERMINATED }
+ let(:previous_actual_state) { RemoteDevelopment::Workspaces::States::TERMINATED }
+ let(:current_actual_state) { RemoteDevelopment::Workspaces::States::TERMINATED }
+ let(:expected_deployment_resource_version) { workspace.deployment_resource_version }
+
+ it 'updates workspace record and returns proper workspace_rails_info entry' do
+ # verify initial states in db (sanity check of match between factory and fixtures)
+ expect(workspace.desired_state).to eq(desired_state)
+ expect(workspace.actual_state).to eq(actual_state)
+
+ # We could do this with a should_not_change block but this reads cleaner IMO
+ payload, error = subject.process(
+ agent: agent,
+ workspace_agent_infos: workspace_agent_infos,
+ update_type: update_type
+ )
+ expect(error).to be_nil
+ workspace_rails_infos = payload.fetch(:workspace_rails_infos)
+ expect(workspace_rails_infos.length).to eq(1)
+
+ workspace.reload
+
+ expect(workspace.desired_state).to eq(workspace.actual_state)
+ expect(workspace.deployment_resource_version)
+ .to eq(expected_deployment_resource_version)
+
+ expect(workspace_rails_infos).to eq(expected_workspace_rails_infos)
+ end
+ end
+ end
+
+ context 'when desired_state does not match actual_state' do
+ # noinspection RubyResolve
+ let(:deployment_resource_version_from_agent) { workspace.deployment_resource_version }
+ # noinspection RubyResolve
+ let(:owning_inventory) { "#{workspace.name}-workspace-inventory" }
+
+ # noinspection RubyResolve
+ let(:expected_config_to_apply) do
+ create_config_to_apply(
+ workspace_id: workspace.id,
+ workspace_name: workspace.name,
+ workspace_namespace: workspace.namespace,
+ agent_id: workspace.agent.id,
+ owning_inventory: owning_inventory,
+ started: expected_value_for_started
+ )
+ end
+
+ let(:expected_workspace_rails_infos) { [expected_workspace_rails_info] }
+
+ # rubocop:disable RSpec/ExpectInHook
+ before do
+ # noinspection RubyResolve
+ expect(workspace.responded_to_agent_at)
+ .to be_before(workspace.desired_state_updated_at)
+ end
+ # rubocop:enable RSpec/ExpectInHook
+
+ context 'when desired_state is Running' do
+ let(:desired_state) { RemoteDevelopment::Workspaces::States::RUNNING }
+
+ # noinspection RubyResolve
+ it 'returns proper workspace_rails_info entry with config_to_apply' do
+ # verify initial states in db (sanity check of match between factory and fixtures)
+ expect(workspace.desired_state).to eq(desired_state)
+ expect(workspace.actual_state).to eq(actual_state)
+
+ payload, error = subject.process(
+ agent: agent,
+ workspace_agent_infos: workspace_agent_infos,
+ update_type: update_type
+ )
+ expect(error).to be_nil
+ workspace_rails_infos = payload.fetch(:workspace_rails_infos)
+ expect(workspace_rails_infos.length).to eq(1)
+
+ workspace.reload
+
+ expect(workspace.deployment_resource_version)
+ .to eq(expected_deployment_resource_version)
+
+ # test the config to apply first to get a more specific diff if it fails
+ # noinspection RubyResolve
+ provisioned_workspace_rails_info =
+ workspace_rails_infos.detect { |info| info.fetch(:name) == workspace.name }
+ expect(provisioned_workspace_rails_info.fetch(:config_to_apply))
+ .to eq(expected_workspace_rails_info.fetch(:config_to_apply))
+
+ # then test everything in the infos
+ expect(workspace_rails_infos).to eq(expected_workspace_rails_infos)
+ end
+ end
+
+ context 'when desired_state is Terminated' do
+ let(:desired_state) { RemoteDevelopment::Workspaces::States::TERMINATED }
+ let(:expected_value_for_started) { false }
+
+ # noinspection RubyResolve
+ it 'returns proper workspace_rails_info entry with config_to_apply' do
+ # verify initial states in db (sanity check of match between factory and fixtures)
+ expect(workspace.desired_state).to eq(desired_state)
+ expect(workspace.actual_state).to eq(actual_state)
+
+ payload, error = subject.process(
+ agent: agent,
+ workspace_agent_infos: workspace_agent_infos,
+ update_type: update_type
+ )
+ expect(error).to be_nil
+ workspace_rails_infos = payload.fetch(:workspace_rails_infos)
+ expect(workspace_rails_infos.length).to eq(1)
+
+ workspace.reload
+
+ expect(workspace.deployment_resource_version)
+ .to eq(expected_deployment_resource_version)
+
+ # test the config to apply first to get a more specific diff if it fails
+ # noinspection RubyResolve
+ provisioned_workspace_rails_info =
+ workspace_rails_infos.detect { |info| info.fetch(:name) == workspace.name }
+ expect(provisioned_workspace_rails_info.fetch(:config_to_apply))
+ .to eq(expected_workspace_rails_info.fetch(:config_to_apply))
+
+ # then test everything in the infos
+ expect(workspace_rails_infos).to eq(expected_workspace_rails_infos)
+ end
+ end
+
+ context 'when desired_state is RestartRequested and actual_state is Stopped' do
+ let(:desired_state) { RemoteDevelopment::Workspaces::States::RESTART_REQUESTED }
+ let(:expected_desired_state) { RemoteDevelopment::Workspaces::States::RUNNING }
+
+ # noinspection RubyResolve
+ it 'changes desired_state to Running' do
+ # verify initial states in db (sanity check of match between factory and fixtures)
+ expect(workspace.desired_state).to eq(desired_state)
+ expect(workspace.actual_state).to eq(actual_state)
+
+ payload, error = subject.process(agent: agent,
+ workspace_agent_infos: workspace_agent_infos,
+ update_type: update_type
+ )
+ expect(error).to be_nil
+ workspace_rails_infos = payload.fetch(:workspace_rails_infos)
+ expect(workspace_rails_infos.length).to eq(1)
+
+ workspace.reload
+ expect(workspace.desired_state).to eq(expected_desired_state)
+
+ # test the config to apply first to get a more specific diff if it fails
+ # noinspection RubyResolve
+ provisioned_workspace_rails_info =
+ workspace_rails_infos.detect { |info| info.fetch(:name) == workspace.name }
+ expect(provisioned_workspace_rails_info[:config_to_apply])
+ .to eq(expected_workspace_rails_info.fetch(:config_to_apply))
+
+ # then test everything in the infos
+ expect(workspace_rails_infos).to eq(expected_workspace_rails_infos)
+ end
+ end
+
+ context 'when actual_state is Unknown' do
+ let(:current_actual_state) { RemoteDevelopment::Workspaces::States::UNKNOWN }
+
+ it 'has test coverage for logging in conditional' do
+ subject.process(agent: agent, workspace_agent_infos: workspace_agent_infos, update_type: update_type)
+ end
+ end
+ end
+ end
+ end
+
+ context 'when receiving agent updates for a workspace which does not exist in the db' do
+ let(:workspace_name) { 'non-existent-workspace' }
+ let(:workspace_namespace) { 'does-not-matter' }
+
+ let(:workspace_agent_info) do
+ create_workspace_agent_info(
+ workspace_id: 1,
+ workspace_name: workspace_name,
+ workspace_namespace: workspace_namespace,
+ agent_id: '1',
+ owning_inventory: 'does-not-matter',
+ resource_version: '42',
+ previous_actual_state: RemoteDevelopment::Workspaces::States::STOPPING,
+ current_actual_state: RemoteDevelopment::Workspaces::States::STOPPED,
+ workspace_exists: false
+ )
+ end
+
+ let(:workspace_agent_infos) { [workspace_agent_info] }
+
+ let(:expected_workspace_rails_infos) { [] }
+
+ it 'prints an error and does not attempt to update the workspace in the db' do
+ payload, error = subject.process(
+ agent: agent,
+ workspace_agent_infos: workspace_agent_infos,
+ update_type: update_type
+ )
+ expect(error).to be_nil
+ workspace_rails_infos = payload.fetch(:workspace_rails_infos)
+ expect(workspace_rails_infos).to be_empty
+ end
+ end
+
+ context 'when new unprovisioned workspace exists in database"' do
+ let(:desired_state) { RemoteDevelopment::Workspaces::States::RUNNING }
+ let(:actual_state) { RemoteDevelopment::Workspaces::States::CREATION_REQUESTED }
+
+ let_it_be(:unprovisioned_workspace) do
+ create(:workspace, :unprovisioned, agent: agent, user: user)
+ end
+
+ let(:workspace_agent_infos) { [] }
+
+ # noinspection RubyResolve
+ let(:owning_inventory) { "#{unprovisioned_workspace.name}-workspace-inventory" }
+
+ # noinspection RubyResolve
+ let(:expected_config_to_apply) do
+ create_config_to_apply(
+ workspace_id: unprovisioned_workspace.id,
+ workspace_name: unprovisioned_workspace.name,
+ workspace_namespace: unprovisioned_workspace.namespace,
+ agent_id: unprovisioned_workspace.agent.id,
+ owning_inventory: owning_inventory,
+ started: expected_value_for_started
+ )
+ end
+
+ # noinspection RubyResolve
+ let(:expected_unprovisioned_workspace_rails_info) do
+ {
+ name: unprovisioned_workspace.name,
+ namespace: unprovisioned_workspace.namespace,
+ desired_state: desired_state,
+ actual_state: actual_state,
+ deployment_resource_version: nil,
+ config_to_apply: expected_config_to_apply
+ }
+ end
+
+ let(:expected_workspace_rails_infos) { [expected_unprovisioned_workspace_rails_info] }
+
+ # noinspection RubyResolve
+ it 'returns proper workspace_rails_info entry' do
+ # verify initial states in db (sanity check of match between factory and fixtures)
+ expect(unprovisioned_workspace.desired_state).to eq(desired_state)
+ expect(unprovisioned_workspace.actual_state).to eq(actual_state)
+
+ payload, error = subject.process(
+ agent: agent,
+ workspace_agent_infos: workspace_agent_infos,
+ update_type: update_type
+ )
+ expect(error).to be_nil
+ workspace_rails_infos = payload.fetch(:workspace_rails_infos)
+ expect(workspace_rails_infos.length).to eq(1)
+
+ # test the config to apply first to get a more specific diff if it fails
+ # noinspection RubyResolve
+ unprovisioned_workspace_rails_info =
+ workspace_rails_infos.detect { |info| info.fetch(:name) == unprovisioned_workspace.name }
+ expect(unprovisioned_workspace_rails_info.fetch(:config_to_apply))
+ .to eq(expected_unprovisioned_workspace_rails_info.fetch(:config_to_apply))
+
+ # then test everything in the infos
+ expect(workspace_rails_infos).to eq(expected_workspace_rails_infos)
+ end
+ end
+ end
+ end
+end
diff --git a/ee/spec/lib/remote_development/workspaces/states_spec.rb b/ee/spec/lib/remote_development/workspaces/states_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..ab9307bf07dec59f2a5725be6862e42c96d95f39
--- /dev/null
+++ b/ee/spec/lib/remote_development/workspaces/states_spec.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+
+RSpec.describe RemoteDevelopment::Workspaces::States, feature_category: :remote_development do
+ let(:object) { Object.new.extend(described_class) }
+
+ describe '.valid_desired_state?' do
+ it 'returns true for a valid desired state' do
+ expect(object.valid_desired_state?(RemoteDevelopment::Workspaces::States::RESTART_REQUESTED)).to be(true)
+ end
+
+ it 'returns false for an invalid desired state' do
+ expect(object.valid_desired_state?(RemoteDevelopment::Workspaces::States::FAILED)).to be(false)
+ end
+ end
+
+ describe '.valid_actual_state?' do
+ it 'returns true for a valid actual state' do
+ expect(object.valid_actual_state?(RemoteDevelopment::Workspaces::States::RUNNING)).to be(true)
+ end
+
+ it 'returns false for an invalid actual state' do
+ expect(object.valid_actual_state?(RemoteDevelopment::Workspaces::States::RESTART_REQUESTED)).to be(false)
+ end
+ end
+end
diff --git a/ee/spec/lib/remote_development/workspaces/update/update_processor_spec.rb b/ee/spec/lib/remote_development/workspaces/update/update_processor_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..821c78fa40af53fd277c0551d01c3c67a25808a0
--- /dev/null
+++ b/ee/spec/lib/remote_development/workspaces/update/update_processor_spec.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ::RemoteDevelopment::Workspaces::Update::UpdateProcessor, feature_category: :remote_development do
+ subject(:results) do
+ described_class.new.process(workspace: workspace, params: params)
+ end
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:current_user) { user }
+ let_it_be(:workspace, refind: true) do
+ create(:workspace, user: user, desired_state: RemoteDevelopment::Workspaces::States::RUNNING)
+ end
+
+ let(:new_desired_state) { RemoteDevelopment::Workspaces::States::STOPPED }
+ let(:params) do
+ {
+ desired_state: new_desired_state
+ }
+ end
+
+ describe '#process' do
+ context 'when workspace update is successful' do
+ it 'updates the workspace and returns success' do
+ expect { subject }.to change { workspace.reload.desired_state }.to(new_desired_state)
+
+ payload, error = subject
+ expect(payload).to eq({ workspace: workspace })
+ expect(error).to be_nil
+ end
+ end
+
+ context 'when workspace update fails' do
+ let(:new_desired_state) { 'InvalidDesiredState' }
+
+ it 'does not update the workspace and returns error' do
+ expect { subject }.not_to change { workspace.reload }
+
+ payload, error = subject
+ expect(payload).to be_nil
+ expect(error.message).to match(/Error/)
+ expect(error.reason).to eq(:bad_request)
+ end
+ end
+ end
+end
diff --git a/ee/spec/models/factories_spec.rb b/ee/spec/models/factories_spec.rb
index b9d3e24425c8f9d943080535af1c39d42a3009fd..5976f5691c91ff64ebef8ca8f65f33ce1bce360d 100644
--- a/ee/spec/models/factories_spec.rb
+++ b/ee/spec/models/factories_spec.rb
@@ -161,6 +161,7 @@
vulnerabilities_finding_identifier
wiki_page
wiki_page_meta
+ workspace
].to_set.freeze
# Some factories and their corresponding models are based on
diff --git a/ee/spec/models/remote_development/remote_development_agent_config_spec.rb b/ee/spec/models/remote_development/remote_development_agent_config_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..24aac01450ad87f596ab2999b6c971e5e05e2924
--- /dev/null
+++ b/ee/spec/models/remote_development/remote_development_agent_config_spec.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe RemoteDevelopment::RemoteDevelopmentAgentConfig, feature_category: :remote_development do
+ let_it_be(:agent) { create(:ee_cluster_agent, :with_remote_development_agent_config) }
+
+ subject { agent.remote_development_agent_config }
+
+ describe 'associations' do
+ it { is_expected.to belong_to(:agent) }
+ it { is_expected.to have_many(:workspaces) }
+
+ context 'with associated workspaces' do
+ let(:workspace_1) { create(:workspace, agent: agent) }
+ let(:workspace_2) { create(:workspace, agent: agent) }
+
+ it 'has correct associations from factory' do
+ expect(subject.reload.workspaces).to contain_exactly(workspace_1, workspace_2)
+ expect(workspace_1.remote_development_agent_config).to eq(subject)
+ end
+ end
+ end
+
+ describe '#after_update' do
+ it 'prevents dns_zone from being updated' do
+ subject.update(dns_zone: 'new-zone') # rubocop:disable Rails/SaveBang
+ expect(subject.errors.full_messages)
+ .to match_array(['Dns zone is currently immutable, and cannot be updated. Create a new agent instead.'])
+ end
+ end
+end
diff --git a/ee/spec/models/remote_development/workspace_spec.rb b/ee/spec/models/remote_development/workspace_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..f7f70673816b48dbcc8ac1205f4187d4a5067549
--- /dev/null
+++ b/ee/spec/models/remote_development/workspace_spec.rb
@@ -0,0 +1,125 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe RemoteDevelopment::Workspace, feature_category: :remote_development do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:agent) { create(:ee_cluster_agent, :with_remote_development_agent_config) }
+ let_it_be(:project) { create(:project, :public, :in_group) }
+
+ subject { create(:workspace, user: user, agent: agent, project: project) }
+
+ describe 'associations' do
+ it { is_expected.to belong_to(:user) }
+ it { is_expected.to have_one(:remote_development_agent_config) }
+
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/409786
+ # This expectation no longer works after the introduction of RemoteDevelopmentAgentConfig, don't know why.
+ # it { is_expected.to belong_to(:agent).with_foreign_key('cluster_agent_id').class_name('Clusters::Agent') }
+
+ it 'has correct associations from factory' do
+ expect(subject.user).to eq(user)
+ expect(subject.project).to eq(project)
+ expect(subject.agent).to eq(agent)
+ expect(subject.remote_development_agent_config).to eq(agent.remote_development_agent_config)
+ expect(agent.remote_development_agent_config.workspaces.first).to eq(subject)
+ end
+ end
+
+ describe '#terminated?' do
+ let(:actual_state) { ::RemoteDevelopment::Workspaces::States::TERMINATED }
+
+ subject { build(:workspace, actual_state: actual_state) }
+
+ it 'returns true if terminated' do
+ expect(subject.terminated?).to eq(true)
+ end
+ end
+
+ describe '.before_save' do
+ describe 'when creating new record', :freeze_time do
+ # NOTE: The workspaces factory overrides the desired_state_updated_at to be earlier than
+ # the current time, so we need to use build here instead of create here to test
+ # the callback which sets the desired_state_updated_at to current time upon creation.
+ subject { build(:workspace, user: user, agent: agent, project: project) }
+
+ it 'sets desired_state_updated_at' do
+ subject.save!
+ # noinspection RubyResolve
+ expect(subject.desired_state_updated_at).to eq(Time.current)
+ end
+ end
+
+ describe 'when updating desired_state' do
+ it 'sets desired_state_updated_at' do
+ expect { subject.update!(desired_state: ::RemoteDevelopment::Workspaces::States::RUNNING) }.to change {
+ # noinspection RubyResolve
+ subject.desired_state_updated_at
+ }
+ end
+ end
+
+ describe 'when updating a field other than desired_state' do
+ it 'does not set desired_state_updated_at' do
+ expect { subject.update!(actual_state: ::RemoteDevelopment::Workspaces::States::RUNNING) }.not_to change {
+ # noinspection RubyResolve
+ subject.desired_state_updated_at
+ }
+ end
+ end
+ end
+
+ describe 'validations' do
+ it 'validates max_hours_before_termination is no more than 120' do
+ # noinspection RubyResolve
+ subject.max_hours_before_termination = described_class::MAX_HOURS_BEFORE_TERMINATION_LIMIT
+ # noinspection RubyResolve
+ expect(subject).to be_valid
+
+ # noinspection RubyResolve
+ subject.max_hours_before_termination = described_class::MAX_HOURS_BEFORE_TERMINATION_LIMIT + 1
+ expect(subject).not_to be_valid
+ end
+
+ it 'validates editor is webide' do
+ subject.editor = 'not-webide'
+ expect(subject).not_to be_valid
+ end
+
+ context 'on remote_development_agent_config' do
+ let(:agent_with_no_remote_development_config) { create(:cluster_agent) }
+
+ subject do
+ build(:workspace, user: user, agent: agent_with_no_remote_development_config, project: project)
+ end
+
+ it 'validates presence of agent.remote_development_agent_config' do
+ # sanity check of fixture
+ expect(agent_with_no_remote_development_config.remote_development_agent_config).not_to be_present
+
+ expect(subject).not_to be_valid
+ expect(subject.errors[:agent]).to include('for Workspace must have an associated RemoteDevelopmentAgentConfig')
+ end
+ end
+
+ context 'on project' do
+ context 'when the project is not public' do
+ let_it_be(:private_project) do
+ create(:project, visibility_level: Gitlab::VisibilityLevel::PRIVATE)
+ end
+
+ subject do
+ build(:workspace, user: user, project: private_project)
+ end
+
+ it 'validates project is public' do
+ # sanity check of fixture
+ expect(private_project).to be_private
+
+ expect(subject).not_to be_valid
+ expect(subject.errors[:project]).to include('for Workspace is required to be public')
+ end
+ end
+ end
+ end
+end
diff --git a/ee/spec/policies/project_policy_spec.rb b/ee/spec/policies/project_policy_spec.rb
index dd0f5021fa106adfd18a734392f8d00d6f37e44e..efa38ed393e6a601a9c2cc8d17efbe21cf3e64c8 100644
--- a/ee/spec/policies/project_policy_spec.rb
+++ b/ee/spec/policies/project_policy_spec.rb
@@ -2676,4 +2676,18 @@ def expect_private_project_permissions_as_if_non_member
it { is_expected.to be_allowed(:read_project_runners) }
end
end
+
+ describe 'workspace creation' do
+ context 'with no user' do
+ let(:current_user) { nil }
+
+ it { is_expected.to be_disallowed(:create_workspace) }
+ end
+
+ context 'with an authorized user' do
+ let(:current_user) { developer }
+
+ it { is_expected.to be_allowed(:create_workspace) }
+ end
+ end
end
diff --git a/ee/spec/policies/remote_development/workspace_policy_spec.rb b/ee/spec/policies/remote_development/workspace_policy_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..1ba64202fb1a77c6cd8781c4aade9b99421ace0f
--- /dev/null
+++ b/ee/spec/policies/remote_development/workspace_policy_spec.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe RemoteDevelopment::WorkspacePolicy, feature_category: :remote_development do
+ let(:current_user) { create(:user) }
+ let_it_be(:developer) { create(:user) }
+ let_it_be(:agent) { create(:ee_cluster_agent, :with_remote_development_agent_config) }
+ let_it_be(:project) { create(:project) }
+ let(:workspace) { build(:workspace, project: project, agent: agent) }
+
+ subject(:policy) { described_class.new(current_user, workspace) }
+
+ before do
+ project.add_developer(developer)
+ end
+
+ describe 'update_workspace' do
+ context 'when the workspace belongs to another user and the user does not have develop access' do
+ it { is_expected.to be_disallowed(:update_workspace) }
+ end
+
+ context 'when the workspace belongs to the user' do
+ before do
+ # An unexpected state
+ # this is done after building the workspace to avoid the factory adding the user as a developer
+ workspace.user = current_user
+ end
+
+ context 'and the user does not have developer access to the project' do
+ it { is_expected.to be_disallowed(:update_workspace) }
+ end
+ end
+
+ context 'when the user has developer access' do
+ let(:current_user) { developer }
+
+ context 'and the workspace belongs to another user' do
+ it { is_expected.to be_disallowed(:update_workspace) }
+ end
+
+ context 'and the workspace belongs to the user' do
+ let(:workspace) { build(:workspace, project: project, agent: agent, user: current_user) }
+
+ it { is_expected.to be_allowed(:update_workspace) }
+ end
+ end
+ end
+end
diff --git a/ee/spec/requests/api/graphql/mutations/remote_development/workspaces/create_spec.rb b/ee/spec/requests/api/graphql/mutations/remote_development/workspaces/create_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..6a07cead2e2c8c239ff3d39bbcb4765b5cb399ed
--- /dev/null
+++ b/ee/spec/requests/api/graphql/mutations/remote_development/workspaces/create_spec.rb
@@ -0,0 +1,118 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Creating a workspace', feature_category: :remote_development do
+ include GraphqlHelpers
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:current_user) { user } # NOTE: Some graphql spec helper methods rely on current_user to be set
+ let_it_be(:project) { create(:project, :public, :in_group, :repository) }
+ let_it_be(:agent) { create(:ee_cluster_agent, :with_remote_development_agent_config) }
+
+ let(:desired_state) { RemoteDevelopment::Workspaces::States::RUNNING }
+
+ let(:all_mutation_args) do
+ {
+ desired_state: desired_state,
+ editor: 'webide',
+ max_hours_before_termination: 24,
+ cluster_agent_id: agent.to_global_id.to_s,
+ project_id: project.to_global_id.to_s,
+ devfile_ref: 'main',
+ devfile_path: '.devfile.yaml'
+ }
+ end
+
+ let(:mutation_args) { all_mutation_args }
+
+ let(:mutation) do
+ graphql_mutation(:workspace_create, mutation_args)
+ end
+
+ let(:expected_service_params) do
+ params = all_mutation_args.except(:cluster_agent_id, :project_id)
+ params[:agent] = agent
+ params[:user] = current_user
+ params[:project] = project
+ params
+ end
+
+ let_it_be(:created_workspace, refind: true) { create(:workspace, user: user) }
+
+ # noinspection RubyResolve
+ let(:stub_service_payload) { { workspace: created_workspace } }
+ let(:stub_service_response) do
+ ServiceResponse.success(payload: stub_service_payload)
+ end
+
+ def mutation_response
+ graphql_mutation_response(:workspace_create)
+ end
+
+ before do
+ stub_licensed_features(remote_development: true)
+ agent.project.add_developer(user)
+ project.add_developer(user)
+ allow_next_instance_of(
+ ::RemoteDevelopment::Workspaces::CreateService
+ ) do |service_instance|
+ allow(service_instance).to receive(:execute).with(
+ params: expected_service_params
+ ) do
+ stub_service_response
+ end
+ end
+ end
+
+ it 'creates the workspace' do
+ post_graphql_mutation(mutation, current_user: user)
+
+ expect_graphql_errors_to_be_empty
+
+ # noinspection RubyResolve
+ expect(mutation_response.fetch('workspace')['name']).to eq(created_workspace['name'])
+ end
+
+ context 'when there are service errors' do
+ let(:stub_service_response) { ::ServiceResponse.error(message: 'some error', reason: :bad_request) }
+
+ it_behaves_like 'a mutation that returns errors in the response', errors: ['some error']
+ end
+
+ context 'when required arguments are missing' do
+ let(:mutation_args) { all_mutation_args.except(:desired_state) }
+
+ it 'returns error about required argument' do
+ post_graphql_mutation(mutation, current_user: user)
+
+ expect_graphql_errors_to_include(/provided invalid value for desiredState \(Expected value to not be null\)/)
+ end
+ end
+
+ context 'when the user cannot create a workspace for the project' do
+ let(:current_user) { create(:user) }
+
+ it_behaves_like 'a mutation on an unauthorized resource'
+ end
+
+ context 'when remote_development feature is unlicensed' do
+ before do
+ stub_licensed_features(remote_development: false)
+ end
+
+ it_behaves_like 'a mutation that returns top-level errors' do
+ let(:match_errors) { include(/'remote_development' licensed feature is not available/) }
+ end
+ end
+
+ context 'when remote_development_feature_flag feature flag is disabled' do
+ before do
+ stub_feature_flags(remote_development_feature_flag: false)
+ end
+
+ it_behaves_like 'a mutation that returns top-level errors' do
+ let(:match_errors) { include(/'remote_development_feature_flag' feature flag is disabled/) }
+ end
+ end
+end
diff --git a/ee/spec/requests/api/graphql/mutations/remote_development/workspaces/update_spec.rb b/ee/spec/requests/api/graphql/mutations/remote_development/workspaces/update_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..34fea4dee0d6af7869c0dd482126d094b7227280
--- /dev/null
+++ b/ee/spec/requests/api/graphql/mutations/remote_development/workspaces/update_spec.rb
@@ -0,0 +1,107 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Updating a workspace', feature_category: :remote_development do
+ include GraphqlHelpers
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:current_user) { user } # NOTE: Some graphql spec helper methods rely on current_user to be set
+ let_it_be(:project) { create(:project, :public, :in_group, :repository) }
+ let_it_be(:agent) { create(:ee_cluster_agent, :with_remote_development_agent_config) }
+ let_it_be(:workspace, refind: true) do
+ create(
+ :workspace,
+ agent: agent,
+ project: project,
+ user: user,
+ desired_state: RemoteDevelopment::Workspaces::States::RUNNING,
+ editor: 'webide'
+ )
+ end
+
+ let(:all_mutation_args) do
+ {
+ id: workspace.to_global_id.to_s,
+ desired_state: RemoteDevelopment::Workspaces::States::STOPPED
+ }
+ end
+
+ let(:mutation_args) { { id: global_id_of(workspace), desired_state: RemoteDevelopment::Workspaces::States::STOPPED } }
+ let(:mutation) { graphql_mutation(:workspace_update, mutation_args) }
+ let(:expected_service_params) { all_mutation_args.except(:id) }
+ let(:stub_service_payload) { { workspace: workspace } }
+ let(:stub_service_response) do
+ ServiceResponse.success(payload: stub_service_payload)
+ end
+
+ def mutation_response
+ graphql_mutation_response(:workspace_update)
+ end
+
+ before do
+ stub_licensed_features(remote_development: true)
+ agent.project.add_developer(user)
+ project.add_developer(user)
+ allow_next_instance_of(
+ ::RemoteDevelopment::Workspaces::UpdateService
+ ) do |service_instance|
+ allow(service_instance).to receive(:execute).with(
+ workspace: workspace,
+ params: expected_service_params
+ ) do
+ stub_service_response
+ end
+ end
+ end
+
+ it 'updates the workspace' do
+ post_graphql_mutation(mutation, current_user: user)
+
+ expect_graphql_errors_to_be_empty
+
+ expect(mutation_response.fetch('workspace')['name']).to eq(workspace['name'])
+ end
+
+ context 'when there are service errors' do
+ let(:stub_service_response) { ::ServiceResponse.error(message: 'some error', reason: :bad_request) }
+
+ it_behaves_like 'a mutation that returns errors in the response', errors: ['some error']
+ end
+
+ context 'when some required arguments are missing' do
+ let(:mutation_args) { all_mutation_args.except(:desired_state) }
+
+ it 'returns error about required argument' do
+ post_graphql_mutation(mutation, current_user: user)
+
+ expect_graphql_errors_to_include(/provided invalid value for desiredState \(Expected value to not be null\)/)
+ end
+ end
+
+ context 'when the user cannot create a workspace for the project' do
+ let(:current_user) { create(:user) }
+
+ it_behaves_like 'a mutation on an unauthorized resource'
+ end
+
+ context 'when remote_development feature is unlicensed' do
+ before do
+ stub_licensed_features(remote_development: false)
+ end
+
+ it_behaves_like 'a mutation that returns top-level errors' do
+ let(:match_errors) { include(/'remote_development' licensed feature is not available/) }
+ end
+ end
+
+ context 'when remote_development_feature_flag feature flag is disabled' do
+ before do
+ stub_feature_flags(remote_development_feature_flag: false)
+ end
+
+ it_behaves_like 'a mutation that returns top-level errors' do
+ let(:match_errors) { include(/'remote_development_feature_flag' feature flag is disabled/) }
+ end
+ end
+end
diff --git a/ee/spec/requests/api/graphql/remote_development/current_user_workspaces_spec.rb b/ee/spec/requests/api/graphql/remote_development/current_user_workspaces_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..3c901d5105bafa776fbc8ffab611350543156d4c
--- /dev/null
+++ b/ee/spec/requests/api/graphql/remote_development/current_user_workspaces_spec.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Query.currentUser.workspaces', feature_category: :remote_development do
+ include GraphqlHelpers
+
+ let_it_be(:workspace) { create(:workspace) }
+ let(:current_user) { workspace.user }
+ let(:fields) do
+ <<~QUERY
+ nodes {
+ #{all_graphql_fields_for('workspaces'.classify, max_depth: 2)}
+ }
+ QUERY
+ end
+
+ let(:query) do
+ graphql_query_for('currentUser', {}, query_graphql_field('workspaces', {}, fields))
+ end
+
+ subject { graphql_data.dig('currentUser', 'workspaces', 'nodes') }
+
+ context 'when licensed and remote_development_feature_flag feature flag is enabled' do
+ before do
+ stub_licensed_features(remote_development: true)
+
+ post_graphql(query, current_user: current_user)
+ end
+
+ it_behaves_like 'a working graphql query'
+
+ # noinspection RubyResolve
+ it { is_expected.to match_array(a_hash_including('name' => workspace.name)) }
+
+ context 'when user is not authorized' do
+ let(:current_user) { create(:user) }
+
+ it { is_expected.to eq([]) }
+ end
+ end
+
+ context 'when remote_development feature is unlicensed' do
+ before do
+ stub_licensed_features(remote_development: false)
+ post_graphql(query, current_user: current_user)
+ end
+
+ it 'returns an error' do
+ expect(subject).to be_nil
+ expect_graphql_errors_to_include(/'remote_development' licensed feature is not available/)
+ end
+ end
+
+ context 'when remote_development_feature_flag feature flag is disabled' do
+ before do
+ stub_licensed_features(remote_development: true)
+ stub_feature_flags(remote_development_feature_flag: false)
+ post_graphql(query, current_user: current_user)
+ end
+
+ it 'returns an error' do
+ expect(subject).to be_nil
+ expect_graphql_errors_to_include(/'remote_development_feature_flag' feature flag is disabled/)
+ end
+ end
+end
diff --git a/ee/spec/requests/api/graphql/remote_development/workspace_by_id_spec.rb b/ee/spec/requests/api/graphql/remote_development/workspace_by_id_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..dd4a20c4488eed129eb4948198dc9fa33ee26c1a
--- /dev/null
+++ b/ee/spec/requests/api/graphql/remote_development/workspace_by_id_spec.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Query.workspace(id: RemoteDevelopmentWorkspaceID!)', feature_category: :remote_development do
+ include GraphqlHelpers
+
+ let_it_be(:workspace) { create(:workspace) }
+ let_it_be(:current_user) { workspace.user }
+
+ let(:fields) do
+ <<~QUERY
+ #{all_graphql_fields_for('workspace'.classify, max_depth: 2)}
+ QUERY
+ end
+
+ let(:query) do
+ graphql_query_for('workspace', { id: workspace.to_global_id.to_s }, fields)
+ end
+
+ subject { graphql_data['workspace'] }
+
+ context 'when licensed and remote_development_feature_flag feature flag is enabled' do
+ before do
+ stub_licensed_features(remote_development: true)
+ post_graphql(query, current_user: current_user)
+ end
+
+ it_behaves_like 'a working graphql query'
+
+ # noinspection RubyResolve
+ it { expect(subject['name']).to eq(workspace.name) }
+
+ context 'when the user is not authorized' do
+ let(:current_user) { create :user }
+
+ let(:query) do
+ graphql_query_for('workspace', { id: workspace.to_global_id.to_s }, fields)
+ end
+
+ it 'does not contain fields for the other workspace' do
+ expect(subject).to be_nil
+ end
+ end
+ end
+
+ context 'when remote_development feature is unlicensed' do
+ before do
+ stub_licensed_features(remote_development: false)
+ post_graphql(query, current_user: current_user)
+ end
+
+ it 'returns an error' do
+ expect(subject).to be_nil
+ expect_graphql_errors_to_include(/'remote_development' licensed feature is not available/)
+ end
+ end
+
+ context 'when remote_development_feature_flag feature flag is disabled' do
+ before do
+ stub_licensed_features(remote_development: true)
+ stub_feature_flags(remote_development_feature_flag: false)
+ post_graphql(query, current_user: current_user)
+ end
+
+ it 'returns an error' do
+ expect(subject).to be_nil
+ expect_graphql_errors_to_include(/'remote_development_feature_flag' feature flag is disabled/)
+ end
+ end
+end
diff --git a/ee/spec/requests/api/graphql/remote_development/workspaces_by_ids_spec.rb b/ee/spec/requests/api/graphql/remote_development/workspaces_by_ids_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..f4465fa2f047854ccbbb53ded8b52d4849d3f1ef
--- /dev/null
+++ b/ee/spec/requests/api/graphql/remote_development/workspaces_by_ids_spec.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Query.workspaces(ids: [RemoteDevelopmentWorkspaceID!])', feature_category: :remote_development do
+ include GraphqlHelpers
+
+ let_it_be(:workspace) { create(:workspace) }
+ let_it_be(:current_user) { workspace.user }
+ let(:ids) { [workspace.to_global_id.to_s] }
+ let(:fields) do
+ <<~QUERY
+ nodes {
+ #{all_graphql_fields_for('workspaces'.classify, max_depth: 2)}
+ }
+ QUERY
+ end
+
+ let(:query) do
+ graphql_query_for('workspaces', { ids: ids }, fields)
+ end
+
+ subject { graphql_data.dig('workspaces', 'nodes') }
+
+ context 'when licensed and remote_development_feature_flag feature flag is enabled' do
+ before do
+ stub_licensed_features(remote_development: true)
+
+ post_graphql(query, current_user: current_user)
+ end
+
+ it_behaves_like 'a working graphql query'
+
+ # noinspection RubyResolve
+ it { is_expected.to match_array(a_hash_including('name' => workspace.name)) }
+ # noinspection RubyResolve
+
+ context 'when the user requests a workspace that they are not authorized for' do
+ let_it_be(:other_workspace) { create(:workspace) }
+ # noinspection RubyResolve
+ let(:ids) do
+ [
+ workspace.to_global_id.to_s,
+ other_workspace.to_global_id.to_s
+ ]
+ end
+
+ it 'does not contain fields for the other workspace' do
+ # noinspection RubyResolve
+ expect(subject).to match_array(a_hash_including('name' => workspace.name))
+ end
+ end
+ end
+
+ context 'when remote_development feature is unlicensed' do
+ before do
+ stub_licensed_features(remote_development: false)
+ post_graphql(query, current_user: current_user)
+ end
+
+ it 'returns an error' do
+ expect(subject).to be_nil
+ expect_graphql_errors_to_include(/'remote_development' licensed feature is not available/)
+ end
+ end
+
+ context 'when remote_development_feature_flag feature flag is disabled' do
+ before do
+ stub_licensed_features(remote_development: true)
+ stub_feature_flags(remote_development_feature_flag: false)
+ post_graphql(query, current_user: current_user)
+ end
+
+ it 'returns an error' do
+ expect(subject).to be_nil
+ expect_graphql_errors_to_include(/'remote_development_feature_flag' feature flag is disabled/)
+ end
+ end
+end
diff --git a/ee/spec/requests/api/internal/kubernetes_spec.rb b/ee/spec/requests/api/internal/kubernetes_spec.rb
index cf5b47919e73f057aec62db8c30e2669efded391..8d1dfa6788df53900f415217590f55e58e39cf79 100644
--- a/ee/spec/requests/api/internal/kubernetes_spec.rb
+++ b/ee/spec/requests/api/internal/kubernetes_spec.rb
@@ -64,6 +64,140 @@ def send_request(params: {}, headers: agent_token_headers)
end
end
+ describe 'POST /internal/kubernetes/modules/remote_development/reconcile' do
+ let(:method) { :post }
+ let(:api_url) { '/internal/kubernetes/modules/remote_development/reconcile' }
+
+ before do
+ stub_licensed_features(remote_development: true)
+ allow_next_instance_of(::RemoteDevelopment::Workspaces::ReconcileService) do |service|
+ allow(service).to receive(:execute).and_return(service_response)
+ end
+ end
+
+ include_examples 'authorization'
+ include_examples 'agent authentication'
+
+ context 'when service response is successful' do
+ let(:service_response) { ServiceResponse.success(payload: {}) }
+
+ it 'returns service response with payload' do
+ send_request(params: {})
+
+ expect(response).to have_gitlab_http_status(:created)
+ end
+ end
+
+ context 'when service response is not successful' do
+ let(:service_response) { ServiceResponse.error(message: 'error', reason: :not_found) }
+
+ it 'returns service response with error' do
+ send_request(params: {})
+
+ expect(response).to have_gitlab_http_status(:internal_server_error)
+ end
+ end
+
+ context 'when remote_development feature is unlicensed' do
+ let(:service_response) { ServiceResponse.success(payload: {}) }
+
+ before do
+ stub_licensed_features(remote_development: false)
+ end
+
+ it 'returns service response with payload' do
+ send_request(params: {})
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
+ context 'when remote_development_feature_flag feature flag is disabled' do
+ let(:service_response) { ServiceResponse.success(payload: {}) }
+
+ before do
+ stub_feature_flags(remote_development_feature_flag: false)
+ end
+
+ it 'returns service response with payload' do
+ send_request(params: {})
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+ end
+
+ describe 'POST /internal/kubernetes/agent_configuration' do
+ def send_request(headers: {}, params: {})
+ post api('/internal/kubernetes/agent_configuration'), params: params, headers: headers.reverse_merge(jwt_auth_headers)
+ end
+
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project, namespace: group) }
+ let_it_be(:agent) { create(:cluster_agent, project: project) }
+ let_it_be(:config) do
+ {
+ ci_access: {
+ groups: [
+ { id: group.full_path, default_namespace: 'production' }
+ ],
+ projects: [
+ { id: project.full_path, default_namespace: 'staging' }
+ ]
+ }
+ }
+ end
+
+ include_examples 'authorization'
+
+ context 'when remote development is configured' do
+ let(:dns_zone) { 'workspaces.localdev.me' }
+ let(:config) do
+ {
+ remote_development: {
+ enabled: true,
+ dns_zone: dns_zone
+ }
+ }
+ end
+
+ before do
+ stub_licensed_features(remote_development: true)
+ end
+
+ it 'creates the remote dev configuration' do
+ send_request(params: { agent_id: agent.id, agent_config: config })
+ expect(response).to have_gitlab_http_status(:no_content)
+ expect(agent.reload.remote_development_agent_config).to be_enabled
+ expect(agent.reload.remote_development_agent_config.dns_zone).to eq(dns_zone)
+ end
+
+ context 'when remote_development feature is unlicensed' do
+ before do
+ stub_licensed_features(remote_development: false)
+ end
+
+ it 'creates the remote dev configuration' do
+ send_request(params: { agent_id: agent.id, agent_config: config })
+ expect(response).to have_gitlab_http_status(:no_content)
+ expect(agent.reload.remote_development_agent_config).to be_nil
+ end
+ end
+
+ context 'when remote_development_feature_flag feature flag is disabled' do
+ before do
+ stub_feature_flags(remote_development_feature_flag: false)
+ end
+
+ it 'creates the remote dev configuration' do
+ send_request(params: { agent_id: agent.id, agent_config: config })
+ expect(response).to have_gitlab_http_status(:no_content)
+ expect(agent.reload.remote_development_agent_config).to be_nil
+ end
+ end
+ end
+ end
+
describe 'PUT /internal/kubernetes/modules/starboard_vulnerability' do
let(:method) { :put }
let(:api_url) { '/internal/kubernetes/modules/starboard_vulnerability' }
diff --git a/ee/spec/services/remote_development/agent_config/update_service_spec.rb b/ee/spec/services/remote_development/agent_config/update_service_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..7fb2dfedfef9fc9a7628e13772bac367f643bc6d
--- /dev/null
+++ b/ee/spec/services/remote_development/agent_config/update_service_spec.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe RemoteDevelopment::AgentConfig::UpdateService, feature_category: :remote_development do
+ let(:agent) { instance_double(Clusters::Agent) }
+ let(:config) { instance_double(Hash) }
+
+ subject do
+ described_class.new.execute(agent: agent, config: config)
+ end
+
+ context 'when update is successful' do
+ let(:payload) { instance_double(Hash) }
+
+ it 'returns the payload' do
+ allow_next_instance_of(RemoteDevelopment::AgentConfig::UpdateProcessor) do |processor|
+ expect(processor).to receive(:process).with(agent: agent, config: config).and_return([payload, nil])
+ end
+ expect(subject).to eq(payload)
+ end
+ end
+
+ context 'when update fails' do
+ let(:message) { 'error message' }
+ let(:reason) { :bad_request }
+ let(:error) { RemoteDevelopment::Error.new(message: message, reason: reason) }
+
+ it 'returns false' do
+ allow_next_instance_of(RemoteDevelopment::AgentConfig::UpdateProcessor) do |processor|
+ expect(processor).to receive(:process).with(agent: agent, config: config).and_return([nil, error])
+ end
+ expect(subject).to be false
+ end
+ end
+end
diff --git a/ee/spec/services/remote_development/workspaces/create_service_spec.rb b/ee/spec/services/remote_development/workspaces/create_service_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..f2d737a262a443c5193e1412b24a6fe0379e32e6
--- /dev/null
+++ b/ee/spec/services/remote_development/workspaces/create_service_spec.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ::RemoteDevelopment::Workspaces::CreateService, feature_category: :remote_development do
+ describe '#execute' do
+ let(:user) { project.owner }
+ let(:group) { instance_double(Group) }
+ let(:params) { { project: project } }
+ let_it_be(:project) { create :project }
+ let(:process_args) { { params: params } }
+
+ subject do
+ described_class.new(current_user: user).execute(params: params)
+ end
+
+ context 'when create is successful' do
+ let(:payload) { instance_double(Hash) }
+
+ it 'returns a success ServiceResponse' do
+ allow_next_instance_of(RemoteDevelopment::Workspaces::Create::CreateProcessor) do |processor|
+ expect(processor).to receive(:process).with(params: hash_including(:project)).and_return([payload, nil])
+ end
+ expect(subject).to be_a(ServiceResponse)
+ expect(subject.payload).to eq(payload)
+ expect(subject.message).to be_nil
+ end
+ end
+
+ context 'when user is not authorized' do
+ let(:user) { build_stubbed(:user) }
+
+ it 'returns an error ServiceResponse' do
+ # noinspection RubyResolve
+ expect(subject).to be_error
+ expect(subject.payload).to eq({})
+ expect(subject.message).to eq('Unauthorized')
+ expect(subject.reason).to eq(:unauthorized)
+ end
+ end
+
+ context 'when create fails' do
+ let(:message) { 'error message' }
+ let(:reason) { :bad_request }
+ let(:error) { RemoteDevelopment::Error.new(message: message, reason: reason) }
+
+ it 'returns an error ServiceResponse' do
+ allow_next_instance_of(RemoteDevelopment::Workspaces::Create::CreateProcessor) do |processor|
+ expect(processor).to receive(:process).with(params: hash_including(:project)).and_return([nil, error])
+ end
+ expect(subject).to be_a(ServiceResponse)
+ expect(subject.payload).to eq({}) # NOTE: A nil payload gets turned into an empty hash
+ expect(subject.message).to eq(message)
+ expect(subject.reason).to eq(reason)
+ end
+ end
+ end
+end
diff --git a/ee/spec/services/remote_development/workspaces/reconcile_service_spec.rb b/ee/spec/services/remote_development/workspaces/reconcile_service_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..692189bb80f1708ea71d10afe854e52ff9938495
--- /dev/null
+++ b/ee/spec/services/remote_development/workspaces/reconcile_service_spec.rb
@@ -0,0 +1,93 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ::RemoteDevelopment::Workspaces::ReconcileService, feature_category: :remote_development do
+ describe '#execute' do
+ let(:agent) { instance_double(Clusters::Agent, id: 1) }
+ let(:params) { instance_double(Hash) }
+
+ subject do
+ described_class.new.execute(agent: agent, params: params)
+ end
+
+ context 'when params parse successfully' do
+ let(:parsed_params) { { some_param: 'some value' } }
+
+ before do
+ allow_next_instance_of(RemoteDevelopment::Workspaces::Reconcile::ParamsParser) do |parser|
+ allow(parser).to receive(:parse).with(params: params).and_return([parsed_params, nil])
+ end
+
+ allow_next_instance_of(RemoteDevelopment::Workspaces::Reconcile::ReconcileProcessor) do |processor|
+ allow(processor).to receive(:process) do |**args|
+ # rubocop:disable RSpec/ExpectInHook - not sure how to avoid this expectation without duplicating the block
+ expect(args).to eq(agent: agent, **parsed_params)
+ # rubocop:enable RSpec/ExpectInHook
+ end.and_return(processor_result)
+ end
+ end
+
+ context 'when reconciliation is successful' do
+ let(:payload) { instance_double(Hash) }
+ let(:processor_result) { [payload, nil] }
+
+ it 'returns a success ServiceResponse' do
+ expect(subject).to be_a(ServiceResponse)
+ expect(subject.payload).to eq(payload)
+ expect(subject.message).to be_nil
+ end
+ end
+
+ context 'when reconciliation processing fails' do
+ let(:message) { 'error message' }
+ let(:reason) { :bad_request }
+ let(:error) { RemoteDevelopment::Error.new(message: message, reason: reason) }
+ let(:processor_result) { [nil, error] }
+
+ it 'returns an error ServiceResponse' do
+ expect(subject).to be_a(ServiceResponse)
+ expect(subject.payload).to eq({}) # NOTE: A nil payload gets turned into an empty hash
+ expect(subject.message).to eq(message)
+ expect(subject.reason).to eq(reason)
+ end
+ end
+ end
+
+ context 'when params parsing fails' do
+ let(:message) { 'error message' }
+ let(:reason) { :unprocessable_entity }
+ let(:error) { RemoteDevelopment::Error.new(message: message, reason: reason) }
+
+ it 'returns an error ServiceResponse' do
+ allow_next_instance_of(RemoteDevelopment::Workspaces::Reconcile::ParamsParser) do |parser|
+ expect(parser).to receive(:parse).with(params: params).and_return([nil, error])
+ end
+ expect(subject).to be_a(ServiceResponse)
+ expect(subject.payload).to eq({}) # NOTE: A nil payload gets turned into an empty hash
+ expect(subject.message).to eq(message)
+ expect(subject.reason).to eq(reason)
+ end
+ end
+
+ context 'when there is an unexpected exception' do
+ let(:reason) { :internal_server_error }
+ let(:exception) { RuntimeError.new('unexpected error') }
+
+ it 'returns an error ServiceResponse' do
+ allow_next_instance_of(RemoteDevelopment::Workspaces::Reconcile::ParamsParser) do |parser|
+ expect(parser).to receive(:parse).and_raise(exception)
+ end
+
+ expect(Gitlab::ErrorTracking).to receive(:track_exception).with(exception,
+ error_type: 'reconcile',
+ agent_id: agent.id
+ )
+ expect(subject).to be_a(ServiceResponse)
+ expect(subject.payload).to eq({}) # NOTE: A nil payload gets turned into an empty hash
+ expect(subject.message).to match(/Unexpected reconcile error/)
+ expect(subject.reason).to eq(reason)
+ end
+ end
+ end
+end
diff --git a/ee/spec/services/remote_development/workspaces/update_service_spec.rb b/ee/spec/services/remote_development/workspaces/update_service_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..0c2cd8d61e63f8c317664d9ecc83253cce8e31c9
--- /dev/null
+++ b/ee/spec/services/remote_development/workspaces/update_service_spec.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe RemoteDevelopment::Workspaces::UpdateService, feature_category: :remote_development do
+ let(:workspace) { build_stubbed(:workspace) }
+ let(:user) { instance_double(User, can?: true) }
+ let(:params) { instance_double(Hash) }
+ let(:process_args) { { workspace: workspace, params: params } }
+
+ subject do
+ described_class.new(current_user: user).execute(workspace: workspace, params: params)
+ end
+
+ context 'when create is successful' do
+ let(:payload) { instance_double(Hash) }
+
+ it 'returns a success ServiceResponse' do
+ allow_next_instance_of(RemoteDevelopment::Workspaces::Update::UpdateProcessor) do |processor|
+ expect(processor).to receive(:process).with(process_args).and_return([payload, nil])
+ end
+ expect(subject).to be_a(ServiceResponse)
+ expect(subject.payload).to eq(payload)
+ expect(subject.message).to be_nil
+ end
+ end
+
+ context 'when user is not authorized' do
+ let(:user) { instance_double(User, can?: false) }
+
+ it 'returns an error ServiceResponse' do
+ # noinspection RubyResolve
+ expect(subject).to be_error
+ expect(subject.payload).to eq({})
+ expect(subject.message).to eq('Unauthorized')
+ expect(subject.reason).to eq(:unauthorized)
+ end
+ end
+
+ context 'when create fails' do
+ let(:message) { 'error message' }
+ let(:reason) { :bad_request }
+ let(:error) { RemoteDevelopment::Error.new(message: message, reason: reason) }
+
+ context 'when authorized' do
+ it 'returns an error ServiceResponse' do
+ allow_next_instance_of(RemoteDevelopment::Workspaces::Update::UpdateProcessor) do |processor|
+ expect(processor).to receive(:process).with(process_args).and_return([nil, error])
+ end
+ expect(subject).to be_a(ServiceResponse)
+ expect(subject.payload).to eq({}) # NOTE: A nil payload gets turned into an empty hash
+ expect(subject.message).to eq(message)
+ expect(subject.reason).to eq(reason)
+ end
+ end
+ end
+end
diff --git a/ee/spec/support/shared_contexts/remote_development/agent_info_status_fixture_not_implemented_error.rb b/ee/spec/support/shared_contexts/remote_development/agent_info_status_fixture_not_implemented_error.rb
new file mode 100644
index 0000000000000000000000000000000000000000..d712eb9b880929eab7f8d93c33a26c79a0cf8849
--- /dev/null
+++ b/ee/spec/support/shared_contexts/remote_development/agent_info_status_fixture_not_implemented_error.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+module RemoteDevelopment
+ # This exception indicates that the agent_info fixture STATUS_YAML
+ # generated by remote_development_shared_contexts.rb#create_workspace_agent_info for the given
+ # state transition has not yet been correctly implemented.
+ AgentInfoStatusFixtureNotImplementedError = Class.new(RuntimeError)
+end
diff --git a/ee/spec/support/shared_contexts/remote_development/remote_development_shared_contexts.rb b/ee/spec/support/shared_contexts/remote_development/remote_development_shared_contexts.rb
new file mode 100644
index 0000000000000000000000000000000000000000..69204c448b12ce2c193c9abbd603000b113a18df
--- /dev/null
+++ b/ee/spec/support/shared_contexts/remote_development/remote_development_shared_contexts.rb
@@ -0,0 +1,628 @@
+# frozen_string_literal: true
+
+RSpec.shared_context 'with remote development shared fixtures' do
+ # noinspection RubyDeadCode
+ # rubocop:disable Metrics/ParameterLists
+ def create_workspace_agent_info(
+ workspace_id:,
+ workspace_name:,
+ workspace_namespace:,
+ agent_id:,
+ owning_inventory:,
+ resource_version:,
+ # NOTE: previous_actual_state is the actual state of the workspace IMMEDIATELY prior to the current state. We don't
+ # simulate the situation where there may have been multiple transitions between reconciliation polling intervals.
+ previous_actual_state:,
+ current_actual_state:,
+ # NOTE: workspace_exists is whether the workspace exists in the cluster at the time of the current_actual_state.
+ workspace_exists:,
+ dns_zone: 'workspaces.localdev.me'
+ )
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/409787
+ # Default some of the parameters which can be derived from others: e.g. owning_inventory, workspace_namespace
+
+ info = {
+ 'name' => workspace_name,
+ 'namespace' => workspace_namespace
+ }
+
+ if current_actual_state == RemoteDevelopment::Workspaces::States::TERMINATED
+ info['termination_progress'] = RemoteDevelopment::Workspaces::Reconcile::ActualStateCalculator::TERMINATED
+ end
+
+ if current_actual_state == RemoteDevelopment::Workspaces::States::TERMINATING
+ info['termination_progress'] = RemoteDevelopment::Workspaces::Reconcile::ActualStateCalculator::TERMINATING
+ end
+
+ if [
+ RemoteDevelopment::Workspaces::States::TERMINATING,
+ RemoteDevelopment::Workspaces::States::TERMINATED,
+ RemoteDevelopment::Workspaces::States::UNKNOWN
+ ].include?(current_actual_state)
+ return info
+ end
+
+ spec_replicas = [ # rubocop:disable Style/MultilineTernaryOperator
+ RemoteDevelopment::Workspaces::States::STOPPED, RemoteDevelopment::Workspaces::States::STOPPING
+ ].include?(current_actual_state) ? 0 : 1
+ host_template = get_workspace_host_template(workspace_name, dns_zone)
+ root_url = Gitlab::Routing.url_helpers.root_url
+
+ # rubocop:disable Lint/UnreachableCode, Lint/DuplicateBranch
+ status =
+ case [previous_actual_state, current_actual_state, workspace_exists]
+ in [RemoteDevelopment::Workspaces::States::CREATION_REQUESTED, RemoteDevelopment::Workspaces::States::STARTING, _]
+ <<~STATUS_YAML
+ conditions:
+ - lastTransitionTime: "2023-04-10T10:14:14Z"
+ lastUpdateTime: "2023-04-10T10:14:14Z"
+ message: Created new replica set "#{workspace_name}-hash"
+ reason: NewReplicaSetCreated
+ status: "True"
+ type: Progressing
+ STATUS_YAML
+ in [RemoteDevelopment::Workspaces::States::STARTING, RemoteDevelopment::Workspaces::States::STARTING, false]
+ <<~STATUS_YAML
+ conditions:
+ - lastTransitionTime: "2023-04-10T10:14:14Z"
+ lastUpdateTime: "2023-04-10T10:14:14Z"
+ message: Deployment does not have minimum availability.
+ reason: MinimumReplicasUnavailable
+ status: "False"
+ type: Available
+ - lastTransitionTime: "2023-04-10T10:14:14Z"
+ lastUpdateTime: "2023-04-10T10:14:14Z"
+ message: ReplicaSet "#{workspace_name}-hash" is progressing.
+ reason: ReplicaSetUpdated
+ status: "True"
+ type: Progressing
+ observedGeneration: 1
+ replicas: 1
+ unavailableReplicas: 1
+ updatedReplicas: 1
+ STATUS_YAML
+ in [RemoteDevelopment::Workspaces::States::STARTING, RemoteDevelopment::Workspaces::States::RUNNING, false]
+ <<~STATUS_YAML
+ availableReplicas: 1
+ conditions:
+ - lastTransitionTime: "2023-03-06T14:36:36Z"
+ lastUpdateTime: "2023-03-06T14:36:36Z"
+ message: Deployment has minimum availability.
+ reason: MinimumReplicasAvailable
+ status: "True"
+ type: Available
+ - lastTransitionTime: "2023-03-06T14:36:31Z"
+ lastUpdateTime: "2023-03-06T14:36:36Z"
+ message: ReplicaSet "#{workspace_name}-hash" has successfully progressed.
+ reason: NewReplicaSetAvailable
+ status: "True"
+ type: Progressing
+ readyReplicas: 1
+ replicas: 1
+ updatedReplicas: 1
+ STATUS_YAML
+ in [RemoteDevelopment::Workspaces::States::STARTING, RemoteDevelopment::Workspaces::States::FAILED, false]
+ raise RemoteDevelopment::AgentInfoStatusFixtureNotImplementedError
+ in [RemoteDevelopment::Workspaces::States::FAILED, RemoteDevelopment::Workspaces::States::STARTING, false]
+ raise RemoteDevelopment::AgentInfoStatusFixtureNotImplementedError
+ in [RemoteDevelopment::Workspaces::States::RUNNING, RemoteDevelopment::Workspaces::States::FAILED, _]
+ raise RemoteDevelopment::AgentInfoStatusFixtureNotImplementedError
+ in [RemoteDevelopment::Workspaces::States::RUNNING, RemoteDevelopment::Workspaces::States::STOPPING, _]
+ <<~STATUS_YAML
+ availableReplicas: 1
+ conditions:
+ - lastTransitionTime: "2023-04-10T10:40:35Z"
+ lastUpdateTime: "2023-04-10T10:40:35Z"
+ message: Deployment has minimum availability.
+ reason: MinimumReplicasAvailable
+ status: "True"
+ type: Available
+ - lastTransitionTime: "2023-04-10T10:40:24Z"
+ lastUpdateTime: "2023-04-10T10:40:35Z"
+ message: ReplicaSet "#{workspace_name}-hash" has successfully progressed.
+ reason: NewReplicaSetAvailable
+ status: "True"
+ type: Progressing
+ observedGeneration: 1
+ readyReplicas: 1
+ replicas: 1
+ updatedReplicas: 1
+ STATUS_YAML
+ in [RemoteDevelopment::Workspaces::States::STOPPING, RemoteDevelopment::Workspaces::States::STOPPED, _]
+ <<~STATUS_YAML
+ conditions:
+ - lastTransitionTime: "2023-04-10T10:40:35Z"
+ lastUpdateTime: "2023-04-10T10:40:35Z"
+ message: Deployment has minimum availability.
+ reason: MinimumReplicasAvailable
+ status: "True"
+ type: Available
+ - lastTransitionTime: "2023-04-10T10:40:24Z"
+ lastUpdateTime: "2023-04-10T10:40:35Z"
+ message: ReplicaSet "#{workspace_name}-hash" has successfully progressed.
+ reason: NewReplicaSetAvailable
+ status: "True"
+ type: Progressing
+ observedGeneration: 2
+ STATUS_YAML
+ in [RemoteDevelopment::Workspaces::States::STOPPING, RemoteDevelopment::Workspaces::States::FAILED, _]
+ raise RemoteDevelopment::AgentInfoStatusFixtureNotImplementedError
+ in [RemoteDevelopment::Workspaces::States::STOPPED, RemoteDevelopment::Workspaces::States::STARTING, _]
+ # There are multiple state transitions inside kubernetes
+ # Fields like `replicas`, `unavailableReplicas` and `updatedReplicas` eventually become present
+ <<~STATUS_YAML
+ conditions:
+ - lastTransitionTime: "2023-04-10T10:40:24Z"
+ lastUpdateTime: "2023-04-10T10:40:35Z"
+ message: ReplicaSet "#{workspace_name}-hash" has successfully progressed.
+ reason: NewReplicaSetAvailable
+ status: "True"
+ type: Progressing
+ - lastTransitionTime: "2023-04-10T10:49:59Z"
+ lastUpdateTime: "2023-04-10T10:49:59Z"
+ message: Deployment does not have minimum availability.
+ reason: MinimumReplicasUnavailable
+ status: "False"
+ type: Available
+ observedGeneration: 3
+ STATUS_YAML
+ in [RemoteDevelopment::Workspaces::States::STOPPED, RemoteDevelopment::Workspaces::States::FAILED, _]
+ # Stopped workspace is terminated by the user which results in a Failed actual state.
+ # e.g. could not unmount volume and terminate the workspace
+ raise RemoteDevelopment::AgentInfoStatusFixtureNotImplementedError
+ in [RemoteDevelopment::Workspaces::States::STARTING, RemoteDevelopment::Workspaces::States::STARTING, true]
+ # There are multiple state transitions inside kubernetes
+ # Fields like `replicas`, `unavailableReplicas` and `updatedReplicas` eventually become present
+ <<~STATUS_YAML
+ conditions:
+ - lastTransitionTime: "2023-04-10T10:40:24Z"
+ lastUpdateTime: "2023-04-10T10:40:35Z"
+ message: ReplicaSet "#{workspace_name}-hash" has successfully progressed.
+ reason: NewReplicaSetAvailable
+ status: "True"
+ type: Progressing
+ - lastTransitionTime: "2023-04-10T10:49:59Z"
+ lastUpdateTime: "2023-04-10T10:49:59Z"
+ message: Deployment does not have minimum availability.
+ reason: MinimumReplicasUnavailable
+ status: "False"
+ type: Available
+ observedGeneration: 3
+ replicas: 1
+ unavailableReplicas: 1
+ updatedReplicas: 1
+ STATUS_YAML
+ in [RemoteDevelopment::Workspaces::States::STARTING, RemoteDevelopment::Workspaces::States::RUNNING, true]
+ <<~STATUS_YAML
+ availableReplicas: 1
+ conditions:
+ - lastTransitionTime: "2023-04-10T10:40:24Z"
+ lastUpdateTime: "2023-04-10T10:40:35Z"
+ message: ReplicaSet "#{workspace_name}-hash" has successfully progressed.
+ reason: NewReplicaSetAvailable
+ status: "True"
+ type: Progressing
+ - lastTransitionTime: "2023-04-10T10:50:10Z"
+ lastUpdateTime: "2023-04-10T10:50:10Z"
+ message: Deployment has minimum availability.
+ reason: MinimumReplicasAvailable
+ status: "True"
+ type: Available
+ observedGeneration: 3
+ readyReplicas: 1
+ replicas: 1
+ updatedReplicas: 1
+ STATUS_YAML
+ in [RemoteDevelopment::Workspaces::States::STARTING, RemoteDevelopment::Workspaces::States::FAILED, true]
+ raise RemoteDevelopment::AgentInfoStatusFixtureNotImplementedError
+ in [RemoteDevelopment::Workspaces::States::FAILED, RemoteDevelopment::Workspaces::States::STARTING, true]
+ raise RemoteDevelopment::AgentInfoStatusFixtureNotImplementedError
+ in [RemoteDevelopment::Workspaces::States::FAILED, RemoteDevelopment::Workspaces::States::STOPPING, _]
+ raise RemoteDevelopment::AgentInfoStatusFixtureNotImplementedError
+ in [_, RemoteDevelopment::Workspaces::States::FAILED, _]
+ raise RemoteDevelopment::AgentInfoStatusFixtureNotImplementedError
+ <<~STATUS_YAML
+ conditions:
+ - lastTransitionTime: "2023-03-06T14:36:31Z"
+ lastUpdateTime: "2023-03-08T11:16:35Z"
+ message: ReplicaSet "#{workspace_name}-hash" has successfully progressed.
+ reason: NewReplicaSetAvailable
+ status: "True"
+ type: Progressing
+ - lastTransitionTime: "2023-03-08T11:16:55Z"
+ lastUpdateTime: "2023-03-08T11:16:55Z"
+ message: Deployment does not have minimum availability.
+ reason: MinimumReplicasUnavailable
+ status: "False"
+ type: Available
+ replicas: 1
+ unavailableReplicas: 1
+ updatedReplicas: 1
+ STATUS_YAML
+ else
+ msg = 'Unsupported state transition passed for create_workspace_agent_info fixture creation: ' \
+ "actual_state: #{previous_actual_state} -> #{current_actual_state}, " \
+ "existing_workspace: #{workspace_exists}"
+ raise RemoteDevelopment::AgentInfoStatusFixtureNotImplementedError, msg
+ end
+ # rubocop:enable Lint/UnreachableCode, Lint/DuplicateBranch
+
+ latest_k8s_deployment_info = <<~RESOURCES_YAML
+ apiVersion: apps/v1
+ kind: Deployment
+ metadata:
+ annotations:
+ config.k8s.io/owning-inventory: #{owning_inventory}
+ workspaces.gitlab.com/host-template: #{host_template}
+ workspaces.gitlab.com/id: \'#{workspace_id}\'
+ creationTimestamp: null
+ labels:
+ agent.gitlab.com/id: \'#{agent_id}\'
+ name: #{workspace_name}
+ namespace: #{workspace_namespace}
+ resourceVersion: "#{resource_version}"
+ spec:
+ replicas: #{spec_replicas}
+ selector:
+ matchLabels:
+ agent.gitlab.com/id: \'#{agent_id}\'
+ strategy:
+ type: Recreate
+ template:
+ metadata:
+ annotations:
+ config.k8s.io/owning-inventory: #{owning_inventory}
+ workspaces.gitlab.com/host-template: #{host_template}
+ workspaces.gitlab.com/id: \'#{workspace_id}\'
+ creationTimestamp: null
+ labels:
+ agent.gitlab.com/id: \'#{agent_id}\'
+ name: #{workspace_name}
+ namespace: #{workspace_namespace}
+ spec:
+ containers:
+ - command:
+ - "/projects/.gl-editor/start_server.sh"
+ env:
+ - name: EDITOR_VOLUME_DIR
+ value: "/projects/.gl-editor"
+ - name: EDITOR_PORT
+ value: "60001"
+ - name: PROJECTS_ROOT
+ value: "/projects"
+ - name: PROJECT_SOURCE
+ value: "/projects"
+ image: quay.io/mloriedo/universal-developer-image:ubi8-dw-demo
+ imagePullPolicy: Always
+ name: tooling-container
+ ports:
+ - containerPort: 60001
+ name: editor-server
+ protocol: TCP
+ resources: {}
+ volumeMounts:
+ - mountPath: "/projects"
+ name: gl-workspace-data
+ securityContext:
+ allowPrivilegeEscalation: false
+ privileged: false
+ runAsNonRoot: true
+ runAsUser: 5001
+ initContainers:
+ - args:
+ - |-
+ if [ ! -d '/projects/test-project' ];
+ then
+ git clone --branch master #{root_url}test-group/test-project.git /projects/test-project;
+ fi
+ command: ["/bin/sh", "-c"]
+ env:
+ - name: PROJECTS_ROOT
+ value: "/projects"
+ - name: PROJECT_SOURCE
+ value: "/projects"
+ image: alpine/git:2.36.3
+ imagePullPolicy: Always
+ name: gl-cloner-injector-gl-cloner-injector-command-1
+ resources:
+ limits:
+ cpu: 500m
+ memory: 128Mi
+ requests:
+ cpu: 30m
+ memory: 32Mi
+ volumeMounts:
+ - mountPath: "/projects"
+ name: gl-workspace-data
+ securityContext:
+ allowPrivilegeEscalation: false
+ privileged: false
+ runAsNonRoot: true
+ runAsUser: 5001
+ - env:
+ - name: EDITOR_VOLUME_DIR
+ value: "/projects/.gl-editor"
+ - name: EDITOR_PORT
+ value: "60001"
+ - name: PROJECTS_ROOT
+ value: "/projects"
+ - name: PROJECT_SOURCE
+ value: "/projects"
+ image: registry.gitlab.com/gitlab-org/gitlab-web-ide-vscode-fork/web-ide-injector:1
+ imagePullPolicy: Always
+ name: gl-editor-injector-gl-editor-injector-command-2
+ resources:
+ limits:
+ cpu: 500m
+ memory: 128Mi
+ requests:
+ cpu: 30m
+ memory: 32Mi
+ volumeMounts:
+ - mountPath: "/projects"
+ name: gl-workspace-data
+ securityContext:
+ allowPrivilegeEscalation: false
+ privileged: false
+ runAsNonRoot: true
+ runAsUser: 5001
+ volumes:
+ - name: gl-workspace-data
+ persistentVolumeClaim:
+ claimName: #{workspace_name}-gl-workspace-data
+ securityContext:
+ runAsNonRoot: true
+ runAsUser: 5001
+ status:
+ #{status.indent(2)}
+ RESOURCES_YAML
+
+ info['latest_k8s_deployment_info'] = YAML.safe_load(latest_k8s_deployment_info)
+ info
+ end
+ # rubocop:enable Metrics/ParameterLists
+
+ def create_workspace_rails_info(
+ name:,
+ namespace:,
+ desired_state:,
+ actual_state:,
+ deployment_resource_version: nil,
+ config_to_apply: nil
+ )
+ {
+ name: name,
+ namespace: namespace,
+ desired_state: desired_state,
+ actual_state: actual_state,
+ deployment_resource_version: deployment_resource_version,
+ config_to_apply: config_to_apply
+ }.compact
+ end
+
+ def create_config_to_apply(
+ workspace_id:,
+ workspace_name:,
+ workspace_namespace:,
+ agent_id:,
+ owning_inventory:,
+ started:,
+ include_inventory: true,
+ dns_zone: 'workspaces.localdev.me'
+ )
+ spec_replicas = started == true ? "1" : "0"
+ host_template = get_workspace_host_template(workspace_name, dns_zone)
+ root_url = Gitlab::Routing.url_helpers.root_url
+ inventory_config = <<~RESOURCES_YAML
+ ---
+ kind: ConfigMap
+ apiVersion: v1
+ metadata:
+ name: #{owning_inventory}
+ namespace: #{workspace_namespace}
+ labels:
+ cli-utils.sigs.k8s.io/inventory-id: #{owning_inventory}
+ agent.gitlab.com/id: \'#{agent_id}\'
+ RESOURCES_YAML
+
+ resources = <<~RESOURCES_YAML
+ ---
+ apiVersion: apps/v1
+ kind: Deployment
+ metadata:
+ annotations:
+ config.k8s.io/owning-inventory: #{owning_inventory}
+ workspaces.gitlab.com/host-template: #{host_template}
+ workspaces.gitlab.com/id: \'#{workspace_id}\'
+ creationTimestamp: null
+ labels:
+ agent.gitlab.com/id: \'#{agent_id}\'
+ name: #{workspace_name}
+ namespace: #{workspace_namespace}
+ spec:
+ replicas: #{spec_replicas}
+ selector:
+ matchLabels:
+ agent.gitlab.com/id: \'#{agent_id}\'
+ strategy:
+ type: Recreate
+ template:
+ metadata:
+ annotations:
+ config.k8s.io/owning-inventory: #{owning_inventory}
+ workspaces.gitlab.com/host-template: #{host_template}
+ workspaces.gitlab.com/id: \'#{workspace_id}\'
+ creationTimestamp: null
+ labels:
+ agent.gitlab.com/id: \'#{agent_id}\'
+ name: #{workspace_name}
+ namespace: #{workspace_namespace}
+ spec:
+ containers:
+ - command:
+ - "/projects/.gl-editor/start_server.sh"
+ env:
+ - name: EDITOR_VOLUME_DIR
+ value: "/projects/.gl-editor"
+ - name: EDITOR_PORT
+ value: "60001"
+ - name: PROJECTS_ROOT
+ value: "/projects"
+ - name: PROJECT_SOURCE
+ value: "/projects"
+ image: quay.io/mloriedo/universal-developer-image:ubi8-dw-demo
+ imagePullPolicy: Always
+ name: tooling-container
+ ports:
+ - containerPort: 60001
+ name: editor-server
+ protocol: TCP
+ resources: {}
+ volumeMounts:
+ - mountPath: "/projects"
+ name: gl-workspace-data
+ securityContext:
+ allowPrivilegeEscalation: false
+ privileged: false
+ runAsNonRoot: true
+ runAsUser: 5001
+ initContainers:
+ - args:
+ - |-
+ if [ ! -d '/projects/test-project' ];
+ then
+ git clone --branch master #{root_url}test-group/test-project.git /projects/test-project;
+ fi
+ command: ["/bin/sh", "-c"]
+ env:
+ - name: PROJECTS_ROOT
+ value: "/projects"
+ - name: PROJECT_SOURCE
+ value: "/projects"
+ image: alpine/git:2.36.3
+ imagePullPolicy: Always
+ name: gl-cloner-injector-gl-cloner-injector-command-1
+ resources:
+ limits:
+ cpu: 500m
+ memory: 128Mi
+ requests:
+ cpu: 30m
+ memory: 32Mi
+ volumeMounts:
+ - mountPath: "/projects"
+ name: gl-workspace-data
+ securityContext:
+ allowPrivilegeEscalation: false
+ privileged: false
+ runAsNonRoot: true
+ runAsUser: 5001
+ - env:
+ - name: EDITOR_VOLUME_DIR
+ value: "/projects/.gl-editor"
+ - name: EDITOR_PORT
+ value: "60001"
+ - name: PROJECTS_ROOT
+ value: "/projects"
+ - name: PROJECT_SOURCE
+ value: "/projects"
+ image: registry.gitlab.com/gitlab-org/gitlab-web-ide-vscode-fork/web-ide-injector:1
+ imagePullPolicy: Always
+ name: gl-editor-injector-gl-editor-injector-command-2
+ resources:
+ limits:
+ cpu: 500m
+ memory: 128Mi
+ requests:
+ cpu: 30m
+ memory: 32Mi
+ volumeMounts:
+ - mountPath: "/projects"
+ name: gl-workspace-data
+ securityContext:
+ allowPrivilegeEscalation: false
+ privileged: false
+ runAsNonRoot: true
+ runAsUser: 5001
+ volumes:
+ - name: gl-workspace-data
+ persistentVolumeClaim:
+ claimName: #{workspace_name}-gl-workspace-data
+ securityContext:
+ runAsNonRoot: true
+ runAsUser: 5001
+ status: {}
+ ---
+ apiVersion: v1
+ kind: Service
+ metadata:
+ annotations:
+ config.k8s.io/owning-inventory: #{owning_inventory}
+ workspaces.gitlab.com/host-template: #{host_template}
+ workspaces.gitlab.com/id: \'#{workspace_id}\'
+ creationTimestamp: null
+ labels:
+ agent.gitlab.com/id: \'#{agent_id}\'
+ name: #{workspace_name}
+ namespace: #{workspace_namespace}
+ spec:
+ ports:
+ - name: editor-server
+ port: 60001
+ targetPort: 60001
+ selector:
+ agent.gitlab.com/id: \'#{agent_id}\'
+ status:
+ loadBalancer: {}
+ ---
+ apiVersion: v1
+ kind: PersistentVolumeClaim
+ metadata:
+ annotations:
+ config.k8s.io/owning-inventory: #{owning_inventory}
+ workspaces.gitlab.com/host-template: #{host_template}
+ workspaces.gitlab.com/id: \'#{workspace_id}\'
+ creationTimestamp:
+ labels:
+ agent.gitlab.com/id: \'#{agent_id}\'
+ name: #{workspace_name}-gl-workspace-data
+ namespace: #{workspace_namespace}
+ spec:
+ accessModes:
+ - ReadWriteOnce
+ resources:
+ requests:
+ storage: 15Gi
+ status: {}
+ RESOURCES_YAML
+
+ unless include_inventory
+ return YAML.load_stream(resources).map do |resource|
+ YAML.dump(resource)
+ end.join
+ end
+
+ YAML.load_stream(inventory_config + resources).map do |resource|
+ YAML.dump(resource)
+ end.join
+ end
+
+ def get_workspace_host_template(workspace_name, dns_zone)
+ "\"{{.port}}-#{workspace_name}.#{dns_zone}\""
+ end
+
+ def example_devfile
+ read_devfile('example.devfile.yaml')
+ end
+
+ def example_processed_devfile
+ devfile_contents = read_devfile('example.processed-devfile.yaml')
+ devfile_contents.gsub!('http://localhost/', Gitlab::Routing.url_helpers.root_url)
+ devfile_contents
+ end
+
+ def read_devfile(filename)
+ # noinspection RubyMismatchedArgumentType
+ File.read(Rails.root.join('ee/spec/fixtures/remote_development', filename))
+ end
+end
diff --git a/lib/api/internal/kubernetes.rb b/lib/api/internal/kubernetes.rb
index 22a26a725e9fa91b5c4545d653af6315505f18b6..d340e097700d93bde5252b14bfdb27f2c57be5dd 100644
--- a/lib/api/internal/kubernetes.rb
+++ b/lib/api/internal/kubernetes.rb
@@ -73,6 +73,11 @@ def increment_count_events
Gitlab::UsageDataCounters::KubernetesAgentCounter.increment_event_counts(events)
end
+
+ def update_configuration(agent:, config:)
+ ::Clusters::Agents::Authorizations::CiAccess::RefreshService.new(agent, config: config).execute
+ ::Clusters::Agents::Authorizations::UserAccess::RefreshService.new(agent, config: config).execute
+ end
end
namespace 'internal' do
@@ -128,9 +133,7 @@ def increment_count_events
end
post '/', feature_category: :deployment_management, urgency: :low do
agent = ::Clusters::Agent.find(params[:agent_id])
-
- ::Clusters::Agents::Authorizations::CiAccess::RefreshService.new(agent, config: params[:agent_config]).execute
- ::Clusters::Agents::Authorizations::UserAccess::RefreshService.new(agent, config: params[:agent_config]).execute
+ update_configuration(agent: agent, config: params[:agent_config])
no_content!
end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 85f282650940408f18f97a292540b4fc9fcabe55..d8e8dd1dd9a0cdabb2a71ca2264976b08c689665 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -23647,6 +23647,9 @@ msgstr ""
msgid "Inherited:"
msgstr ""
+msgid "Inheriting from parent is not yet supported"
+msgstr ""
+
msgid "Initial default branch name"
msgstr ""
@@ -29681,6 +29684,12 @@ msgstr ""
msgid "No committers"
msgstr ""
+msgid "No component has 'gl/inject-editor' attribute"
+msgstr ""
+
+msgid "No components present in the devfile"
+msgstr ""
+
msgid "No confirmation email received? Check your spam folder or %{request_link_start}request new confirmation email%{request_link_end}."
msgstr ""
@@ -53026,6 +53035,12 @@ msgstr ""
msgid "for %{ref}"
msgstr ""
+msgid "for Workspace is required to be public"
+msgstr ""
+
+msgid "for Workspace must have an associated RemoteDevelopmentAgentConfig"
+msgstr ""
+
msgid "for this project"
msgstr ""
@@ -53173,6 +53188,9 @@ msgstr ""
msgid "is blocked by"
msgstr ""
+msgid "is currently immutable, and cannot be updated. Create a new agent instead."
+msgstr ""
+
msgid "is forbidden by a top-level group"
msgstr ""
diff --git a/qa/qa/ee/flow/workspace.rb b/qa/qa/ee/flow/workspace.rb
new file mode 100644
index 0000000000000000000000000000000000000000..b873b700fdf8882f22faec3d8549512a9baf0ac3
--- /dev/null
+++ b/qa/qa/ee/flow/workspace.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+module QA
+ module EE
+ module Flow
+ module Workspace
+ extend self
+
+ def create_workspace(group, agent, state, editor, project)
+ group.visit!
+
+ QA::Page::Group::Menu.perform(&:go_to_workspaces)
+
+ QA::EE::Page::Workspace::Index.perform(&:click_new_workspace)
+
+ QA::EE::Page::Workspace::New.perform do |p|
+ p.select_cluster_agent(agent)
+ p.select_desired_state(state)
+ p.set_editor(editor)
+ p.select_devfile_project(project)
+
+ p.confirm_workspace_creation
+ end
+ end
+
+ def get_active_workspaces(group)
+ group.visit!
+
+ QA::Page::Group::Menu.perform(&:go_to_workspaces)
+
+ QA::EE::Page::Workspace::Index.perform(&:get_active_workspaces)
+ end
+
+ def terminate_workspace(group, workspace)
+ group.visit!
+
+ QA::Page::Group::Menu.perform(&:go_to_workspaces)
+
+ QA::EE::Page::Workspace::Index.perform do |index|
+ index.click_edit_button(workspace)
+ end
+
+ QA::EE::Page::Workspace::Edit.perform do |edit|
+ edit.select_desired_state("Terminated")
+ end
+
+ QA::EE::Page::Workspace::Edit.perform(&:save_workspace_changes)
+
+ # wait until workspace disappears from Active workspaces tab
+ QA::Support::Retrier.retry_until(sleep_interval: 5, max_attempts: 30) do
+ active_workspaces = get_active_workspaces(group)
+ active_workspaces.exclude? workspace
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/ee/page/workspace/edit.rb b/qa/qa/ee/page/workspace/edit.rb
new file mode 100644
index 0000000000000000000000000000000000000000..60fce2fd4f66a85a62fe472652adec9cb25123ef
--- /dev/null
+++ b/qa/qa/ee/page/workspace/edit.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+module QA
+ module EE
+ module Page
+ module Workspace
+ class Edit < QA::Page::Base
+ # TODO: This needs to be update for the new UI. This view is a placeholder value added to make
+ # bundle exec bin/qa Test::Sanity::Selectors pass when the old UI was deleted.
+ view 'ee/app/assets/javascripts/remote_development/pages/app.vue' do
+ # element :workspace_cluster_agent_id_field
+ # element :workspace_desired_state_field
+ # element :workspace_editor_field
+ # element :workspace_devfile_project_id_field
+ # element :save_workspace_button
+ end
+
+ def select_desired_state(desired_state)
+ state_selector = find_element(:workspace_desired_state_field)
+ options = state_selector.all('option')
+
+ raise "No desiredState available" if options.empty?
+ raise "No matching desiredState found" if options.none? { |option| option.text == desired_state }
+
+ state_selector.select desired_state
+ end
+
+ def save_workspace_changes
+ click_element(:save_workspace_button)
+
+ Support::Retrier.retry_until(sleep_interval: 1, max_attempts: 10) do
+ !has_element?(:save_workspace_button, wait: 0)
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/ee/page/workspace/index.rb b/qa/qa/ee/page/workspace/index.rb
new file mode 100644
index 0000000000000000000000000000000000000000..0b4b1bc95c8eafeacff3e0d94c0ac00ba19f00c6
--- /dev/null
+++ b/qa/qa/ee/page/workspace/index.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+module QA
+ module EE
+ module Page
+ module Workspace
+ class Index < QA::Page::Base
+ # TODO: This needs to be update for the new UI. This view is a placeholder value added to make
+ # bundle exec bin/qa Test::Sanity::Selectors pass when the old UI was deleted.
+ view 'ee/app/assets/javascripts/remote_development/pages/app.vue' do
+ # element :new_workspace_button
+ # element :workspace_data_loader
+ end
+
+ def click_new_workspace
+ Support::Retrier.retry_until do
+ click_element(:new_workspace_button)
+ !has_element?(:new_workspace_button, wait: 0)
+ end
+ end
+
+ def click_edit_button(workspace)
+ edit_link = "workspace_#{workspace}_edit_link".to_sym
+ click_element(edit_link)
+ end
+
+ def get_active_workspaces
+ Support::Retrier.retry_until(sleep_interval: 5, max_attempts: 30) do
+ all_elements(:workspace_data_loader, minimum: 0).empty?
+ end
+
+ all_elements(:active_workspace_name, minimum: 0).map(&:text)
+ end
+
+ def get_actual_state_of_workspace(workspace)
+ element = "#{workspace}_actual_state".to_sym
+ find_element(element).text
+ end
+
+ def expect_workspace_to_have_state(workspace, state)
+ QA::Support::Retrier.retry_until(sleep_interval: 5, max_attempts: 30) do
+ get_actual_state_of_workspace(workspace) == state
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/ee/page/workspace/new.rb b/qa/qa/ee/page/workspace/new.rb
new file mode 100644
index 0000000000000000000000000000000000000000..e4b5b867337e2535b0a770ecbdaa1fddafd6272a
--- /dev/null
+++ b/qa/qa/ee/page/workspace/new.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+module QA
+ module EE
+ module Page
+ module Workspace
+ class New < QA::Page::Base
+ # TODO: This needs to be update for the new UI. This view is a placeholder value added to make
+ # bundle exec bin/qa Test::Sanity::Selectors pass when the old UI was deleted.
+ view 'ee/app/assets/javascripts/remote_development/pages/app.vue' do
+ # element :workspace_cluster_agent_id_field
+ # element :workspace_desired_state_field
+ # element :workspace_editor_field
+ # element :workspace_devfile_project_id_field
+ # element :save_workspace_button
+ end
+
+ def select_cluster_agent(agent)
+ agent_selector = find_element(:workspace_cluster_agent_id_field)
+ options = agent_selector.all('option')
+
+ raise "No agent available" if options.empty?
+ raise "No matching agent found" if options.none? { |option| option.text == agent }
+
+ agent_selector.select agent
+ end
+
+ def select_desired_state(desired_state)
+ state_selector = find_element(:workspace_desired_state_field)
+ options = state_selector.all('option')
+
+ raise "No desiredState available" if options.empty?
+ raise "No matching desiredState found" if options.none? { |option| option.text == desired_state }
+
+ state_selector.select desired_state
+ end
+
+ def select_devfile_project(project)
+ project_selector = find_element(:workspace_devfile_project_id_field)
+ options = project_selector.all('option')
+
+ raise "No devfile projects available" if options.empty?
+ raise "No matching devfile project found" if options.none? { |option| option.text == project }
+
+ project_selector.select project
+ end
+
+ def set_editor(editor)
+ fill_element(:workspace_editor_field, editor)
+ end
+
+ def confirm_workspace_creation
+ click_element(:save_workspace_button)
+
+ Support::Retrier.retry_until(sleep_interval: 1, max_attempts: 10) do
+ !has_element?(:save_workspace_button, wait: 0)
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/flow/project.rb b/qa/qa/flow/project.rb
index 70bdcfcb7195405797dcd0eabcf4e4261a439c29..20c1fa77e244853a24e2e6b762b6c91abc0ca73a 100644
--- a/qa/qa/flow/project.rb
+++ b/qa/qa/flow/project.rb
@@ -8,6 +8,17 @@ module Project
def go_to_create_project_from_template
Page::Project::New.perform(&:click_create_from_template_link)
end
+
+ def archive_project(project)
+ project.visit!
+
+ Page::Project::Menu.perform(&:go_to_general_settings)
+ Page::Project::Settings::Main.perform(&:expand_advanced_settings)
+ Page::Project::Settings::Advanced.perform(&:archive_project)
+ Support::Waiter.wait_until do
+ Page::Project::Show.perform { |show| show.has_text?("Archived project!") }
+ end
+ end
end
end
end
diff --git a/qa/qa/page/group/menu.rb b/qa/qa/page/group/menu.rb
index 157bc3abaf687ebe2c4b59fc2495266857d0d7b8..752fa37094ff87841feebf280eedeb22665af9eb 100644
--- a/qa/qa/page/group/menu.rb
+++ b/qa/qa/page/group/menu.rb
@@ -107,6 +107,12 @@ def go_to_access_token_settings
end
end
+ def go_to_workspaces
+ within_sidebar do
+ click_element(:sidebar_menu_link, menu_item: "Workspaces")
+ end
+ end
+
private
def hover_settings
diff --git a/qa/qa/specs/features/ee/browser_ui/3_create/remote_development/create_workspace_spec.rb b/qa/qa/specs/features/ee/browser_ui/3_create/remote_development/create_workspace_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..d5518d2793c93f2340ac7b52891305b7a09beaf0
--- /dev/null
+++ b/qa/qa/specs/features/ee/browser_ui/3_create/remote_development/create_workspace_spec.rb
@@ -0,0 +1,109 @@
+# frozen_string_literal: true
+
+# What does this test do
+#
+# This is an e2e test that carries out all the operations necessary to create a new workspace from scratch.
+# In order to iterate upon this spec quickly, this first version doesnt manage/orchestrate KAS / agentk
+# / gitlab but expects them to be up and running.
+#
+# How to setup the test
+#
+# 1. Ensure gitlab is up and running with default KAS / agentk stopped
+# 2. Setup agentk and start agentk with the token received \
+# 3. Call the helper scripts at `scripts/remote_development/run_e2e_spec.sh`
+
+module QA
+ RSpec.describe 'Create',
+ quarantine: {
+ type: :waiting_on,
+ issue: 'https://gitlab.com/gitlab-org/gitlab/-/issues/389342'
+ }, product_group: :editor do
+ describe 'Remote Development' do
+ let(:group) do
+ QA::Resource::Sandbox.fabricate! do |sandbox|
+ sandbox.path = ENV.fetch("AGENTK_GROUP", "gitlab-org")
+ end
+ end
+
+ let(:new_workspace) do
+ {
+ desired_state: "Running",
+ editor: "webide",
+ cluster_agent: ENV.fetch("AGENTK_NAME", "test-agent")
+ }
+ end
+
+ let(:devfile_project) do
+ project = Resource::Project.fabricate_via_api! do |project|
+ project.name = 'devfile-project'
+ project.description = 'Project with a valid devfile'
+ project.group = group
+ end
+
+ # TODO re-use the devfile created in other remote dev specs
+ devfile_content = <<~YAML
+ schemaVersion: 2.2.0
+ components:
+ - name: tooling-container
+ attributes:
+ gl/inject-editor: true
+ container:
+ image: quay.io/mloriedo/universal-developer-image:ubi8-dw-demo
+ YAML
+
+ Resource::Repository::Commit.fabricate_via_api! do |commit|
+ commit.project = project
+ commit.add_files([{ file_path: '.devfile.yaml', content: devfile_content }])
+ end
+
+ project
+ end
+
+ before do
+ Flow::Login.sign_in
+ end
+
+ after do
+ unless new_workspace[:name].nil?
+ EE::Flow::Workspace.terminate_workspace(
+ group,
+ new_workspace[:name]
+ )
+ end
+
+ Flow::Project.archive_project(devfile_project) unless devfile_project.nil?
+ end
+
+ it 'creates a new workspace using a devfile from a project',
+ testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/396854' do
+ existing_active_workspaces = EE::Flow::Workspace.get_active_workspaces(group)
+
+ EE::Flow::Workspace.create_workspace(
+ group,
+ new_workspace[:cluster_agent],
+ new_workspace[:desired_state],
+ new_workspace[:editor],
+ devfile_project.name
+ )
+
+ updated_active_workspaces = []
+
+ # retry until the newly created workspace entry is listed in the workspace index page
+ QA::Support::Retrier.retry_until(sleep_interval: 1, max_attempts: 30) do
+ updated_active_workspaces = EE::Flow::Workspace.get_active_workspaces(group)
+
+ (updated_active_workspaces - existing_active_workspaces).length == 1
+ end
+
+ new_workspace[:name] = (updated_active_workspaces - existing_active_workspaces)[0]
+
+ QA::EE::Page::Workspace::Index.perform do |index|
+ index.expect_workspace_to_have_state(
+ new_workspace[:name],
+ new_workspace[:desired_state]
+ )
+ end
+ end
+ end
+ end
+end
diff --git a/spec/graphql/types/user_type_spec.rb b/spec/graphql/types/user_type_spec.rb
index 7a0b6c90ace73a8235c458b8d28ef42fa39640bd..0b0dcf2fb6aa1ea1490808ac87b276d9999f8d0c 100644
--- a/spec/graphql/types/user_type_spec.rb
+++ b/spec/graphql/types/user_type_spec.rb
@@ -50,7 +50,7 @@
user_achievements
]
- expect(described_class).to have_graphql_fields(*expected_fields)
+ expect(described_class).to include_graphql_fields(*expected_fields)
end
describe 'name field' do
diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml
index e6f281ae35b541f26b17e03c56b6dbeb070fe5d2..8a2602ea9f6296dbc613bb239c5d3bbedd778116 100644
--- a/spec/lib/gitlab/import_export/all_models.yml
+++ b/spec/lib/gitlab/import_export/all_models.yml
@@ -515,6 +515,7 @@ project:
- cluster_agents
- ci_access_project_authorizations
- cluster_project
+- workspaces
- creator
- cycle_analytics_stages
- value_streams
diff --git a/spec/requests/api/graphql/user_spec.rb b/spec/requests/api/graphql/user_spec.rb
index c19dfa6f3f3268359deecd8797901e4a797eea99..f881935c052944b3fa439a64f45dd1f08a38d43f 100644
--- a/spec/requests/api/graphql/user_spec.rb
+++ b/spec/requests/api/graphql/user_spec.rb
@@ -10,6 +10,12 @@
shared_examples 'a working user query' do
it_behaves_like 'a working graphql query' do
before do
+ # TODO: This license stub is necessary because the remote development workspaces field
+ # defined in the EE version of UserInterface gets picked up here and thus the license
+ # check happens. This comes from the `ancestors` call in
+ # lib/graphql/schema/member/has_fields.rb#fields in the graphql library.
+ stub_licensed_features(remote_development: true)
+
post_graphql(query, current_user: current_user)
end
end
diff --git a/spec/support/helpers/graphql_helpers.rb b/spec/support/helpers/graphql_helpers.rb
index a55027d3976fbc479a33a30940dc301c9a479377..a9ad853b028047e103b3cc2e0f49c5c91abec02d 100644
--- a/spec/support/helpers/graphql_helpers.rb
+++ b/spec/support/helpers/graphql_helpers.rb
@@ -639,7 +639,11 @@ def expect_graphql_errors_to_include(regexes_to_match)
end
def expect_graphql_errors_to_be_empty
- expect(flattened_errors).to be_empty
+ # TODO: using eq([]) instead of be_empty makes it print out the full error message including the
+ # raisedAt key which contains the full stacktrace. This is necessary to know where the
+ # unexpected error occurred during tests.
+ # This or an equivalent fix should be added in a separate MR on master.
+ expect(flattened_errors).to eq([])
end
# Helps migrate to the new GraphQL interpreter,
diff --git a/spec/support/shared_examples/graphql/types/merge_request_interactions_type_shared_examples.rb b/spec/support/shared_examples/graphql/types/merge_request_interactions_type_shared_examples.rb
index ed193d06161c4a674a332482c2d6966ebe5f2f33..3dffc2066aed4d87fd9f02b763f96f63b3dee0b9 100644
--- a/spec/support/shared_examples/graphql/types/merge_request_interactions_type_shared_examples.rb
+++ b/spec/support/shared_examples/graphql/types/merge_request_interactions_type_shared_examples.rb
@@ -45,6 +45,10 @@
user_achievements
]
+ # TODO: 'workspaces' needs to be included, but only when this spec is run in EE context, to account for the
+ # ee-only extension in ee/app/graphql/ee/types/user_interface.rb. Not sure how else to handle this.
+ expected_fields << 'workspaces' if Gitlab.ee?
+
expect(described_class).to have_graphql_fields(*expected_fields)
end