From a33e033155a926aae0a8c33be52a246792b330df Mon Sep 17 00:00:00 2001 From: Chad Woolley Date: Thu, 1 Dec 2022 23:19:46 -0800 Subject: [PATCH 1/3] Remote Development - The initial release of the Remote Development feature - https://gitlab.com/gitlab-org/gitlab/-/merge_requests/105783 - See https://gitlab.com/gitlab-org/remote-development/gitlab-remote-development-docs/-/blob/main/doc/local-development-environment-setup.md Changelog: added --- Gemfile | 3 + Gemfile.checksum | 3 + Gemfile.lock | 2 + app/graphql/types/user_interface.rb | 2 + db/docs/remote_development_agent_configs.yml | 10 + db/docs/verification_codes.yml | 3 +- db/docs/workspaces.yml | 10 + .../20221225010101_create_workspaces_table.rb | 44 ++ ...0102_create_workspaces_user_foreign_key.rb | 18 + ...3_create_workspaces_project_foreign_key.rb | 18 + ...te_workspaces_cluster_agent_foreign_key.rb | 18 + ..._remote_development_agent_configs_table.rb | 16 + ...elopment_agent_config_agent_foreign_key.rb | 16 + db/schema_migrations/20221225010101 | 1 + db/schema_migrations/20221225010102 | 1 + db/schema_migrations/20221225010103 | 1 + db/schema_migrations/20221225010104 | 1 + db/schema_migrations/20221225010105 | 1 + db/schema_migrations/20221225010106 | 1 + db/structure.sql | 157 ++++- doc/api/graphql/reference/index.md | 243 +++++++ .../create/search_projects_listbox.vue | 5 +- .../list/workspace_state_indicator.vue | 1 + .../workspace_create.mutation.graphql | 2 +- .../workspace_update.mutation.graphql | 4 +- .../user_workspaces_list.query.graphql | 2 +- .../remote_development/init_workspaces_app.js | 73 +- .../remote_development/pages/create.vue | 1 - .../services/apollo_cache_mutators.js | 22 +- .../remote_development/workspaces_finder.rb | 29 + ee/app/graphql/ee/types/mutation_type.rb | 2 + ee/app/graphql/ee/types/query_type.rb | 35 +- ee/app/graphql/ee/types/user_interface.rb | 15 + .../remote_development/workspaces/create.rb | 83 +++ .../remote_development/workspaces/update.rb | 56 ++ .../remote_development/workspaces_resolver.rb | 34 + .../remote_development/workspace_type.rb | 80 +++ ee/app/models/ee/clusters/agent.rb | 10 + ee/app/models/ee/project.rb | 2 + ee/app/models/ee/user.rb | 2 + .../remote_development_agent_config.rb | 25 + ee/app/models/remote_development/workspace.rb | 76 +++ ee/app/policies/ee/global_policy.rb | 4 +- ee/app/policies/ee/project_policy.rb | 1 + .../remote_development/workspace_policy.rb | 19 + .../agent_config/update_service.rb | 40 ++ .../workspaces/create_service.rb | 48 ++ .../workspaces/reconcile_service.rb | 68 ++ .../workspaces/update_service.rb | 45 ++ .../remote_development_feature_flag.yml | 2 +- ee/lib/ee/api/internal/kubernetes.rb | 40 ++ .../agent_config/update_processor.rb | 27 + ee/lib/remote_development/error.rb | 12 + ee/lib/remote_development/logger.rb | 9 + .../workspaces/create/create_processor.rb | 84 +++ .../workspaces/create/devfile_processor.rb | 209 ++++++ .../workspaces/create/devfile_validator.rb | 166 +++++ .../reconcile/actual_state_calculator.rb | 180 +++++ .../workspaces/reconcile/agent_info.rb | 27 + .../workspaces/reconcile/agent_info_parser.rb | 43 ++ .../reconcile/desired_config_generator.rb | 86 +++ .../workspaces/reconcile/devfile_parser.rb | 69 ++ .../workspaces/reconcile/params_parser.rb | 57 ++ .../reconcile/reconcile_processor.rb | 208 ++++++ .../workspaces/reconcile/update_type.rb | 12 + .../remote_development/workspaces/states.rb | 48 ++ .../workspaces/update/update_processor.rb | 20 + ee/spec/factories/clusters/agents.rb | 9 + .../remote_development_agent_configs.rb | 10 + .../remote_development/workspaces.rb | 102 +++ .../remote_development/workspaces_spec.rb | 188 ++++++ .../workspaces_finder_spec.rb | 64 ++ .../remote_development/example.devfile.yaml | 8 + ...ibutes-editor-injector-absent-devfile.yaml | 6 + ...utes-editor-injector-multiple-devfile.yaml | 13 + ...example.invalid-no-components-devfile.yaml | 2 + ...-command-apply-component-name-devfile.yaml | 12 + ...x-command-exec-component-name-devfile.yaml | 14 + ...estricted-prefix-command-name-devfile.yaml | 14 + ...onent-container-endpoint-name-devfile.yaml | 13 + ...tricted-prefix-component-name-devfile.yaml | 8 + ...fix-event-type-poststart-name-devfile.yaml | 18 + ...efix-event-type-prestart-name-devfile.yaml | 18 + ...refix-event-type-prestop-name-devfile.yaml | 18 + ...stricted-prefix-variable-name-devfile.yaml | 10 + ...variable-name-with-underscore-devfile.yaml | 10 + ...upported-component-type-image-devfile.yaml | 15 + ...ted-component-type-kubernetes-devfile.yaml | 20 + ...rted-component-type-openshift-devfile.yaml | 20 + ...nsupported-parent-inheritance-devfile.yaml | 11 + .../example.processed-devfile.yaml | 72 ++ .../components/list/workspaces_table_spec.js | 26 +- .../remote_development/mock_data/index.js | 4 +- .../remote_development/pages/create_spec.js | 60 +- ee/spec/graphql/api/workspace_spec.rb | 43 ++ ee/spec/graphql/types/query_type_spec.rb | 4 +- .../remote_development/workspace_type_spec.rb | 19 + ee/spec/graphql/types/user_type_spec.rb | 13 + .../agent_config/update_processor_spec.rb | 72 ++ .../create/create_processor_spec.rb | 124 ++++ .../create/devfile_processor_spec.rb | 41 ++ .../create/devfile_validator_spec.rb | 55 ++ .../reconcile/actual_state_calculator_spec.rb | 481 ++++++++++++++ .../reconcile/agent_info_parser_spec.rb | 86 +++ .../workspaces/reconcile/agent_info_spec.rb | 35 + .../desired_config_generator_spec.rb | 61 ++ .../reconcile/devfile_parser_spec.rb | 53 ++ .../reconcile/params_parser_spec.rb | 80 +++ .../reconcile_processor_scenarios_spec.rb | 240 +++++++ .../reconcile/reconcile_processor_spec.rb | 461 +++++++++++++ .../workspaces/states_spec.rb | 27 + .../update/update_processor_spec.rb | 47 ++ ee/spec/models/factories_spec.rb | 1 + .../remote_development_agent_config_spec.rb | 32 + .../remote_development/workspace_spec.rb | 125 ++++ ee/spec/policies/project_policy_spec.rb | 14 + .../workspace_policy_spec.rb | 49 ++ .../workspaces/create_spec.rb | 118 ++++ .../workspaces/update_spec.rb | 107 +++ .../current_user_workspaces_spec.rb | 67 ++ .../workspace_by_id_spec.rb | 71 ++ .../workspaces_by_ids_spec.rb | 79 +++ .../requests/api/internal/kubernetes_spec.rb | 134 ++++ .../agent_config/update_service_spec.rb | 36 + .../workspaces/create_service_spec.rb | 58 ++ .../workspaces/reconcile_service_spec.rb | 93 +++ .../workspaces/update_service_spec.rb | 57 ++ ...fo_status_fixture_not_implemented_error.rb | 8 + .../remote_development_shared_contexts.rb | 628 ++++++++++++++++++ lib/api/internal/kubernetes.rb | 9 +- locale/gitlab.pot | 18 + qa/qa/ee/flow/workspace.rb | 58 ++ qa/qa/ee/page/workspace/edit.rb | 39 ++ qa/qa/ee/page/workspace/index.rb | 49 ++ qa/qa/ee/page/workspace/new.rb | 63 ++ qa/qa/flow/project.rb | 11 + qa/qa/page/group/menu.rb | 6 + .../create_workspace_spec.rb | 109 +++ .../reset-remote-dev-db-tables.sh | 24 + scripts/remote_development/run-e2e-spec.sh | 34 + .../run-remote-development-specs.sh | 67 ++ spec/graphql/types/user_type_spec.rb | 2 +- spec/lib/gitlab/import_export/all_models.yml | 1 + spec/requests/api/graphql/user_spec.rb | 6 + spec/support/helpers/graphql_helpers.rb | 6 +- ...quest_interactions_type_shared_examples.rb | 4 + 146 files changed, 7366 insertions(+), 176 deletions(-) create mode 100644 db/docs/remote_development_agent_configs.yml create mode 100644 db/docs/workspaces.yml create mode 100644 db/migrate/20221225010101_create_workspaces_table.rb create mode 100644 db/migrate/20221225010102_create_workspaces_user_foreign_key.rb create mode 100644 db/migrate/20221225010103_create_workspaces_project_foreign_key.rb create mode 100644 db/migrate/20221225010104_create_workspaces_cluster_agent_foreign_key.rb create mode 100644 db/migrate/20221225010105_create_remote_development_agent_configs_table.rb create mode 100644 db/migrate/20221225010106_create_remote_development_agent_config_agent_foreign_key.rb create mode 100644 db/schema_migrations/20221225010101 create mode 100644 db/schema_migrations/20221225010102 create mode 100644 db/schema_migrations/20221225010103 create mode 100644 db/schema_migrations/20221225010104 create mode 100644 db/schema_migrations/20221225010105 create mode 100644 db/schema_migrations/20221225010106 create mode 100644 ee/app/finders/remote_development/workspaces_finder.rb create mode 100644 ee/app/graphql/ee/types/user_interface.rb create mode 100644 ee/app/graphql/mutations/remote_development/workspaces/create.rb create mode 100644 ee/app/graphql/mutations/remote_development/workspaces/update.rb create mode 100644 ee/app/graphql/resolvers/remote_development/workspaces_resolver.rb create mode 100644 ee/app/graphql/types/remote_development/workspace_type.rb create mode 100644 ee/app/models/remote_development/remote_development_agent_config.rb create mode 100644 ee/app/models/remote_development/workspace.rb create mode 100644 ee/app/policies/remote_development/workspace_policy.rb create mode 100644 ee/app/services/remote_development/agent_config/update_service.rb create mode 100644 ee/app/services/remote_development/workspaces/create_service.rb create mode 100644 ee/app/services/remote_development/workspaces/reconcile_service.rb create mode 100644 ee/app/services/remote_development/workspaces/update_service.rb create mode 100644 ee/lib/remote_development/agent_config/update_processor.rb create mode 100644 ee/lib/remote_development/error.rb create mode 100644 ee/lib/remote_development/logger.rb create mode 100644 ee/lib/remote_development/workspaces/create/create_processor.rb create mode 100644 ee/lib/remote_development/workspaces/create/devfile_processor.rb create mode 100644 ee/lib/remote_development/workspaces/create/devfile_validator.rb create mode 100644 ee/lib/remote_development/workspaces/reconcile/actual_state_calculator.rb create mode 100644 ee/lib/remote_development/workspaces/reconcile/agent_info.rb create mode 100644 ee/lib/remote_development/workspaces/reconcile/agent_info_parser.rb create mode 100644 ee/lib/remote_development/workspaces/reconcile/desired_config_generator.rb create mode 100644 ee/lib/remote_development/workspaces/reconcile/devfile_parser.rb create mode 100644 ee/lib/remote_development/workspaces/reconcile/params_parser.rb create mode 100644 ee/lib/remote_development/workspaces/reconcile/reconcile_processor.rb create mode 100644 ee/lib/remote_development/workspaces/reconcile/update_type.rb create mode 100644 ee/lib/remote_development/workspaces/states.rb create mode 100644 ee/lib/remote_development/workspaces/update/update_processor.rb create mode 100644 ee/spec/factories/clusters/agents.rb create mode 100644 ee/spec/factories/remote_development/remote_development_agent_configs.rb create mode 100644 ee/spec/factories/remote_development/workspaces.rb create mode 100644 ee/spec/features/remote_development/workspaces_spec.rb create mode 100644 ee/spec/finders/remote_development/workspaces_finder_spec.rb create mode 100644 ee/spec/fixtures/remote_development/example.devfile.yaml create mode 100644 ee/spec/fixtures/remote_development/example.invalid-attributes-editor-injector-absent-devfile.yaml create mode 100644 ee/spec/fixtures/remote_development/example.invalid-attributes-editor-injector-multiple-devfile.yaml create mode 100644 ee/spec/fixtures/remote_development/example.invalid-no-components-devfile.yaml create mode 100644 ee/spec/fixtures/remote_development/example.invalid-restricted-prefix-command-apply-component-name-devfile.yaml create mode 100644 ee/spec/fixtures/remote_development/example.invalid-restricted-prefix-command-exec-component-name-devfile.yaml create mode 100644 ee/spec/fixtures/remote_development/example.invalid-restricted-prefix-command-name-devfile.yaml create mode 100644 ee/spec/fixtures/remote_development/example.invalid-restricted-prefix-component-container-endpoint-name-devfile.yaml create mode 100644 ee/spec/fixtures/remote_development/example.invalid-restricted-prefix-component-name-devfile.yaml create mode 100644 ee/spec/fixtures/remote_development/example.invalid-restricted-prefix-event-type-poststart-name-devfile.yaml create mode 100644 ee/spec/fixtures/remote_development/example.invalid-restricted-prefix-event-type-prestart-name-devfile.yaml create mode 100644 ee/spec/fixtures/remote_development/example.invalid-restricted-prefix-event-type-prestop-name-devfile.yaml create mode 100644 ee/spec/fixtures/remote_development/example.invalid-restricted-prefix-variable-name-devfile.yaml create mode 100644 ee/spec/fixtures/remote_development/example.invalid-restricted-prefix-variable-name-with-underscore-devfile.yaml create mode 100644 ee/spec/fixtures/remote_development/example.invalid-unsupported-component-type-image-devfile.yaml create mode 100644 ee/spec/fixtures/remote_development/example.invalid-unsupported-component-type-kubernetes-devfile.yaml create mode 100644 ee/spec/fixtures/remote_development/example.invalid-unsupported-component-type-openshift-devfile.yaml create mode 100644 ee/spec/fixtures/remote_development/example.invalid-unsupported-parent-inheritance-devfile.yaml create mode 100644 ee/spec/fixtures/remote_development/example.processed-devfile.yaml create mode 100644 ee/spec/graphql/api/workspace_spec.rb create mode 100644 ee/spec/graphql/types/remote_development/workspace_type_spec.rb create mode 100644 ee/spec/graphql/types/user_type_spec.rb create mode 100644 ee/spec/lib/remote_development/agent_config/update_processor_spec.rb create mode 100644 ee/spec/lib/remote_development/workspaces/create/create_processor_spec.rb create mode 100644 ee/spec/lib/remote_development/workspaces/create/devfile_processor_spec.rb create mode 100644 ee/spec/lib/remote_development/workspaces/create/devfile_validator_spec.rb create mode 100644 ee/spec/lib/remote_development/workspaces/reconcile/actual_state_calculator_spec.rb create mode 100644 ee/spec/lib/remote_development/workspaces/reconcile/agent_info_parser_spec.rb create mode 100644 ee/spec/lib/remote_development/workspaces/reconcile/agent_info_spec.rb create mode 100644 ee/spec/lib/remote_development/workspaces/reconcile/desired_config_generator_spec.rb create mode 100644 ee/spec/lib/remote_development/workspaces/reconcile/devfile_parser_spec.rb create mode 100644 ee/spec/lib/remote_development/workspaces/reconcile/params_parser_spec.rb create mode 100644 ee/spec/lib/remote_development/workspaces/reconcile/reconcile_processor_scenarios_spec.rb create mode 100644 ee/spec/lib/remote_development/workspaces/reconcile/reconcile_processor_spec.rb create mode 100644 ee/spec/lib/remote_development/workspaces/states_spec.rb create mode 100644 ee/spec/lib/remote_development/workspaces/update/update_processor_spec.rb create mode 100644 ee/spec/models/remote_development/remote_development_agent_config_spec.rb create mode 100644 ee/spec/models/remote_development/workspace_spec.rb create mode 100644 ee/spec/policies/remote_development/workspace_policy_spec.rb create mode 100644 ee/spec/requests/api/graphql/mutations/remote_development/workspaces/create_spec.rb create mode 100644 ee/spec/requests/api/graphql/mutations/remote_development/workspaces/update_spec.rb create mode 100644 ee/spec/requests/api/graphql/remote_development/current_user_workspaces_spec.rb create mode 100644 ee/spec/requests/api/graphql/remote_development/workspace_by_id_spec.rb create mode 100644 ee/spec/requests/api/graphql/remote_development/workspaces_by_ids_spec.rb create mode 100644 ee/spec/services/remote_development/agent_config/update_service_spec.rb create mode 100644 ee/spec/services/remote_development/workspaces/create_service_spec.rb create mode 100644 ee/spec/services/remote_development/workspaces/reconcile_service_spec.rb create mode 100644 ee/spec/services/remote_development/workspaces/update_service_spec.rb create mode 100644 ee/spec/support/shared_contexts/remote_development/agent_info_status_fixture_not_implemented_error.rb create mode 100644 ee/spec/support/shared_contexts/remote_development/remote_development_shared_contexts.rb create mode 100644 qa/qa/ee/flow/workspace.rb create mode 100644 qa/qa/ee/page/workspace/edit.rb create mode 100644 qa/qa/ee/page/workspace/index.rb create mode 100644 qa/qa/ee/page/workspace/new.rb create mode 100644 qa/qa/specs/features/ee/browser_ui/3_create/remote_development/create_workspace_spec.rb create mode 100755 scripts/remote_development/reset-remote-dev-db-tables.sh create mode 100755 scripts/remote_development/run-e2e-spec.sh create mode 100755 scripts/remote_development/run-remote-development-specs.sh diff --git a/Gemfile b/Gemfile index 7e25a3bae9157d..b2f654c157b1fe 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.15.pre.alpha1' + # Apple plist parsing gem 'CFPropertyList', '~> 3.0.0' gem 'app_store_connect' diff --git a/Gemfile.checksum b/Gemfile.checksum index 8b9a45e5a1949a..fdd9f9c1229698 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.15.pre.alpha1","platform":"arm64-darwin","checksum":"8e8234d552310925758dd5f214e1fc059ecc7255f8d692300d1adb955c370198"}, +{"name":"devfile","version":"0.0.15.pre.alpha1","platform":"ruby","checksum":"69a058d9edb44efe2fb78769e531b324f3bd75b21d45be15d8b0335b691d093e"}, +{"name":"devfile","version":"0.0.15.pre.alpha1","platform":"x86_64-linux","checksum":"460ddad57cc69a293661bb82ebc19ca44147201c74f78b36bb4861b402ecff24"}, {"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 f074a37cc8edf7..780b7d2a7867d9 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.15.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.15.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 83d2f3f830a81c..64fc069b508950 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 00000000000000..89e8009558056c --- /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 9d0e3f53830bcd..b34818070b123b 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 00000000000000..045a31d0d73eca --- /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 00000000000000..4c8bc26bcf651f --- /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 00000000000000..f6c38f289d62f4 --- /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 00000000000000..fe2b6eec2e01ed --- /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 00000000000000..c7874349e861d9 --- /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 00000000000000..f375f78b616099 --- /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 00000000000000..b861f417168478 --- /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 00000000000000..62d2d001438b77 --- /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 00000000000000..8aacd082afcd12 --- /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 00000000000000..99590b1246f879 --- /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 00000000000000..abbf974cda0dfc --- /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 00000000000000..9f101f1aff31c0 --- /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 00000000000000..1499a3257eb5ff --- /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 2fd0a3e40b3951..6fb9c5dfeaf42c 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, @@ -18736,13 +18736,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, @@ -20129,6 +20129,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, @@ -20153,7 +20154,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 ); @@ -21197,11 +21197,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, @@ -21832,6 +21832,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, @@ -24609,6 +24628,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, @@ -25614,6 +25676,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); @@ -25854,6 +25918,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); @@ -27998,6 +28064,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); @@ -28409,6 +28478,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); @@ -31348,6 +31420,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); @@ -32216,6 +32290,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); @@ -33020,6 +33096,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); @@ -34650,6 +34734,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; @@ -35337,6 +35424,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; @@ -35481,6 +35571,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; @@ -35586,6 +35679,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; @@ -35616,6 +35712,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 bf373120701ac9..0a5082716d6d16 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -760,6 +760,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. @@ -6825,6 +6861,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 @@ -11099,6 +11188,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. @@ -16731,6 +16843,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. @@ -16981,6 +17109,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. @@ -17250,6 +17394,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. @@ -17519,6 +17679,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 @@ -21933,6 +22109,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. @@ -22917,6 +23109,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. @@ -25844,6 +26065,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. @@ -26639,6 +26866,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 7753839ac62e90..9b2482a3925e85 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 07fc1950216627..aa3da498bb066e 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 99ca46acfff96c..600e8018164413 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 e091af000e6dd9..e21a90b0745d15 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 15ca0a4de97b6d..704ceaca1980c0 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 4941663d02564a..3237671b164f2f 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 7c8115bda8ff75..29220d094babcc 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 dc053981b2b62a..a663ef34b391b5 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 00000000000000..66c8e7d8ec1cf4 --- /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 04ca0a36d949dd..757b33cc94f999 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 47af1c0394b3d6..dcbd3e2d3a7596 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, @@ -88,6 +101,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 00000000000000..f09268fe22abf3 --- /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 00000000000000..20babd00f6fa1d --- /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 00000000000000..5e865aa9e6bc5f --- /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 00000000000000..fb7978f60255bf --- /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 00000000000000..496205ba097a46 --- /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 62a8ecf86c1532..d0d3bce6fbcc74 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 32f7f32d6f8a0d..0594f0f6039f97 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 99a7aa3c8964bb..7aae0e2c7cd7f7 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 00000000000000..5e599c59cbc1c6 --- /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 00000000000000..4117a8c5317c99 --- /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 63fbc95d8e70b3..0efa7bde405f9b 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 4d6e1c24ddee29..38623ebe5559d7 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 00000000000000..c74361008bf1a6 --- /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 00000000000000..5ba2e48b85a552 --- /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 00000000000000..84fc8edffdd702 --- /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 00000000000000..4a6699551a20c6 --- /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 00000000000000..50d7b5c47c6bed --- /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 ca32b9c764f2a0..f30bdd583aeb47 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 993b39ceea0d26..cbfed899acc140 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 00000000000000..db25e9593d1682 --- /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 00000000000000..c9d8b058bf6dcc --- /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 00000000000000..ed767b6b59d2ef --- /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 00000000000000..982d20842e0ccd --- /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 00000000000000..0259de7bd8bd2e --- /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}'" + } + ], + '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}'" + } + ] + + 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 00000000000000..ed3b636fbd7aa4 --- /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 00000000000000..9a816aef67cbdc --- /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 00000000000000..7e2ddebce26dc9 --- /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 00000000000000..94affb1604eb49 --- /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 00000000000000..8414924bead1ec --- /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 00000000000000..72573af2dac7fb --- /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 00000000000000..4cf4089058084b --- /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 00000000000000..dbf5f545fe3aaf --- /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 00000000000000..073fd0a4c84c67 --- /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 00000000000000..7530f8485271cb --- /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 00000000000000..6cc660b7267ebe --- /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 00000000000000..ee57ddcef9d532 --- /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 00000000000000..4a43f089e1df4f --- /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 00000000000000..4b93e6ca42c8fb --- /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 00000000000000..876326a8ec46db --- /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 00000000000000..114c9d2f7802a2 --- /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 00000000000000..b0b397aef5458a --- /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 00000000000000..3b26e99b120193 --- /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 00000000000000..555f6714c3944a --- /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 00000000000000..06ae3366eee5ab --- /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 00000000000000..c0cc9298ca12f1 --- /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 00000000000000..ab31818fd9ed59 --- /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 00000000000000..e646c33691f493 --- /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 00000000000000..c6d2811ffc16bf --- /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 00000000000000..15d6d5777fa3b8 --- /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 00000000000000..e035558b538de0 --- /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 00000000000000..9a731c506ccf29 --- /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 00000000000000..218a480a3b583d --- /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 00000000000000..0d28d092f76409 --- /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 00000000000000..0b9e88a68fbd0e --- /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 00000000000000..63d39dbb0f6ce1 --- /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 00000000000000..ff729b338db0f9 --- /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 00000000000000..a0f7812e980585 --- /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 00000000000000..ca1e7f122397bb --- /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 00000000000000..55bd94efb3ac21 --- /dev/null +++ b/ee/spec/fixtures/remote_development/example.processed-devfile.yaml @@ -0,0 +1,72 @@ +--- +schemaVersion: 2.2.0 +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 + - 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 efc4e554775332..15ce6ea2bc4676 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 3387efbd83c4d3..3dd4094afbbd60 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 bcf153116b1d92..12505d7bc75ba7 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,32 +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, + nodes: workspaces, + pageInfo: USER_WORKSPACES_QUERY_RESULT.data.currentUser.workspaces.pageInfo, }, }, }, @@ -278,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 () => { @@ -340,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 00000000000000..8901ce50a95c4c --- /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 4ec8b477e4463d..e5761fc7755f1e 100644 --- a/ee/spec/graphql/types/query_type_spec.rb +++ b/ee/spec/graphql/types/query_type_spec.rb @@ -19,7 +19,9 @@ :subscription_future_entries, :vulnerabilities, :vulnerabilities_count_by_day, - :vulnerability + :vulnerability, + :workspace, + :workspaces ] all_expected_fields = expected_foss_fields + expected_ee_fields 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 00000000000000..4e1ff9f775be4c --- /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 00000000000000..13945fce12dfe6 --- /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 00000000000000..f5ad206b3f9401 --- /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 00000000000000..31f064d8cedb26 --- /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 00000000000000..cb17cf8aff4102 --- /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 00000000000000..fe83b4d8488aa6 --- /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 00000000000000..ad3647ce7cb174 --- /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 00000000000000..b334559b499173 --- /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 00000000000000..e98fb9816d09a6 --- /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 00000000000000..eed9e8de52bbd1 --- /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 00000000000000..0f7280206b2265 --- /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 00000000000000..746d22da182bb1 --- /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 00000000000000..e2582e366725bc --- /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 00000000000000..3bc025852934f8 --- /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 00000000000000..ab9307bf07dec5 --- /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 00000000000000..821c78fa40af53 --- /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 b9d3e24425c8f9..5976f5691c91ff 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 00000000000000..24aac01450ad87 --- /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 00000000000000..f7f70673816b48 --- /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 dd0f5021fa106a..efa38ed393e6a6 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 00000000000000..1ba64202fb1a77 --- /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 00000000000000..6a07cead2e2c8c --- /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 00000000000000..34fea4dee0d6af --- /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 00000000000000..3c901d5105bafa --- /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 00000000000000..dd4a20c4488eed --- /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 00000000000000..f4465fa2f04785 --- /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 cf5b47919e73f0..8d1dfa6788df53 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 00000000000000..7fb2dfedfef9fc --- /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 00000000000000..f2d737a262a443 --- /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 00000000000000..692189bb80f170 --- /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 00000000000000..0c2cd8d61e63f8 --- /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 00000000000000..d712eb9b880929 --- /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 00000000000000..c402349085972f --- /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 22a26a725e9fa9..d340e097700d93 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 355f0b490b9ddf..3954f5d3c45c9c 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 "" @@ -53023,6 +53032,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 "" @@ -53170,6 +53185,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 00000000000000..b873b700fdf888 --- /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 00000000000000..60fce2fd4f66a8 --- /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 00000000000000..0b4b1bc95c8eaf --- /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 00000000000000..e4b5b867337e25 --- /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 70bdcfcb719540..20c1fa77e24485 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 157bc3abaf687e..752fa37094ff87 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 00000000000000..d5518d2793c93f --- /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/scripts/remote_development/reset-remote-dev-db-tables.sh b/scripts/remote_development/reset-remote-dev-db-tables.sh new file mode 100755 index 00000000000000..2ef64ae2068a25 --- /dev/null +++ b/scripts/remote_development/reset-remote-dev-db-tables.sh @@ -0,0 +1,24 @@ +# Resets all db schema related to remote development environment, so we can +# keep updating a single migration for the integration branch. +# All ongoing migration code will be added to the 20221225010101 migration while we are still +# on the branch. + +# shellcheck disable=SC2089 +CMD1=("DROP TABLE workspaces") +CMD2=("DROP TABLE remote_development_agent_configs") +CMD3=("DELETE FROM schema_migrations WHERE version in ('20221225010101','20221225010102','20221225010103','20221225010104','20221225010105','20221225010106','20230112010101','20230128010101','20230128010102', '20230330154123')") + +databases=(gitlabhq_development gitlabhq_development_ci gitlabhq_test gitlabhq_test_ci) + +for database in "${databases[@]}"; do + gdk psql -d "${database}" -P pager=off -c "${CMD1[@]}" -c "${CMD2[@]}" -c "${CMD3[@]}" +done + +rm db/schema_migrations/20221225010101 db/schema_migrations/20221225010102 db/schema_migrations/20221225010103 db/schema_migrations/20221225010104 db/schema_migrations/20221225010105 db/schema_migrations/20221225010106 db/schema_migrations/20230112010101 db/schema_migrations/20230128010101 db/schema_migrations/20230128010102 db/schema_migrations/20230330154123 + +# If this script fails, try running commands below manually to debug + +bin/rails db:migrate +bin/rails db:migrate:ci +bin/rails db:test:prepare +scripts/regenerate-schema diff --git a/scripts/remote_development/run-e2e-spec.sh b/scripts/remote_development/run-e2e-spec.sh new file mode 100755 index 00000000000000..3b1fe131957dd6 --- /dev/null +++ b/scripts/remote_development/run-e2e-spec.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env zsh + +# Before you run the test, ensure that: +# 1. test gitlab instance is up and running with default KAS / agentk stopped +# 2. KAS / agentk with remote dev code is running +# 3. current working directory is somewhere within gdk/gitlab repository + +# Below environment variables can have their defaults overridden but providing them +# when running the script +# +# For example, to override any variable, the script can be run in the following manner +# AGENTK_GROUP=gitlab-org AGENTK_NAME=test-agent GITLAB_PASSWORD=example TEST_INSTANCE_URL=https://gdk.test:3443 scripts/remote_development/run_e2e_spec.sh + +DEFAULT_PASSWORD='5iveL!fe' + +export WEBDRIVER_HEADLESS="${WEBDRIVER_HEADLESS:-0}" +export GITLAB_USERNAME="${GITLAB_USERNAME:-root}" +export GITLAB_PASSWORD="${GITLAB_PASSWORD:-${DEFAULT_PASSWORD}}" +export AGENTK_GROUP="${AGENTK_GROUP:-gitlab-org}" +export AGENTK_NAME="${AGENTK_NAME:-test-agent}" +export TEST_INSTANCE_URL="${TEST_INSTANCE_URL:-http://gdk.test:3000}" + +echo "Headless mode: ${WEBDRIVER_HEADLESS}" +echo "Gitlab username: ${GITLAB_USERNAME}" +echo "Agentk Group: ${AGENTK_GROUP}" +echo "Agentk Name: ${AGENTK_NAME}" +echo "Gitlab test instance: ${TEST_INSTANCE_URL}" + +working_directory="$(git rev-parse --show-toplevel)/qa" + +(cd "$working_directory" && \ + bundle && \ + bundle exec bin/qa Test::Instance::All "$TEST_INSTANCE_URL" -- \ + --tag quarantine qa/specs/features/ee/browser_ui/3_create/remote_development/create_workspace_spec.rb) diff --git a/scripts/remote_development/run-remote-development-specs.sh b/scripts/remote_development/run-remote-development-specs.sh new file mode 100755 index 00000000000000..937cc4f0fc5566 --- /dev/null +++ b/scripts/remote_development/run-remote-development-specs.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash + +# shellcheck disable=SC2059 + +set -o errexit # AKA -e - exit immediately on errors (http://mywiki.wooledge.org/BashFAQ/105) + +# https://stackoverflow.com/a/28938235 +BCyan='\033[1;36m' # Bold Cyan +BRed='\033[1;31m' # Bold Red +BGreen='\033[1;32m' # Bold Green +BBlue='\033[1;34m' # Bold Blue +Color_Off='\033[0m' # Text Reset + +function onexit_err() { + local exit_status=${1:-$?} + printf "\n❌❌❌ ${BRed}Remote Development specs failed!${Color_Off} ❌❌❌\n" + exit "${exit_status}" +} +trap onexit_err ERR +set -o errexit + +printf "${BCyan}" +printf "\nStarting Remote Development specs.\n\n" +printf "${Color_Off}" + +printf "${BBlue}Running Remote Development backend specs${Color_Off}\n\n" + +# Check/regenerate list with: git log $(git merge-base head master)..head --name-status | grep _spec | cut -f2- | sort | uniq + +# NOTE: For some reason this test started causing the following spec file in the list to blow up with +# "Failed to write to log, write log/workhorse-test.log: file already closed". Just removing +# it for now. +# ee/spec/graphql/api/workspace_spec.rb + +bin/spring rspec -r spec_helper \ +ee/spec/features/remote_development/workspaces_spec.rb \ +ee/spec/finders/remote_development/workspaces_finder_spec.rb \ +ee/spec/graphql/types/query_type_spec.rb \ +ee/spec/graphql/types/remote_development/workspace_type_spec.rb \ +ee/spec/graphql/types/subscription_type_spec.rb \ +ee/spec/lib/remote_development/workspaces/create/create_processor_spec.rb \ +ee/spec/lib/remote_development/workspaces/create/devfile_processor_spec.rb \ +ee/spec/lib/remote_development/workspaces/create/devfile_validator_spec.rb \ +ee/spec/lib/remote_development/workspaces/reconcile/actual_state_calculator_spec.rb \ +ee/spec/lib/remote_development/workspaces/reconcile/agent_info_parser_spec.rb \ +ee/spec/lib/remote_development/workspaces/reconcile/agent_info_spec.rb \ +ee/spec/lib/remote_development/workspaces/reconcile/desired_config_generator_spec.rb \ +ee/spec/lib/remote_development/workspaces/reconcile/params_parser_spec.rb \ +ee/spec/lib/remote_development/workspaces/reconcile/reconcile_processor_scenarios_spec.rb \ +ee/spec/lib/remote_development/workspaces/reconcile/reconcile_processor_spec.rb \ +ee/spec/lib/remote_development/workspaces/states_spec.rb \ +ee/spec/lib/remote_development/workspaces/update/update_processor_spec.rb \ +ee/spec/models/remote_development/remote_development_agent_config_spec.rb \ +ee/spec/models/remote_development/workspace_spec.rb \ +ee/spec/requests/api/graphql/mutations/remote_development/workspaces/create_spec.rb \ +ee/spec/requests/api/graphql/mutations/remote_development/workspaces/update_spec.rb \ +ee/spec/requests/api/graphql/remote_development/current_user_workspaces_spec.rb \ +ee/spec/requests/api/graphql/remote_development/workspaces_by_ids_spec.rb \ +ee/spec/requests/api/graphql/remote_development/workspace_by_id_spec.rb \ +ee/spec/requests/api/internal/kubernetes_spec.rb \ +ee/spec/services/remote_development/agent_config/update_service_spec.rb \ +ee/spec/services/remote_development/workspaces/create_service_spec.rb \ +ee/spec/services/remote_development/workspaces/reconcile_service_spec.rb \ +ee/spec/services/remote_development/workspaces/update_service_spec.rb \ +spec/graphql/types/subscription_type_spec.rb \ + +printf "\n✅✅✅ ${BGreen}All Remote Development specs passed successfully!${Color_Off} ✅✅✅\n" diff --git a/spec/graphql/types/user_type_spec.rb b/spec/graphql/types/user_type_spec.rb index 7a0b6c90ace73a..0b0dcf2fb6aa1e 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 e6f281ae35b541..8a2602ea9f6296 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 c19dfa6f3f3268..f881935c052944 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 a55027d3976fbc..a9ad853b028047 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 ed193d06161c4a..3dffc2066aed4d 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 -- GitLab From 4a93a036e8460ae13a9463202f1cbd4eb994247e Mon Sep 17 00:00:00 2001 From: Vishal Tak Date: Mon, 8 May 2023 10:32:32 +0530 Subject: [PATCH 2/3] Remove scripts folder Remove scripts for remote development to avoid approvals --- .../reset-remote-dev-db-tables.sh | 24 ------- scripts/remote_development/run-e2e-spec.sh | 34 ---------- .../run-remote-development-specs.sh | 67 ------------------- 3 files changed, 125 deletions(-) delete mode 100755 scripts/remote_development/reset-remote-dev-db-tables.sh delete mode 100755 scripts/remote_development/run-e2e-spec.sh delete mode 100755 scripts/remote_development/run-remote-development-specs.sh diff --git a/scripts/remote_development/reset-remote-dev-db-tables.sh b/scripts/remote_development/reset-remote-dev-db-tables.sh deleted file mode 100755 index 2ef64ae2068a25..00000000000000 --- a/scripts/remote_development/reset-remote-dev-db-tables.sh +++ /dev/null @@ -1,24 +0,0 @@ -# Resets all db schema related to remote development environment, so we can -# keep updating a single migration for the integration branch. -# All ongoing migration code will be added to the 20221225010101 migration while we are still -# on the branch. - -# shellcheck disable=SC2089 -CMD1=("DROP TABLE workspaces") -CMD2=("DROP TABLE remote_development_agent_configs") -CMD3=("DELETE FROM schema_migrations WHERE version in ('20221225010101','20221225010102','20221225010103','20221225010104','20221225010105','20221225010106','20230112010101','20230128010101','20230128010102', '20230330154123')") - -databases=(gitlabhq_development gitlabhq_development_ci gitlabhq_test gitlabhq_test_ci) - -for database in "${databases[@]}"; do - gdk psql -d "${database}" -P pager=off -c "${CMD1[@]}" -c "${CMD2[@]}" -c "${CMD3[@]}" -done - -rm db/schema_migrations/20221225010101 db/schema_migrations/20221225010102 db/schema_migrations/20221225010103 db/schema_migrations/20221225010104 db/schema_migrations/20221225010105 db/schema_migrations/20221225010106 db/schema_migrations/20230112010101 db/schema_migrations/20230128010101 db/schema_migrations/20230128010102 db/schema_migrations/20230330154123 - -# If this script fails, try running commands below manually to debug - -bin/rails db:migrate -bin/rails db:migrate:ci -bin/rails db:test:prepare -scripts/regenerate-schema diff --git a/scripts/remote_development/run-e2e-spec.sh b/scripts/remote_development/run-e2e-spec.sh deleted file mode 100755 index 3b1fe131957dd6..00000000000000 --- a/scripts/remote_development/run-e2e-spec.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env zsh - -# Before you run the test, ensure that: -# 1. test gitlab instance is up and running with default KAS / agentk stopped -# 2. KAS / agentk with remote dev code is running -# 3. current working directory is somewhere within gdk/gitlab repository - -# Below environment variables can have their defaults overridden but providing them -# when running the script -# -# For example, to override any variable, the script can be run in the following manner -# AGENTK_GROUP=gitlab-org AGENTK_NAME=test-agent GITLAB_PASSWORD=example TEST_INSTANCE_URL=https://gdk.test:3443 scripts/remote_development/run_e2e_spec.sh - -DEFAULT_PASSWORD='5iveL!fe' - -export WEBDRIVER_HEADLESS="${WEBDRIVER_HEADLESS:-0}" -export GITLAB_USERNAME="${GITLAB_USERNAME:-root}" -export GITLAB_PASSWORD="${GITLAB_PASSWORD:-${DEFAULT_PASSWORD}}" -export AGENTK_GROUP="${AGENTK_GROUP:-gitlab-org}" -export AGENTK_NAME="${AGENTK_NAME:-test-agent}" -export TEST_INSTANCE_URL="${TEST_INSTANCE_URL:-http://gdk.test:3000}" - -echo "Headless mode: ${WEBDRIVER_HEADLESS}" -echo "Gitlab username: ${GITLAB_USERNAME}" -echo "Agentk Group: ${AGENTK_GROUP}" -echo "Agentk Name: ${AGENTK_NAME}" -echo "Gitlab test instance: ${TEST_INSTANCE_URL}" - -working_directory="$(git rev-parse --show-toplevel)/qa" - -(cd "$working_directory" && \ - bundle && \ - bundle exec bin/qa Test::Instance::All "$TEST_INSTANCE_URL" -- \ - --tag quarantine qa/specs/features/ee/browser_ui/3_create/remote_development/create_workspace_spec.rb) diff --git a/scripts/remote_development/run-remote-development-specs.sh b/scripts/remote_development/run-remote-development-specs.sh deleted file mode 100755 index 937cc4f0fc5566..00000000000000 --- a/scripts/remote_development/run-remote-development-specs.sh +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env bash - -# shellcheck disable=SC2059 - -set -o errexit # AKA -e - exit immediately on errors (http://mywiki.wooledge.org/BashFAQ/105) - -# https://stackoverflow.com/a/28938235 -BCyan='\033[1;36m' # Bold Cyan -BRed='\033[1;31m' # Bold Red -BGreen='\033[1;32m' # Bold Green -BBlue='\033[1;34m' # Bold Blue -Color_Off='\033[0m' # Text Reset - -function onexit_err() { - local exit_status=${1:-$?} - printf "\n❌❌❌ ${BRed}Remote Development specs failed!${Color_Off} ❌❌❌\n" - exit "${exit_status}" -} -trap onexit_err ERR -set -o errexit - -printf "${BCyan}" -printf "\nStarting Remote Development specs.\n\n" -printf "${Color_Off}" - -printf "${BBlue}Running Remote Development backend specs${Color_Off}\n\n" - -# Check/regenerate list with: git log $(git merge-base head master)..head --name-status | grep _spec | cut -f2- | sort | uniq - -# NOTE: For some reason this test started causing the following spec file in the list to blow up with -# "Failed to write to log, write log/workhorse-test.log: file already closed". Just removing -# it for now. -# ee/spec/graphql/api/workspace_spec.rb - -bin/spring rspec -r spec_helper \ -ee/spec/features/remote_development/workspaces_spec.rb \ -ee/spec/finders/remote_development/workspaces_finder_spec.rb \ -ee/spec/graphql/types/query_type_spec.rb \ -ee/spec/graphql/types/remote_development/workspace_type_spec.rb \ -ee/spec/graphql/types/subscription_type_spec.rb \ -ee/spec/lib/remote_development/workspaces/create/create_processor_spec.rb \ -ee/spec/lib/remote_development/workspaces/create/devfile_processor_spec.rb \ -ee/spec/lib/remote_development/workspaces/create/devfile_validator_spec.rb \ -ee/spec/lib/remote_development/workspaces/reconcile/actual_state_calculator_spec.rb \ -ee/spec/lib/remote_development/workspaces/reconcile/agent_info_parser_spec.rb \ -ee/spec/lib/remote_development/workspaces/reconcile/agent_info_spec.rb \ -ee/spec/lib/remote_development/workspaces/reconcile/desired_config_generator_spec.rb \ -ee/spec/lib/remote_development/workspaces/reconcile/params_parser_spec.rb \ -ee/spec/lib/remote_development/workspaces/reconcile/reconcile_processor_scenarios_spec.rb \ -ee/spec/lib/remote_development/workspaces/reconcile/reconcile_processor_spec.rb \ -ee/spec/lib/remote_development/workspaces/states_spec.rb \ -ee/spec/lib/remote_development/workspaces/update/update_processor_spec.rb \ -ee/spec/models/remote_development/remote_development_agent_config_spec.rb \ -ee/spec/models/remote_development/workspace_spec.rb \ -ee/spec/requests/api/graphql/mutations/remote_development/workspaces/create_spec.rb \ -ee/spec/requests/api/graphql/mutations/remote_development/workspaces/update_spec.rb \ -ee/spec/requests/api/graphql/remote_development/current_user_workspaces_spec.rb \ -ee/spec/requests/api/graphql/remote_development/workspaces_by_ids_spec.rb \ -ee/spec/requests/api/graphql/remote_development/workspace_by_id_spec.rb \ -ee/spec/requests/api/internal/kubernetes_spec.rb \ -ee/spec/services/remote_development/agent_config/update_service_spec.rb \ -ee/spec/services/remote_development/workspaces/create_service_spec.rb \ -ee/spec/services/remote_development/workspaces/reconcile_service_spec.rb \ -ee/spec/services/remote_development/workspaces/update_service_spec.rb \ -spec/graphql/types/subscription_type_spec.rb \ - -printf "\n✅✅✅ ${BGreen}All Remote Development specs passed successfully!${Color_Off} ✅✅✅\n" -- GitLab From fae834c60b573e5cbd452d9ce1df476dfbaf92a3 Mon Sep 17 00:00:00 2001 From: Vishal Tak Date: Mon, 8 May 2023 19:43:33 +0530 Subject: [PATCH 3/3] Bump devfile version and fix specs This version is a critical security update preventing shell injection --- Gemfile | 2 +- Gemfile.checksum | 6 +++--- Gemfile.lock | 4 ++-- .../workspaces/create/devfile_processor.rb | 4 ++-- .../remote_development/example.processed-devfile.yaml | 7 +++++-- .../remote_development_shared_contexts.rb | 4 ++-- 6 files changed, 15 insertions(+), 12 deletions(-) diff --git a/Gemfile b/Gemfile index b2f654c157b1fe..7d5a6177dee8aa 100644 --- a/Gemfile +++ b/Gemfile @@ -588,7 +588,7 @@ gem 'cvss-suite', '~> 3.0.1', require: 'cvss_suite' gem 'arr-pm', '~> 0.0.12' # Remote Development -gem 'devfile', '~> 0.0.15.pre.alpha1' +gem 'devfile', '~> 0.0.17.pre.alpha1' # Apple plist parsing gem 'CFPropertyList', '~> 3.0.0' diff --git a/Gemfile.checksum b/Gemfile.checksum index fdd9f9c1229698..7011065b5b95b2 100644 --- a/Gemfile.checksum +++ b/Gemfile.checksum @@ -109,9 +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.15.pre.alpha1","platform":"arm64-darwin","checksum":"8e8234d552310925758dd5f214e1fc059ecc7255f8d692300d1adb955c370198"}, -{"name":"devfile","version":"0.0.15.pre.alpha1","platform":"ruby","checksum":"69a058d9edb44efe2fb78769e531b324f3bd75b21d45be15d8b0335b691d093e"}, -{"name":"devfile","version":"0.0.15.pre.alpha1","platform":"x86_64-linux","checksum":"460ddad57cc69a293661bb82ebc19ca44147201c74f78b36bb4861b402ecff24"}, +{"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 780b7d2a7867d9..be9a727e611273 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -375,7 +375,7 @@ GEM thor (>= 0.19, < 2) descendants_tracker (0.0.4) thread_safe (~> 0.3, >= 0.3.1) - devfile (0.0.15.pre.alpha1) + devfile (0.0.17.pre.alpha1) device_detector (1.0.0) devise (4.8.1) bcrypt (~> 3.0) @@ -1715,7 +1715,7 @@ DEPENDENCIES declarative_policy (~> 1.1.0) deprecation_toolkit (~> 1.5.1) derailed_benchmarks - devfile (~> 0.0.15.pre.alpha1) + devfile (~> 0.0.17.pre.alpha1) device_detector devise (~> 4.8.1) devise-pbkdf2-encryptable (~> 0.0.0)! diff --git a/ee/lib/remote_development/workspaces/create/devfile_processor.rb b/ee/lib/remote_development/workspaces/create/devfile_processor.rb index 0259de7bd8bd2e..9bfd00e399abc4 100644 --- a/ee/lib/remote_development/workspaces/create/devfile_processor.rb +++ b/ee/lib/remote_development/workspaces/create/devfile_processor.rb @@ -52,7 +52,7 @@ def add_editor(flattened_devfile:, editor:, volume_reference:, volume_mount_dir: }, { 'name' => 'EDITOR_PORT', - 'value' => "'#{editor_port}'" + 'value' => editor_port.to_s } ], 'memoryLimit' => '128Mi', @@ -87,7 +87,7 @@ def add_editor(flattened_devfile:, editor:, volume_reference:, volume_mount_dir: }, { 'name' => 'EDITOR_PORT', - 'value' => "'#{editor_port}'" + 'value' => editor_port.to_s } ] diff --git a/ee/spec/fixtures/remote_development/example.processed-devfile.yaml b/ee/spec/fixtures/remote_development/example.processed-devfile.yaml index 55bd94efb3ac21..817b960f0db1eb 100644 --- a/ee/spec/fixtures/remote_development/example.processed-devfile.yaml +++ b/ee/spec/fixtures/remote_development/example.processed-devfile.yaml @@ -1,5 +1,6 @@ --- schemaVersion: 2.2.0 +metadata: {} components: - name: tooling-container attributes: @@ -15,13 +16,15 @@ components: - name: EDITOR_VOLUME_DIR value: "/projects/.gl-editor" - name: EDITOR_PORT - value: "'60001'" + 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 @@ -35,7 +38,7 @@ components: - name: EDITOR_VOLUME_DIR value: "/projects/.gl-editor" - name: EDITOR_PORT - value: "'60001'" + value: "60001" memoryLimit: 128Mi memoryRequest: 32Mi cpuLimit: 500m 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 index c402349085972f..69204c448b12ce 100644 --- 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 @@ -311,7 +311,7 @@ def create_workspace_agent_info( initContainers: - args: - |- - if [ ! -d /projects/test-project ]; + if [ ! -d '/projects/test-project' ]; then git clone --branch master #{root_url}test-group/test-project.git /projects/test-project; fi @@ -489,7 +489,7 @@ def create_config_to_apply( initContainers: - args: - |- - if [ ! -d /projects/test-project ]; + if [ ! -d '/projects/test-project' ]; then git clone --branch master #{root_url}test-group/test-project.git /projects/test-project; fi -- GitLab