diff --git a/db/migrate/20250114194544_add_user_provided_to_workspace_variables.rb b/db/migrate/20250114194544_add_user_provided_to_workspace_variables.rb
new file mode 100644
index 0000000000000000000000000000000000000000..07c3759b69da9830080ed28dd6d6ef7f08772377
--- /dev/null
+++ b/db/migrate/20250114194544_add_user_provided_to_workspace_variables.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class AddUserProvidedToWorkspaceVariables < Gitlab::Database::Migration[2.2]
+ milestone '17.9'
+
+ def change
+ add_column :workspace_variables, :user_provided, :boolean, null: false, default: false, if_not_exists: true
+ end
+end
diff --git a/db/migrate/20250114194610_backfill_user_provided_workspace_variables.rb b/db/migrate/20250114194610_backfill_user_provided_workspace_variables.rb
new file mode 100644
index 0000000000000000000000000000000000000000..187625cea7b6ac6ae717a1b71b9ab670d4ef5cf7
--- /dev/null
+++ b/db/migrate/20250114194610_backfill_user_provided_workspace_variables.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+class BackfillUserProvidedWorkspaceVariables < Gitlab::Database::Migration[2.2]
+ milestone '17.9'
+
+ restrict_gitlab_migration gitlab_schema: :gitlab_main
+
+ class WorkspaceVariable < MigrationRecord
+ self.table_name = :workspace_variables
+
+ include EachBatch
+ end
+
+ BATCH_SIZE = 100
+
+ # Since file type is not supported for user variables
+ VARIABLE_ENV_TYPE = 0
+
+ # Internal workspace variables that get created on workspace creation
+ # Reference: /ee/lib/remote_development/workspace_operations/create/workspace_variables.rb
+ WORKSPACE_INTERNAL_VARIABLES = %w[
+ GIT_CONFIG_COUNT
+ GIT_CONFIG_KEY_0
+ GIT_CONFIG_VALUE_0
+ GIT_CONFIG_KEY_1
+ GIT_CONFIG_VALUE_1
+ GIT_CONFIG_KEY_2
+ GIT_CONFIG_VALUE_2
+ GL_GIT_CREDENTIAL_STORE_FILE_PATH
+ GL_TOKEN_FILE_PATH
+ GL_WORKSPACE_DOMAIN_TEMPLATE
+ GL_EDITOR_EXTENSIONS_GALLERY_SERVICE_URL
+ GL_EDITOR_EXTENSIONS_GALLERY_ITEM_URL
+ GL_EDITOR_EXTENSIONS_GALLERY_RESOURCE_URL_TEMPLATE
+ GITLAB_WORKFLOW_INSTANCE_URL
+ GITLAB_WORKFLOW_TOKEN_FILE
+ ].freeze
+
+ def up
+ WorkspaceVariable.reset_column_information
+
+ WorkspaceVariable.each_batch(of: BATCH_SIZE) do |batch|
+ batch.where(variable_type: VARIABLE_ENV_TYPE).where.not(key: WORKSPACE_INTERNAL_VARIABLES)
+ .update_all(user_provided: true)
+ end
+ end
+
+ def down
+ WorkspaceVariable.reset_column_information
+
+ # Column is NOT NULL DEFAULT 0, so setting back to default
+ WorkspaceVariable.update_all(user_provided: false)
+ end
+end
diff --git a/db/schema_migrations/20250114194544 b/db/schema_migrations/20250114194544
new file mode 100644
index 0000000000000000000000000000000000000000..1136a615f5baff6c0d72d1bd9cb87f6550fefe7d
--- /dev/null
+++ b/db/schema_migrations/20250114194544
@@ -0,0 +1 @@
+79ded29b939ad063a58275fec52191c01161d48b89758b05f3dc3f15c41f018d
\ No newline at end of file
diff --git a/db/schema_migrations/20250114194610 b/db/schema_migrations/20250114194610
new file mode 100644
index 0000000000000000000000000000000000000000..04cc0d3f4f3735251cd85b97a9281dbcb392ae1f
--- /dev/null
+++ b/db/schema_migrations/20250114194610
@@ -0,0 +1 @@
+e57acf78acb8d7c507b2e4d611be22d6741f4a0e09970776bb86f2aca266b542
\ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index ec51c6336aeec41ed1d7831acd5447f4937514b0..ce302baf8b52e491ffdca59fd920a9e3a631f9d4 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -23254,6 +23254,7 @@ CREATE TABLE workspace_variables (
encrypted_value bytea NOT NULL,
encrypted_value_iv bytea NOT NULL,
project_id bigint,
+ user_provided boolean DEFAULT false NOT NULL,
CONSTRAINT check_5545042100 CHECK ((char_length(key) <= 255)),
CONSTRAINT check_ed95da8691 CHECK ((project_id IS NOT NULL))
);
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index 3d68d696080446f1c36b95af6a9da8ef9681ec5d..406cedcdcf5f2b92a372c5ebcc08b8951e2fd232 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -18344,6 +18344,29 @@ The edge type for [`Workspace`](#workspace).
| `cursor` | [`String!`](#string) | A cursor for use in pagination. |
| `node` | [`Workspace`](#workspace) | The item at the end of the edge. |
+#### `WorkspaceVariableConnection`
+
+The connection type for [`WorkspaceVariable`](#workspacevariable).
+
+##### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `edges` | [`[WorkspaceVariableEdge]`](#workspacevariableedge) | A list of edges. |
+| `nodes` | [`[WorkspaceVariable]`](#workspacevariable) | A list of nodes. |
+| `pageInfo` | [`PageInfo!`](#pageinfo) | Information to aid in pagination. |
+
+#### `WorkspaceVariableEdge`
+
+The edge type for [`WorkspaceVariable`](#workspacevariable).
+
+##### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `cursor` | [`String!`](#string) | A cursor for use in pagination. |
+| `node` | [`WorkspaceVariable`](#workspacevariable) | The item at the end of the edge. |
+
## Object types
Object types represent the resources that the GitLab GraphQL API can return.
@@ -21308,7 +21331,7 @@ four standard [pagination arguments](#pagination-arguments):
| ---- | ---- | ----------- |
| `actualStates` | [`[String!]`](#string) | Filter workspaces by actual states. |
| `ids` | [`[RemoteDevelopmentWorkspaceID!]`](#remotedevelopmentworkspaceid) | Filter workspaces by workspace GlobalIDs. For example, `["gid://gitlab/RemoteDevelopment::Workspace/1"]`. |
-| `projectIds` | [`[ProjectID!]`](#projectid) | Filter workspaces by project GlobalID. |
+| `projectIds` | [`[ProjectID!]`](#projectid) | Filter workspaces by project GlobalIDs. |
### `ClusterAgentActivityEvent`
@@ -38476,8 +38499,24 @@ Represents a remote development workspace.
| `updatedAt` | [`Time!`](#time) | Timestamp of the last update to any mutable workspace property. |
| `url` | [`String!`](#string) | URL of the workspace. |
| `user` | [`UserCore!`](#usercore) | Owner of the workspace. |
+| `workspaceVariables` **{warning-solid}** | [`WorkspaceVariableConnection`](#workspacevariableconnection) | **Introduced** in GitLab 17.9. **Status**: Experiment. User defined variables associated with the workspace. |
| `workspacesAgentConfigVersion` **{warning-solid}** | [`Int!`](#int) | **Introduced** in GitLab 17.6. **Status**: Experiment. Version of the associated WorkspacesAgentConfig for the workspace. |
+### `WorkspaceVariable`
+
+Represents a remote development workspace variable.
+
+#### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `createdAt` | [`Time!`](#time) | Timestamp of when the workspace variable was created. |
+| `id` | [`RemoteDevelopmentWorkspaceVariableID!`](#remotedevelopmentworkspacevariableid) | Global ID of the workspace variable. |
+| `key` | [`String`](#string) | Name of the workspace variable. |
+| `updatedAt` | [`Time!`](#time) | Timestamp of when the workspace variable was updated. |
+| `value` | [`String`](#string) | Value of the workspace variable. |
+| `variableType` | [`WorkspaceVariableType`](#workspacevariabletype) | Type of the workspace variable. |
+
### `WorkspacesAgentConfig`
Represents a workspaces agent config.
@@ -42177,6 +42216,14 @@ Enum for the type of the variable to be injected in a workspace.
| ----- | ----------- |
| `ENVIRONMENT` | Name type. |
+### `WorkspaceVariableType`
+
+Enum for the type of the variable injected in a workspace.
+
+| Value | Description |
+| ----- | ----------- |
+| `ENVIRONMENT` | Environment type. |
+
## Scalar types
Scalar values are atomic values, and do not have fields of their own.
@@ -43149,6 +43196,12 @@ A `RemoteDevelopmentWorkspaceID` is a global ID. It is encoded as a string.
An example `RemoteDevelopmentWorkspaceID` is: `"gid://gitlab/RemoteDevelopment::Workspace/1"`.
+### `RemoteDevelopmentWorkspaceVariableID`
+
+A `RemoteDevelopmentWorkspaceVariableID` is a global ID. It is encoded as a string.
+
+An example `RemoteDevelopmentWorkspaceVariableID` is: `"gid://gitlab/RemoteDevelopment::WorkspaceVariable/1"`.
+
### `RemoteDevelopmentWorkspacesAgentConfigID`
A `RemoteDevelopmentWorkspacesAgentConfigID` is a global ID. It is encoded as a string.
@@ -45634,6 +45687,7 @@ Attributes for defining a variable to be injected in a workspace.
| Name | Type | Description |
| ---- | ---- | ----------- |
-| `key` | [`String!`](#string) | Key of the variable. |
-| `type` | [`WorkspaceVariableInputType!`](#workspacevariableinputtype) | Type of the variable to be injected in a workspace. |
+| `key` | [`String!`](#string) | Name of the workspace variable. |
+| `type` **{warning-solid}** | [`WorkspaceVariableInputType`](#workspacevariableinputtype) | **Deprecated:** Use `variableType` instead. Deprecated in GitLab 17.9. |
| `value` | [`String!`](#string) | Value of the variable. |
+| `variableType` | [`WorkspaceVariableType`](#workspacevariabletype) | Type of the variable to be injected in a workspace. |
diff --git a/ee/app/graphql/ee/types/query_type.rb b/ee/app/graphql/ee/types/query_type.rb
index feb21cf019d43c37c8dd3cfd663e7102a3d24b55..90669245b3e1ef742bcacce19392b97f597ddf1e 100644
--- a/ee/app/graphql/ee/types/query_type.rb
+++ b/ee/app/graphql/ee/types/query_type.rb
@@ -98,7 +98,7 @@ module QueryType
field :workspaces,
::Types::RemoteDevelopment::WorkspaceType.connection_type,
null: true,
- resolver: ::Resolvers::RemoteDevelopment::AdminWorkspacesResolver,
+ resolver: ::Resolvers::RemoteDevelopment::WorkspacesAdminResolver,
description: 'Find workspaces across the entire instance. This field is only available to instance admins, ' \
'it will return an empty result for all non-admins.'
field :instance_external_audit_event_destinations,
diff --git a/ee/app/graphql/resolvers/remote_development/cluster_agent/workspaces_resolver.rb b/ee/app/graphql/resolvers/remote_development/cluster_agent/workspaces_resolver.rb
index 68c04db7e4506d429e7f0c9782f0cec655d430d8..f5556295e06a119f8d379b33df719830d50bc1b4 100644
--- a/ee/app/graphql/resolvers/remote_development/cluster_agent/workspaces_resolver.rb
+++ b/ee/app/graphql/resolvers/remote_development/cluster_agent/workspaces_resolver.rb
@@ -3,40 +3,24 @@
module Resolvers
module RemoteDevelopment
module ClusterAgent
- class WorkspacesResolver < ::Resolvers::BaseResolver
- include ResolvesIds
+ class WorkspacesResolver < WorkspacesBaseResolver
include Gitlab::Graphql::Authorize::AuthorizeResource
type Types::RemoteDevelopment::WorkspaceType.connection_type, null: true
authorize :admin_cluster
authorizes_object!
- argument :ids, [::Types::GlobalIDType[::RemoteDevelopment::Workspace]],
- required: false,
- description:
- 'Filter workspaces by workspace GlobalIDs. For example, `["gid://gitlab/RemoteDevelopment::Workspace/1"]`.'
-
- argument :project_ids, [::Types::GlobalIDType[Project]],
- required: false,
- description: 'Filter workspaces by project GlobalID.'
-
- argument :actual_states, [GraphQL::Types::String],
- required: false,
- description: 'Filter workspaces by actual states.'
-
alias_method :agent, :object
- def resolve(**args)
- unless License.feature_available?(:remote_development)
- raise_resource_not_available_error! "'remote_development' licensed feature is not available"
- end
-
- ::RemoteDevelopment::WorkspacesFinder.execute(
- current_user: current_user,
- agent_ids: [agent.id],
- ids: resolve_ids(args[:ids]).map(&:to_i),
- project_ids: resolve_ids(args[:project_ids]).map(&:to_i),
- actual_states: args[:actual_states] || []
+ def resolve_with_lookahead(**args)
+ apply_lookahead(
+ ::RemoteDevelopment::WorkspacesFinder.execute(
+ current_user: current_user,
+ agent_ids: [agent.id],
+ ids: resolve_ids(args[:ids]).map(&:to_i),
+ project_ids: resolve_ids(args[:project_ids]).map(&:to_i),
+ actual_states: args[:actual_states] || []
+ )
)
end
end
diff --git a/ee/app/graphql/resolvers/remote_development/admin_workspaces_resolver.rb b/ee/app/graphql/resolvers/remote_development/workspaces_admin_resolver.rb
similarity index 55%
rename from ee/app/graphql/resolvers/remote_development/admin_workspaces_resolver.rb
rename to ee/app/graphql/resolvers/remote_development/workspaces_admin_resolver.rb
index ff856113f963775ea48e486418e0b85e06f95931..3713040c94e0401db68a61c2769f37bcdbecd697 100644
--- a/ee/app/graphql/resolvers/remote_development/admin_workspaces_resolver.rb
+++ b/ee/app/graphql/resolvers/remote_development/workspaces_admin_resolver.rb
@@ -2,9 +2,7 @@
module Resolvers
module RemoteDevelopment
- class AdminWorkspacesResolver < ::Resolvers::BaseResolver
- include ResolvesIds
-
+ class WorkspacesAdminResolver < WorkspacesBaseResolver
# NOTE: We are intentionally not including Gitlab::Graphql::Authorize::AuthorizeResource, because this resolver
# is currently only authorized at the instance admin level for all workspaces in the instance via the
# `:read_all_workspaces` ability, so it's not necessary (or performant) to authorize individual workspaces.
@@ -14,19 +12,10 @@ class AdminWorkspacesResolver < ::Resolvers::BaseResolver
type Types::RemoteDevelopment::WorkspaceType.connection_type, null: true
- argument :ids, [::Types::GlobalIDType[::RemoteDevelopment::Workspace]],
- required: false,
- description:
- 'Filter workspaces by workspace GlobalIDs. For example, `["gid://gitlab/RemoteDevelopment::Workspace/1"]`.'
-
argument :user_ids, [::Types::GlobalIDType[Project]],
required: false,
description: 'Filter workspaces by user GlobalIDs.'
- argument :project_ids, [::Types::GlobalIDType[Project]],
- required: false,
- description: 'Filter workspaces by project GlobalIDs.'
-
argument :agent_ids, [::Types::GlobalIDType[::Clusters::Agent]],
required: false,
description: 'Filter workspaces by agent GlobalIDs.'
@@ -36,28 +25,19 @@ class AdminWorkspacesResolver < ::Resolvers::BaseResolver
deprecated: { reason: 'Use actual_states instead', milestone: '16.7' },
description: 'Filter workspaces by actual states.'
- argument :actual_states, [GraphQL::Types::String],
- required: false,
- description: 'Filter workspaces by actual states.'
-
- def resolve(**args)
- # rubocop:disable Graphql/ResourceNotAvailableError -- We are intentionally not including Gitlab::Graphql::Authorize::AuthorizeResource - see note at top of class
- unless License.feature_available?(:remote_development)
- raise ::Gitlab::Graphql::Errors::ResourceNotAvailable,
- "'remote_development' licensed feature is not available"
- end
- # rubocop:enable Graphql/ResourceNotAvailableError
-
+ def resolve_with_lookahead(**args)
return ::RemoteDevelopment::Workspace.none unless can_read_all_workspaces?
begin
- ::RemoteDevelopment::WorkspacesFinder.execute(
- current_user: current_user,
- ids: resolve_ids(args[:ids]).map(&:to_i),
- user_ids: resolve_ids(args[:user_ids]).map(&:to_i),
- project_ids: resolve_ids(args[:project_ids]).map(&:to_i),
- agent_ids: resolve_ids(args[:agent_ids]).map(&:to_i),
- actual_states: args[:actual_states] || args[:include_actual_states] || []
+ apply_lookahead(
+ ::RemoteDevelopment::WorkspacesFinder.execute(
+ current_user: current_user,
+ ids: resolve_ids(args[:ids]).map(&:to_i),
+ user_ids: resolve_ids(args[:user_ids]).map(&:to_i),
+ project_ids: resolve_ids(args[:project_ids]).map(&:to_i),
+ agent_ids: resolve_ids(args[:agent_ids]).map(&:to_i),
+ actual_states: args[:actual_states] || args[:include_actual_states] || []
+ )
)
rescue ArgumentError => e
raise ::Gitlab::Graphql::Errors::ArgumentError, e.message
diff --git a/ee/app/graphql/resolvers/remote_development/workspaces_base_resolver.rb b/ee/app/graphql/resolvers/remote_development/workspaces_base_resolver.rb
new file mode 100644
index 0000000000000000000000000000000000000000..8c93bb0bc72810ace9235dda29d566680b1ec76d
--- /dev/null
+++ b/ee/app/graphql/resolvers/remote_development/workspaces_base_resolver.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module RemoteDevelopment
+ class WorkspacesBaseResolver < ::Resolvers::BaseResolver
+ include ResolvesIds
+ include LooksAhead
+
+ extras [:lookahead]
+
+ type Types::RemoteDevelopment::WorkspaceType.connection_type, null: true
+
+ argument :ids, [::Types::GlobalIDType[::RemoteDevelopment::Workspace]],
+ required: false,
+ description:
+ 'Filter workspaces by workspace GlobalIDs. For example, `["gid://gitlab/RemoteDevelopment::Workspace/1"]`.'
+
+ argument :project_ids, [::Types::GlobalIDType[Project]],
+ required: false,
+ description: 'Filter workspaces by project GlobalIDs.'
+
+ argument :actual_states, [GraphQL::Types::String],
+ required: false,
+ description: 'Filter workspaces by actual states.'
+
+ def ready?(**args)
+ # rubocop:disable Graphql/ResourceNotAvailableError -- Gitlab::Graphql::Authorize::AuthorizeResource is not included
+ unless License.feature_available?(:remote_development)
+ raise ::Gitlab::Graphql::Errors::ResourceNotAvailable,
+ "'remote_development' licensed feature is not available"
+ end
+ # rubocop:enable Graphql/ResourceNotAvailableError
+
+ super
+ end
+
+ def preloads
+ {
+ user_provided_workspace_variables: [:user_provided_workspace_variables]
+ }
+ 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
index c8fd4f3d76efee4d10af6fe1c8a306c09ee20660..078f7c3979f974ced4e1856d7168b7fbc8417121 100644
--- a/ee/app/graphql/resolvers/remote_development/workspaces_resolver.rb
+++ b/ee/app/graphql/resolvers/remote_development/workspaces_resolver.rb
@@ -2,21 +2,11 @@
module Resolvers
module RemoteDevelopment
- class WorkspacesResolver < ::Resolvers::BaseResolver
- include ResolvesIds
+ class WorkspacesResolver < WorkspacesBaseResolver
include Gitlab::Graphql::Authorize::AuthorizeResource
type Types::RemoteDevelopment::WorkspaceType.connection_type, null: true
- argument :ids, [::Types::GlobalIDType[::RemoteDevelopment::Workspace]],
- required: false,
- description:
- 'Filter workspaces by workspace GlobalIDs. For example, `["gid://gitlab/RemoteDevelopment::Workspace/1"]`.'
-
- argument :project_ids, [::Types::GlobalIDType[Project]],
- required: false,
- description: 'Filter workspaces by project GlobalIDs.'
-
argument :agent_ids, [::Types::GlobalIDType[::Clusters::Agent]],
required: false,
description: 'Filter workspaces by agent GlobalIDs.'
@@ -26,24 +16,18 @@ class WorkspacesResolver < ::Resolvers::BaseResolver
deprecated: { reason: 'Use actual_states instead', milestone: '16.7' },
description: 'Filter workspaces by actual states.'
- argument :actual_states, [GraphQL::Types::String],
- required: false,
- description: 'Filter workspaces by actual states.'
-
- def resolve(**args)
- unless License.feature_available?(:remote_development)
- raise_resource_not_available_error! "'remote_development' licensed feature is not available"
- end
-
+ def resolve_with_lookahead(**args)
# noinspection RubyNilAnalysis - This is because the superclass #current_user uses #[], which can return nil
# TODO: Change the superclass to use context.fetch(:current_user) instead of context[:current_user]
- ::RemoteDevelopment::WorkspacesFinder.execute(
- current_user: current_user,
- user_ids: [current_user.id],
- ids: resolve_ids(args[:ids]).map(&:to_i),
- project_ids: resolve_ids(args[:project_ids]).map(&:to_i),
- agent_ids: resolve_ids(args[:agent_ids]).map(&:to_i),
- actual_states: args[:actual_states] || args[:include_actual_states] || []
+ apply_lookahead(
+ ::RemoteDevelopment::WorkspacesFinder.execute(
+ current_user: current_user,
+ user_ids: [current_user.id],
+ ids: resolve_ids(args[:ids]).map(&:to_i),
+ project_ids: resolve_ids(args[:project_ids]).map(&:to_i),
+ agent_ids: resolve_ids(args[:agent_ids]).map(&:to_i),
+ actual_states: args[:actual_states] || args[:include_actual_states] || []
+ )
)
end
end
diff --git a/ee/app/graphql/types/remote_development/workspace_type.rb b/ee/app/graphql/types/remote_development/workspace_type.rb
index 503ec2459d63864d7a2c0cead4c0417c243e646c..1a6f5a3135dbc2dfa168e7e3a8b16cccf3ba2c54 100644
--- a/ee/app/graphql/types/remote_development/workspace_type.rb
+++ b/ee/app/graphql/types/remote_development/workspace_type.rb
@@ -101,6 +101,12 @@ class WorkspaceType < ::Types::BaseObject
field :updated_at, Types::TimeType,
null: false, description: 'Timestamp of the last update to any mutable workspace property.'
+ field :workspace_variables,
+ ::Types::RemoteDevelopment::WorkspaceVariableType.connection_type,
+ null: true,
+ experiment: { milestone: '17.9' },
+ description: 'User defined variables associated with the workspace.', method: :user_provided_workspace_variables
+
def project_id
"gid://gitlab/Project/#{object.project_id}"
end
diff --git a/ee/app/graphql/types/remote_development/workspace_variable_input.rb b/ee/app/graphql/types/remote_development/workspace_variable_input.rb
index 349bceaaac60600984d11ce07e1579aa9029e18f..6238f566826fb8e35801996944e024c2660a551d 100644
--- a/ee/app/graphql/types/remote_development/workspace_variable_input.rb
+++ b/ee/app/graphql/types/remote_development/workspace_variable_input.rb
@@ -9,14 +9,23 @@ class WorkspaceVariableInput < BaseInputObject
# do not allow empty values. also validate that the key contains only alphanumeric characters, -, _ or .
# https://kubernetes.io/docs/concepts/configuration/secret/#restriction-names-data
argument :key, GraphQL::Types::String,
- description: 'Key of the variable.',
+ description: 'Name of the workspace variable.',
validates: {
allow_blank: false,
format: { with: /\A[a-zA-Z0-9\-_.]+\z/, message: 'must contain only alphanumeric characters, -, _ or .' }
}
argument :type, Types::RemoteDevelopment::WorkspaceVariableInputTypeEnum,
- description: 'Type of the variable to be injected in a workspace.'
+ required: false,
+ default_value: Types::RemoteDevelopment::WorkspaceVariableInputTypeEnum.environment,
+ replace_null_with_default: true,
+ description: 'Type of the variable to be injected in a workspace.',
+ deprecated: { reason: 'Use `variableType` instead', milestone: '17.9' }
argument :value, GraphQL::Types::String, description: 'Value of the variable.'
+ argument :variable_type, Types::RemoteDevelopment::WorkspaceVariableTypeEnum,
+ required: false,
+ default_value: Types::RemoteDevelopment::WorkspaceVariableTypeEnum.environment,
+ replace_null_with_default: true,
+ description: 'Type of the variable to be injected in a workspace.'
end
end
end
diff --git a/ee/app/graphql/types/remote_development/workspace_variable_input_type_enum.rb b/ee/app/graphql/types/remote_development/workspace_variable_input_type_enum.rb
index 88de95f8c275868a6c2f4dd48e6ff0561748a774..c01fd833febed79be0548fc35452d32b5138f738 100644
--- a/ee/app/graphql/types/remote_development/workspace_variable_input_type_enum.rb
+++ b/ee/app/graphql/types/remote_development/workspace_variable_input_type_enum.rb
@@ -10,6 +10,10 @@ class WorkspaceVariableInputTypeEnum < BaseEnum
::RemoteDevelopment::Enums::Workspace::WORKSPACE_VARIABLE_TYPES_FOR_GRAPHQL,
description: "#{%(name).capitalize} type."
)
+
+ def self.environment
+ enum[:environment]
+ end
end
end
end
diff --git a/ee/app/graphql/types/remote_development/workspace_variable_type.rb b/ee/app/graphql/types/remote_development/workspace_variable_type.rb
new file mode 100644
index 0000000000000000000000000000000000000000..3f31a3037686998530b95b0b5f33b69391b81ea9
--- /dev/null
+++ b/ee/app/graphql/types/remote_development/workspace_variable_type.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+module Types
+ module RemoteDevelopment
+ class WorkspaceVariableType < ::Types::BaseObject
+ graphql_name 'WorkspaceVariable'
+ description 'Represents a remote development workspace variable'
+
+ authorize :read_workspace_variable
+
+ field :id, ::Types::GlobalIDType[::RemoteDevelopment::WorkspaceVariable],
+ null: false, description: 'Global ID of the workspace variable.'
+
+ field :key, GraphQL::Types::String,
+ null: true, description: 'Name of the workspace variable.'
+
+ field :value, GraphQL::Types::String,
+ null: true, description: 'Value of the workspace variable.'
+
+ field :variable_type, Types::RemoteDevelopment::WorkspaceVariableTypeEnum,
+ null: true, description: 'Type of the workspace variable.'
+
+ field :created_at, Types::TimeType,
+ null: false, description: 'Timestamp of when the workspace variable was created.'
+
+ field :updated_at, Types::TimeType,
+ null: false, description: 'Timestamp of when the workspace variable was updated.'
+ end
+ end
+end
diff --git a/ee/app/graphql/types/remote_development/workspace_variable_type_enum.rb b/ee/app/graphql/types/remote_development/workspace_variable_type_enum.rb
new file mode 100644
index 0000000000000000000000000000000000000000..17114b61d747f70d8df027d6ef1427af2718ed73
--- /dev/null
+++ b/ee/app/graphql/types/remote_development/workspace_variable_type_enum.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Types
+ module RemoteDevelopment
+ class WorkspaceVariableTypeEnum < BaseEnum
+ graphql_name 'WorkspaceVariableType'
+ description 'Enum for the type of the variable injected in a workspace.'
+
+ ::RemoteDevelopment::Enums::Workspace::WORKSPACE_VARIABLE_TYPES.slice(:environment).each do |name, value|
+ value name.to_s.upcase, value: value, description: "#{name.to_s.capitalize} type."
+ end
+
+ def self.environment
+ enum[:environment]
+ end
+ end
+ end
+end
diff --git a/ee/app/models/remote_development/workspace.rb b/ee/app/models/remote_development/workspace.rb
index 1acb86e716c7d1e9b126e379d2ce82a8dec0e63f..140669c53b39d78ac4afa4726cc0b5ce91b6b98f 100644
--- a/ee/app/models/remote_development/workspace.rb
+++ b/ee/app/models/remote_development/workspace.rb
@@ -23,6 +23,10 @@ class Workspace < ApplicationRecord
# noinspection RailsParamDefResolve - https://handbook.gitlab.com/handbook/tools-and-tips/editors-and-ides/jetbrains-ides/tracked-jetbrains-issues/#ruby-31542
has_one :remote_development_agent_config, through: :agent, source: :remote_development_agent_config
has_many :workspace_variables, class_name: 'RemoteDevelopment::WorkspaceVariable', inverse_of: :workspace
+ # Currently we only support :environment type for user provided variables
+ has_many :user_provided_workspace_variables, -> {
+ user_provided.with_variable_type_environment.order_id_desc
+ }, class_name: 'RemoteDevelopment::WorkspaceVariable', inverse_of: :workspace
validates :user, presence: true
validates :agent, presence: true
diff --git a/ee/app/models/remote_development/workspace_variable.rb b/ee/app/models/remote_development/workspace_variable.rb
index 30466b091159b679cd1d873ad6ea4083d59f9529..890ce302128cacc5e5fb1cb03a60e7012f6e0155 100644
--- a/ee/app/models/remote_development/workspace_variable.rb
+++ b/ee/app/models/remote_development/workspace_variable.rb
@@ -2,6 +2,8 @@
module RemoteDevelopment
class WorkspaceVariable < ApplicationRecord
+ include Sortable
+
belongs_to :workspace, class_name: 'RemoteDevelopment::Workspace', inverse_of: :workspace_variables
validates :variable_type, presence: true, inclusion: {
@@ -19,6 +21,13 @@ class WorkspaceVariable < ApplicationRecord
where(variable_type: RemoteDevelopment::Enums::Workspace::WORKSPACE_VARIABLE_TYPES[:file])
}
+ scope :by_workspace_ids, ->(ids) { where(workspace_id: ids) }
+ scope :by_project_ids, ->(ids) { where(project_id: ids) }
+
+ scope :user_provided, -> {
+ where(user_provided: true)
+ }
+
attr_encrypted :value,
mode: :per_attribute_iv,
key: ::Settings.attr_encrypted_db_key_base_32,
diff --git a/ee/app/policies/remote_development/workspace_variable_policy.rb b/ee/app/policies/remote_development/workspace_variable_policy.rb
new file mode 100644
index 0000000000000000000000000000000000000000..35f057527f0e2f1fdd04d7b50e9d1866bdceabb2
--- /dev/null
+++ b/ee/app/policies/remote_development/workspace_variable_policy.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module RemoteDevelopment
+ class WorkspaceVariablePolicy < BasePolicy
+ condition(:can_read_workspace) { can?(:read_workspace, @subject.workspace) }
+
+ rule { can_read_workspace }.enable :read_workspace_variable
+ end
+end
diff --git a/ee/lib/remote_development/workspace_operations/create/workspace_variables.rb b/ee/lib/remote_development/workspace_operations/create/workspace_variables.rb
index 3a4ade8e84986ca53bb285d9c74f4bebd1999e77..4d65e083a731855803217cbd32c5982353ab4502 100644
--- a/ee/lib/remote_development/workspace_operations/create/workspace_variables.rb
+++ b/ee/lib/remote_development/workspace_operations/create/workspace_variables.rb
@@ -44,7 +44,7 @@ def self.variables(
resource_url_template: String => vscode_extensions_gallery_resource_url_template,
}
- static_variables = [
+ internal_variables = [
{
key: File.basename(RemoteDevelopment::WorkspaceOperations::FileMounts::GITLAB_TOKEN_FILE),
value: personal_access_token_value,
@@ -155,11 +155,12 @@ def self.variables(
key: variable.fetch(:key),
value: variable.fetch(:value),
variable_type: variable.fetch(:type),
+ user_provided: true,
workspace_id: workspace_id
}
end
- static_variables + user_provided_variables
+ internal_variables + user_provided_variables
end
end
end
diff --git a/ee/spec/factories/remote_development/workspace_variables.rb b/ee/spec/factories/remote_development/workspace_variables.rb
index 04b4ed3dcbffb188d716e18eefe50fd1efbf1095..a4b50e6d09b1a3bbc57350b44b099f5ee5082383 100644
--- a/ee/spec/factories/remote_development/workspace_variables.rb
+++ b/ee/spec/factories/remote_development/workspace_variables.rb
@@ -6,6 +6,7 @@
key { 'my_key' }
value { 'my_value' }
+ user_provided { false }
variable_type { RemoteDevelopment::Enums::Workspace::WORKSPACE_VARIABLE_TYPES[:file] }
end
end
diff --git a/ee/spec/graphql/types/remote_development/workspace_type_spec.rb b/ee/spec/graphql/types/remote_development/workspace_type_spec.rb
index 973a9109514834d4503614075d04e7f7e0144695..0ce217e3e87cf1d436192a4ee7712801cd9f39a7 100644
--- a/ee/spec/graphql/types/remote_development/workspace_type_spec.rb
+++ b/ee/spec/graphql/types/remote_development/workspace_type_spec.rb
@@ -10,6 +10,7 @@
url editor devfile_ref devfile_path devfile_web_url devfile processed_devfile
project_ref deployment_resource_version desired_config_generator_version
workspaces_agent_config_version force_include_all_resources created_at updated_at
+ workspace_variables
]
end
diff --git a/ee/spec/graphql/types/remote_development/workspace_variable_input_spec.rb b/ee/spec/graphql/types/remote_development/workspace_variable_input_spec.rb
index d78c46f788f1da249a71bdd74f3cf9e097c21b64..619fba35dd976cdd88853183cdd0a29c06a59ad3 100644
--- a/ee/spec/graphql/types/remote_development/workspace_variable_input_spec.rb
+++ b/ee/spec/graphql/types/remote_development/workspace_variable_input_spec.rb
@@ -8,6 +8,7 @@
key
type
value
+ variableType
]
end
diff --git a/ee/spec/graphql/types/remote_development/workspace_variable_type_spec.rb b/ee/spec/graphql/types/remote_development/workspace_variable_type_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..8ac3350b5b852991863a78c7b09ff7846911c1b1
--- /dev/null
+++ b/ee/spec/graphql/types/remote_development/workspace_variable_type_spec.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['WorkspaceVariable'], feature_category: :workspaces do
+ let(:fields) do
+ %i[
+ id key value variable_type created_at updated_at
+ ]
+ end
+
+ specify { expect(described_class.graphql_name).to eq('WorkspaceVariable') }
+
+ specify { expect(described_class).to have_graphql_fields(fields) }
+
+ specify { expect(described_class).to require_graphql_authorizations(:read_workspace_variable) }
+end
diff --git a/ee/spec/lib/remote_development/workspace_operations/create/workspace_variables_spec.rb b/ee/spec/lib/remote_development/workspace_operations/create/workspace_variables_spec.rb
index 5cf2f3451c86c3de5675b2a8651fcd6cdb4b2cf6..23386173597e5a61eb07cf6192d2aaedb64711cf 100644
--- a/ee/spec/lib/remote_development/workspace_operations/create/workspace_variables_spec.rb
+++ b/ee/spec/lib/remote_development/workspace_operations/create/workspace_variables_spec.rb
@@ -142,12 +142,14 @@
{
key: "VAR1",
value: "value 1",
+ user_provided: true,
variable_type: RemoteDevelopment::Enums::Workspace::WORKSPACE_VARIABLE_TYPES[:environment],
workspace_id: workspace_id
},
{
key: "/path/to/file",
value: "value 2",
+ user_provided: true,
variable_type: RemoteDevelopment::Enums::Workspace::WORKSPACE_VARIABLE_TYPES[:file],
workspace_id: workspace_id
}
diff --git a/ee/spec/policies/remote_development/workspace_variable_policy_spec.rb b/ee/spec/policies/remote_development/workspace_variable_policy_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..46ce96303bc9b2ca5882189f32b6bab1702c02eb
--- /dev/null
+++ b/ee/spec/policies/remote_development/workspace_variable_policy_spec.rb
@@ -0,0 +1,108 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe RemoteDevelopment::WorkspaceVariablePolicy, feature_category: :workspaces do
+ include AdminModeHelper
+ using RSpec::Parameterized::TableSyntax
+
+ let_it_be(:agent_project_creator, refind: true) { create(:user) }
+ let_it_be(:agent_project, refind: true) { create(:project, creator: agent_project_creator) }
+ let_it_be(:agent, refind: true) do
+ create(:ee_cluster_agent, :with_existing_workspaces_agent_config, project: agent_project)
+ end
+
+ let_it_be(:workspace_project_creator, refind: true) { create(:user) }
+ let_it_be(:workspace_project, refind: true) { create(:project, creator: workspace_project_creator) }
+ let_it_be(:workspace_owner, refind: true) { create(:user) }
+ let_it_be(:workspace, refind: true) do
+ create(:workspace, project: workspace_project, agent: agent, user: workspace_owner)
+ end
+
+ let_it_be(:workspace_variable, refind: true) do
+ create(:workspace_variable, workspace: workspace)
+ end
+
+ let_it_be(:admin_user, refind: true) { create(:admin) }
+ let_it_be(:non_admin_user, refind: true) { create(:user) }
+ # NOTE: The following need to be `let`, not `let_it_be`, because it uses a `let` declaration from the matrix
+ let(:user) { admin_mode ? admin_user : non_admin_user }
+
+ let(:policy_class) { described_class }
+
+ subject(:policy_instance) { policy_class.new(user, workspace_variable) }
+
+ before do
+ stub_licensed_features(remote_development: licensed)
+ enable_admin_mode!(user) if admin_mode
+ workspace.update!(user: user) if workspace_owner
+ agent_project.add_role(user, role_on_agent_project) unless role_on_agent_project == :none
+ agent_project.reload
+ # noinspection RubyResolve - https://handbook.gitlab.com/handbook/tools-and-tips/editors-and-ides/jetbrains-ides/tracked-jetbrains-issues/#ruby-31543
+ workspace_project.add_role(user, role_on_workspace_project) unless role_on_workspace_project == :none
+ workspace_project.reload
+ user.reload
+
+ debug = false # Set to true to enable debugging of policies, but change back to false before committing
+ debug_policies(user, workspace_variable, policy_class, ability) if debug
+ end
+
+ shared_examples 'fixture sanity checks' do
+ # noinspection RubyResolve -- Rubymine is incorrectly resolving workspace_project as `QA::Resource::Project`.
+ it "has fixture sanity checks" do
+ expect(agent_project.creator_id).not_to eq(workspace_project.creator_id)
+ expect(agent_project.creator_id).not_to eq(user.id)
+ expect(workspace_project.creator_id).not_to eq(user.id)
+ expect(agent.created_by_user_id).not_to eq(workspace.user_id)
+ expect(workspace.user_id).not_to eq(user.id) unless workspace_owner
+ end
+ end
+
+ # rubocop:disable Layout/LineLength -- TableSyntax should not be split across lines
+ where(:admin, :admin_mode, :licensed, :workspace_owner, :role_on_workspace_project, :role_on_agent_project, :allowed) do
+ # @formatter:off - Turn off RubyMine autoformatting
+
+ # admin | # admin_mode | # licensed | workspace_owner | role_on_workspace_project | role_on_agent_project | allowed # check
+ true | true | false | false | :none | :none | false # admin_mode enabled but not licensed: not allowed
+ false | false | false | true | :developer | :none | false # Workspace owner and project developer but not licensed: not allowed
+ false | false | true | true | :guest | :none | false # Workspace owner but project guest: not allowed
+ false | false | false | false | :none | :maintainer | false # Cluster agent admin but not licensed: not allowed
+ false | false | true | false | :none | :developer | false # Not a cluster agent admin (must be maintainer): not allowed
+ true | false | true | false | :none | :none | false # admin but admin_mode not enabled and licensed: not allowed
+ true | true | true | false | :none | :none | true # admin_mode enabled and licensed: allowed
+ false | false | true | true | :developer | :none | true # Workspace owner and project developer: allowed
+ false | false | true | false | :none | :maintainer | true # Cluster agent admin: allowed
+
+ # @formatter:on
+ end
+ # rubocop:enable Layout/LineLength
+
+ with_them do
+ # NOTE: Currently :read_workspace and :update_workspace abilities have identical rules, so we can test them with
+ # the same table checks. If their behavior diverges in the future, we'll need to duplicate the table checks.
+
+ describe "read_workspace_variable ability" do
+ let(:ability) { :read_workspace_variable }
+
+ it_behaves_like 'fixture sanity checks'
+
+ it { is_expected.to(allowed ? be_allowed(:read_workspace_variable) : be_disallowed(:read_workspace_variable)) }
+ end
+ end
+
+ # NOTE: Leaving this method here for future use. You can also set GITLAB_DEBUG_POLICIES=1. For more details, see:
+ # https://docs.gitlab.com/ee/development/permissions/custom_roles.html#refactoring-abilities
+ # This may be generalized in the future for use across all policy specs
+ # Issue: https://gitlab.com/gitlab-org/gitlab/-/issues/463453
+ def debug_policies(user, workspace, policy_class, ability)
+ puts "\n\nPolicy debug for #{policy_class} policy:\n"
+ puts "user: #{user.username} (id: #{user.id}, admin: #{user.admin?}, " \
+ "admin_mode: #{user && Gitlab::Auth::CurrentUserMode.new(user).admin_mode?}" \
+ ")\n"
+
+ policy = policy_class.new(user, workspace)
+ puts "debugging :#{ability} ability:\n\n"
+ pp policy.debug(ability)
+ puts "\n\n"
+ end
+end
diff --git a/ee/spec/requests/api/graphql/mutations/remote_development/workspace_operations/create_spec.rb b/ee/spec/requests/api/graphql/mutations/remote_development/workspace_operations/create_spec.rb
index b86b8d572868aacc83f3c875bc1157b592bb7982..0340378c23a1e822a5a5bb2abdb4be04a4c0fe7a 100644
--- a/ee/spec/requests/api/graphql/mutations/remote_development/workspace_operations/create_spec.rb
+++ b/ee/spec/requests/api/graphql/mutations/remote_development/workspace_operations/create_spec.rb
@@ -37,6 +37,20 @@
let(:desired_state) { RemoteDevelopment::WorkspaceOperations::States::RUNNING }
+ let(:mutation_expected_varaiables) do
+ [
+ { key: 'VAR1', value: 'value 1', type: 'ENVIRONMENT', variable_type: 'ENVIRONMENT' },
+ { key: 'VAR2', value: 'value 2', type: 'ENVIRONMENT', variable_type: 'ENVIRONMENT' }
+ ]
+ end
+
+ let(:service_class_expected_variables) do
+ [
+ { key: 'VAR1', value: 'value 1', type: 'ENVIRONMENT', variable_type: 0 },
+ { key: 'VAR2', value: 'value 2', type: 'ENVIRONMENT', variable_type: 0 }
+ ]
+ end
+
let(:all_mutation_args) do
{
desired_state: desired_state,
@@ -46,10 +60,7 @@
project_id: workspace_project.to_global_id.to_s,
project_ref: 'main',
devfile_path: '.devfile.yaml',
- variables: [
- { key: 'VAR1', value: 'value 1', type: 'ENVIRONMENT' },
- { key: 'VAR2', value: 'value 2', type: 'ENVIRONMENT' }
- ]
+ variables: mutation_expected_varaiables
}
end
@@ -61,6 +72,7 @@
let(:expected_service_args) do
params = all_mutation_args.except(:cluster_agent_id, :project_id)
+ params[:variables] = service_class_expected_variables
params[:agent] = agent
params[:user] = current_user
params[:project] = workspace_project
diff --git a/ee/spec/requests/api/graphql/remote_development/README.md b/ee/spec/requests/api/graphql/remote_development/README.md
index b7e0d69d659c31fa69d031e9f37335f50ccf5e72..9fbc5db7f6bee089385452453c23d8456fac45ca 100644
--- a/ee/spec/requests/api/graphql/remote_development/README.md
+++ b/ee/spec/requests/api/graphql/remote_development/README.md
@@ -14,7 +14,7 @@ Here are the related spec folders for the fields (in alphabetical order by resol
- GraphQL Field: `Query.workspaces`
- Spec folder: `ee/spec/requests/api/graphql/remote_development/workspaces`
- API docs: https://docs.gitlab.com/ee/api/graphql/reference/index.html#queryworkspaces
- - Resolver source file for `tests.yml` and `verify-tff-mapping`: `ee/app/graphql/resolvers/remote_development/admin_workspaces_resolver.rb`
+ - Resolver source file for `tests.yml` and `verify-tff-mapping`: `ee/app/graphql/resolvers/remote_development/workspaces_admin_resolver.rb`
- Notes: Only admins may use this field.
- GraphQL Field: `Query.project.clusterAgent.remoteDevelopmentAgentConfig`
@@ -51,6 +51,12 @@ Here are the related spec folders for the fields (in alphabetical order by resol
- Resolver source file for `tests.yml` and `verify-tff-mapping`: `ee/app/graphql/resolvers/remote_development/namespace/workspaces_resolver.rb`
- Notes: This is the same resolver used by `Query.currentUser.workspaces`
+- GraphQL Field: `Query.workspace.workspaceVariables`
+ - Spec folder: `ee/spec/requests/api/graphql/remote_development/workspace_variables`
+ - API docs: https://docs.gitlab.com/ee/api/graphql/reference/index.html##workspacevariableconnection
+ - Resolver source file for `tests.yml` and `verify-tff-mapping`: `ee/app/graphql/resolvers/remote_development/namespace/workspaces_resolver.rb`
+ - Notes: This is the same resolver used by `Query.currentUser.workspaces`
+
- GraphQL Field: `Query.currentUser.workspaces`
- Spec folder: `ee/spec/requests/api/graphql/remote_development/current_user/workspaces`
- API docs: https://docs.gitlab.com/ee/api/graphql/reference/index.html#currentuserworkspaces
@@ -75,6 +81,8 @@ Without this approach, achieving equivalent coverage across all of this same Gra
If you add new spec files, you should update `tests.yml` and `scripts/verify-tff-mapping` accordingly.
+Add entries for relevant types and resolvers in these files.
+
## Why aren't all individual fields of graphql types tested in these specs?
None of these other graphql API request specs test the actual fields because that’s not necessary.
diff --git a/ee/spec/requests/api/graphql/remote_development/workspace_variables/shared.rb b/ee/spec/requests/api/graphql/remote_development/workspace_variables/shared.rb
new file mode 100644
index 0000000000000000000000000000000000000000..0c8b04b69346d99f088ec62514a23ad724a4c3af
--- /dev/null
+++ b/ee/spec/requests/api/graphql/remote_development/workspace_variables/shared.rb
@@ -0,0 +1,128 @@
+# frozen_string_literal: true
+
+require_relative '../shared'
+
+RSpec.shared_context 'with multiple workspace_variables created' do
+ include_context 'with unauthorized workspace created'
+
+ let_it_be(:workspace, reload: true) { create(:workspace, name: 'matching-workspace') }
+
+ let(:id) { workspace.to_global_id.to_s }
+ let(:args) { { id: id } }
+
+ let_it_be(:workspace_a_internal_variable) do
+ create(:workspace_variable,
+ workspace_id: workspace.id,
+ key: 'GIT_CONFIG_COUNT',
+ value: 'internal_var',
+ variable_type: 0,
+ user_provided: false
+ )
+ end
+
+ let_it_be(:workspace_user_env_variable) do
+ create(:workspace_variable,
+ workspace_id: workspace.id,
+ key: 'GIT_CONFIG_KEY_1',
+ value: 'user_var_1',
+ variable_type: 0,
+ user_provided: true
+ )
+ end
+
+ let_it_be(:workspace_user_file_variable) do
+ create(:workspace_variable,
+ workspace_id: workspace.id,
+ key: 'CONFIG_FILE',
+ value: 'user_var_1',
+ variable_type: 1,
+ user_provided: true
+ )
+ end
+
+ let_it_be(:unauthorized_workspace_variable) do
+ create(:workspace_variable,
+ workspace_id: unauthorized_workspace.id,
+ key: 'OTHER_VAR',
+ value: 'other_var',
+ variable_type: 0,
+ user_provided: true
+ )
+ end
+end
+
+RSpec.shared_context 'for a Query.workspace.workspaceVariables query' do
+ include GraphqlHelpers
+
+ include_context 'with multiple workspace_variables created'
+ include_context "with authorized user as developer on workspace's project"
+
+ let(:query) do
+ fields = all_graphql_fields_for('WorkspaceVariable')
+
+ graphql_query_for(
+ :workspace,
+ args,
+ query_nodes(:workspace_variables, fields)
+ )
+ end
+
+ subject(:actual_workspace_variables) { graphql_dig_at(graphql_data, :workspace, :workspace_variables, :nodes) }
+end
+
+RSpec.shared_examples 'query returns an array containing all non-internal variables associated with a workspace' do
+ before do
+ post_graphql(query, current_user: current_user)
+ end
+
+ it 'includes only the expected workspace_variables', :unlimited_max_formatted_output_length do
+ expect(workspace_variable_keys).not_to include(unauthorized_workspace_variable.key)
+ expect(workspace_variable_keys).not_to include(workspace_user_file_variable.key)
+ expect(workspace_variable_keys).not_to include(workspace_a_internal_variable.key)
+
+ expect(workspace_variable_keys).to include(workspace_user_env_variable.key)
+ expect(workspace_variable_values).to include(workspace_user_env_variable.value)
+ end
+
+ it 'includes only the correct type of workspace_variables' do
+ expect(workspace_variable_types).to include(
+ ::RemoteDevelopment::Enums::Workspace::WORKSPACE_VARIABLE_TYPES.key(0).to_s.upcase
+ )
+ expect(workspace_variable_types).not_to include(
+ ::RemoteDevelopment::Enums::Workspace::WORKSPACE_VARIABLE_TYPES.key(1).to_s.upcase
+ )
+ end
+end
+
+RSpec.shared_examples 'multiple workspace_variables query' do |authorized_user_is_admin: false|
+ include_context 'in licensed environment'
+
+ let(:workspace_variable_keys) { subject.pluck("key") }
+ let(:workspace_variable_values) { subject.pluck("value") }
+ let(:workspace_variable_types) { subject.pluck("variableType") }
+
+ context 'when user is authorized' do
+ include_context 'with authorized user as current user'
+
+ it_behaves_like 'query is a working graphql query'
+ it_behaves_like 'query returns an array containing all non-internal variables associated with a workspace'
+
+ unless authorized_user_is_admin
+ context 'when the user requests a workspace that they are not authorized for' do
+ let(:id) { global_id_of(unauthorized_workspace) }
+
+ it_behaves_like 'query is a working graphql query'
+ it_behaves_like 'query returns blank'
+ end
+ end
+ end
+
+ context 'when user is not authorized' do
+ include_context 'with unauthorized user as current user'
+
+ it_behaves_like 'query is a working graphql query'
+ it_behaves_like 'query returns blank'
+ end
+
+ it_behaves_like 'query in unlicensed environment'
+end
diff --git a/ee/spec/requests/api/graphql/remote_development/workspace_variables/with_no_args_spec.rb b/ee/spec/requests/api/graphql/remote_development/workspace_variables/with_no_args_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..36565bb7702bb8e74fd7cf61d675dad6c19ae00f
--- /dev/null
+++ b/ee/spec/requests/api/graphql/remote_development/workspace_variables/with_no_args_spec.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_relative './shared'
+
+RSpec.describe 'Query.workspace.workspaceVariables (with no arguments)', feature_category: :workspaces do
+ include_context 'for a Query.workspace.workspaceVariables query'
+
+ context 'with non-admin user' do
+ let_it_be(:authorized_user) { workspace.user }
+ let_it_be(:unauthorized_user) { create(:user) }
+
+ it_behaves_like 'multiple workspace_variables query'
+ end
+
+ context 'with admin user' do
+ let_it_be(:authorized_user) { create(:admin) }
+ let_it_be(:unauthorized_user) { create(:user) }
+
+ it_behaves_like 'multiple workspace_variables query', authorized_user_is_admin: true
+ end
+end
diff --git a/ee/spec/requests/remote_development/integration_spec.rb b/ee/spec/requests/remote_development/integration_spec.rb
index 184059b152b59441970248f5461c82f9d0f22b08..4a2fdb077c15806dc3f899a39b5f2c372647ad2e 100644
--- a/ee/spec/requests/remote_development/integration_spec.rb
+++ b/ee/spec/requests/remote_development/integration_spec.rb
@@ -87,7 +87,7 @@
]
end
- let(:expected_static_variables) do
+ let(:expected_internal_variables) do
# rubocop:disable Layout/LineLength -- keep them on one line for easier readability and editability
[
{ key: "GIT_CONFIG_COUNT", type: :environment, value: "3" },
@@ -237,15 +237,20 @@ def do_create_workspace
expect(actual_processed_devfile.fetch(:components)).to eq(expected_processed_devfile.fetch(:components))
expect(actual_processed_devfile).to eq(expected_processed_devfile)
- all_expected_vars = (expected_static_variables + user_provided_variables).sort_by { |v| v[:key] }
+ all_expected_vars = (expected_internal_variables + user_provided_variables).sort_by { |v| v[:key] }
# NOTE: We convert the actual records into hashes and sort them as a hash rather than ordering in
# ActiveRecord, to account for platform- or db-specific sorting differences.
types = RemoteDevelopment::Enums::Workspace::WORKSPACE_VARIABLE_TYPES
- all_actual_vars =
- RemoteDevelopment::WorkspaceVariable
- .where(workspace: workspace)
- .map { |v| { key: v.key, type: types.invert[v.variable_type], value: v.value } }
- .sort_by { |v| v[:key] }
+ all_actual_vars = RemoteDevelopment::WorkspaceVariable.where(workspace: workspace)
+
+ actual_user_provided_vars = all_actual_vars.select(&:user_provided)
+
+ all_actual_vars = all_actual_vars.map { |v| { key: v.key, type: types.invert[v.variable_type], value: v.value } }
+ .sort_by { |v| v[:key] }
+
+ # Check that user provided variables had their flag set correctly.
+ expect(actual_user_provided_vars.count).to eq(user_provided_variables.count)
+ expect(actual_user_provided_vars[0][:key]).to eq(user_provided_variables[0][:key])
# Check just keys first, to get an easy failure message if a new key has been added
expect(all_actual_vars.pluck(:key)).to match_array(all_expected_vars.pluck(:key))
@@ -255,7 +260,7 @@ def do_create_workspace
actual_without_regexes = all_actual_vars.reject { |v| v[:key] == "gl_token" }
expect(expected_without_regexes).to match(actual_without_regexes)
- expected_gl_token_value = expected_static_variables.find { |var| var[:key] == "gl_token" }[:value]
+ expected_gl_token_value = expected_internal_variables.find { |var| var[:key] == "gl_token" }[:value]
actual_gl_token_value = all_actual_vars.find { |var| var[:key] == "gl_token" }[:value]
expect(actual_gl_token_value).to match(expected_gl_token_value)
diff --git a/scripts/verify-tff-mapping b/scripts/verify-tff-mapping
index 0bd94ee6f6f642cf2739cc7d65268ffe7582d880..689f9ab1687621be4e0943aa862ed853170cac23 100755
--- a/scripts/verify-tff-mapping
+++ b/scripts/verify-tff-mapping
@@ -472,18 +472,6 @@ tests = [
## END Remote development GraphQL mutations
## BEGIN Remote development GraphQL resolvers (in alphabetical order by resolver source file path)
- {
- explanation: 'Map Remote Development GraphQL query root admin_workspaces_resolver.rb to request specs',
- changed_file: 'ee/app/graphql/resolvers/remote_development/admin_workspaces_resolver.rb',
- expected: %w[
- ee/spec/requests/api/graphql/remote_development/workspace/with_id_arg_spec.rb
- ee/spec/requests/api/graphql/remote_development/workspaces/with_actual_states_arg_spec.rb
- ee/spec/requests/api/graphql/remote_development/workspaces/with_agent_ids_arg_spec.rb
- ee/spec/requests/api/graphql/remote_development/workspaces/with_ids_arg_spec.rb
- ee/spec/requests/api/graphql/remote_development/workspaces/with_no_args_spec.rb
- ee/spec/requests/api/graphql/remote_development/workspaces/with_project_ids_arg_spec.rb
- ]
- },
{
explanation:
'Map Remote Development GraphQL cluster_agent/remote_development_agent_config_resolver.rb to request specs',
@@ -502,6 +490,7 @@ tests = [
ee/spec/requests/api/graphql/remote_development/cluster_agent/workspaces/with_ids_arg_spec.rb
ee/spec/requests/api/graphql/remote_development/cluster_agent/workspaces/with_no_args_spec.rb
ee/spec/requests/api/graphql/remote_development/cluster_agent/workspaces/with_project_ids_arg_spec.rb
+ ee/spec/requests/api/graphql/remote_development/workspace_variables/with_no_args_spec.rb
]
},
{
@@ -526,6 +515,19 @@ tests = [
]
# rubocop:enable Layout/LineLength
},
+ {
+ explanation: 'Map Remote Development GraphQL query root workspaces_admin_resolver.rb to request specs',
+ changed_file: 'ee/app/graphql/resolvers/remote_development/workspaces_admin_resolver.rb',
+ expected: %w[
+ ee/spec/requests/api/graphql/remote_development/workspace/with_id_arg_spec.rb
+ ee/spec/requests/api/graphql/remote_development/workspaces/with_actual_states_arg_spec.rb
+ ee/spec/requests/api/graphql/remote_development/workspaces/with_agent_ids_arg_spec.rb
+ ee/spec/requests/api/graphql/remote_development/workspaces/with_ids_arg_spec.rb
+ ee/spec/requests/api/graphql/remote_development/workspaces/with_no_args_spec.rb
+ ee/spec/requests/api/graphql/remote_development/workspaces/with_project_ids_arg_spec.rb
+ ee/spec/requests/api/graphql/remote_development/workspace_variables/with_no_args_spec.rb
+ ]
+ },
{
explanation: 'Map Remote Development GraphQL workspaces_resolver.rb to request specs',
changed_file: 'ee/app/graphql/resolvers/remote_development/workspaces_resolver.rb',
@@ -536,9 +538,12 @@ tests = [
ee/spec/requests/api/graphql/remote_development/current_user/workspaces/with_ids_arg_spec.rb
ee/spec/requests/api/graphql/remote_development/current_user/workspaces/with_no_args_spec.rb
ee/spec/requests/api/graphql/remote_development/current_user/workspaces/with_project_ids_arg_spec.rb
+ ee/spec/requests/api/graphql/remote_development/workspace_variables/with_no_args_spec.rb
]
},
+ ## END Remote development GraphQL resolvers
+ ## BEGIN Remote development GraphQL types
{
explanation: 'Map Remote Development GraphQL query root workspace type resolver to request specs',
changed_file: 'ee/app/graphql/types/remote_development/workspace_type.rb',
@@ -547,7 +552,37 @@ tests = [
ee/spec/requests/api/graphql/remote_development/workspace/with_id_arg_spec.rb
]
},
- ## END Remote development GraphQL resolvers
+ {
+ explanation: 'Map Remote Development GraphQL workspace_variable_type.rb to request specs',
+ changed_file: 'ee/app/graphql/types/remote_development/workspace_variable_type.rb',
+ expected: %w[
+ ee/spec/graphql/types/remote_development/workspace_variable_type_spec.rb
+ ee/spec/requests/api/graphql/remote_development/workspace_variables/with_no_args_spec.rb
+ ]
+ },
+ {
+ explanation: 'Map Remote Development GraphQL workspace_variable_input_type_enum.rb to request specs',
+ changed_file: 'ee/app/graphql/types/remote_development/workspace_variable_input.rb',
+ expected: %w[
+ ee/spec/graphql/types/remote_development/workspace_variable_input_spec.rb
+ ee/spec/requests/api/graphql/mutations/remote_development/workspace_operations/create_spec.rb
+ ]
+ },
+ {
+ explanation: 'Map Remote Development GraphQL workspace_variable_type_enum.rb to request specs',
+ changed_file: 'ee/app/graphql/types/remote_development/workspace_variable_type_enum.rb',
+ expected: %w[
+ ee/spec/requests/api/graphql/remote_development/workspace_variables/with_no_args_spec.rb
+ ]
+ },
+ {
+ explanation: 'Map Remote Development GraphQL workspace_variable_input_type_enum.rb to request specs',
+ changed_file: 'ee/app/graphql/types/remote_development/workspace_variable_input_type_enum.rb',
+ expected: %w[
+ ee/spec/requests/api/graphql/mutations/remote_development/workspace_operations/create_spec.rb
+ ]
+ },
+ ## END Remote development GraphQL types
{
explanation: 'https://gitlab.com/gitlab-org/gitlab/-/issues/466068#note_1987834618',
diff --git a/spec/migrations/20250114194610_backfill_user_provided_workspace_variables_spec.rb b/spec/migrations/20250114194610_backfill_user_provided_workspace_variables_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..eaa67992b0ddcb28d88c72a69a1494654e9706e1
--- /dev/null
+++ b/spec/migrations/20250114194610_backfill_user_provided_workspace_variables_spec.rb
@@ -0,0 +1,119 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe BackfillUserProvidedWorkspaceVariables, feature_category: :workspaces do
+ let(:user) { table(:users).create!(name: 'test-user', email: 'test@example.com', projects_limit: 5) }
+ let(:organization) { table(:organizations).create!(name: 'test-org', path: 'default') }
+ let(:namespace) { table(:namespaces).create!(name: 'name', path: 'path', organization_id: organization.id) }
+ let(:workspace_variables) { table(:workspace_variables) }
+
+ let(:project) do
+ table(:projects).create!(namespace_id: namespace.id, project_namespace_id: namespace.id,
+ organization_id: organization.id)
+ end
+
+ let!(:personal_access_token) do
+ table(:personal_access_tokens).create!(
+ user_id: user.id,
+ name: 'token_name',
+ organization_id: organization.id,
+ expires_at: Time.now
+ )
+ end
+
+ let(:cluster_agent) { table(:cluster_agents).create!(name: 'remotedev', project_id: project.id) }
+
+ let!(:agent) do
+ table(:workspaces_agent_configs).create!(
+ cluster_agent_id: cluster_agent.id,
+ enabled: true,
+ dns_zone: 'test.workspace.me',
+ project_id: project.id
+ )
+ end
+
+ let!(:agent_config_version) do
+ table(:workspaces_agent_config_versions).create!(
+ project_id: project.id,
+ item_id: agent.id,
+ item_type: 'RemoteDevelopment::WorkspacesAgentConfig',
+ event: 'create'
+ )
+ end
+
+ let!(:workspace) do
+ table(:workspaces).create!(
+ user_id: user.id,
+ project_id: project.id,
+ cluster_agent_id: cluster_agent.id,
+ desired_state_updated_at: Time.now,
+ responded_to_agent_at: Time.now,
+ name: 'workspace-1',
+ namespace: 'workspace_1_namespace',
+ desired_state: 'Running',
+ actual_state: 'Running',
+ project_ref: 'devfile-ref',
+ devfile_path: 'devfile-path',
+ devfile: 'devfile',
+ processed_devfile: 'processed_dev_file',
+ url: 'workspace-url',
+ deployment_resource_version: 'v1',
+ personal_access_token_id: personal_access_token.id,
+ max_hours_before_termination: 5760,
+ workspaces_agent_config_version: agent_config_version.id,
+ desired_config_generator_version: 3
+ )
+ end
+
+ let!(:workspace_static_file_variable) do
+ workspace_variables.create!(
+ workspace_id: workspace.id,
+ project_id: project.id,
+ key: 'gl_token',
+ variable_type: 1,
+ encrypted_value: 'encrypted_value',
+ encrypted_value_iv: 'encrypted_value_iv'
+ )
+ end
+
+ let!(:workspace_static_env_variable) do
+ workspace_variables.create!(
+ workspace_id: workspace.id,
+ project_id: project.id,
+ key: 'GIT_CONFIG_COUNT',
+ variable_type: 0,
+ encrypted_value: 'encrypted_value',
+ encrypted_value_iv: 'encrypted_value_iv'
+ )
+ end
+
+ let!(:workspace_user_provided_variable) do
+ workspace_variables.create!(
+ workspace_id: workspace.id,
+ project_id: project.id,
+ key: 'variable_key',
+ variable_type: 0,
+ encrypted_value: 'encrypted_value',
+ encrypted_value_iv: 'encrypted_value_iv'
+ )
+ end
+
+ it 'sets user_provided to true for all non-internal environment variables in the table' do
+ reversible_migration do |migration|
+ migration.before -> {
+ expect(workspace_variables.pluck(:user_provided)).to all(be false)
+ }
+
+ migration.after -> {
+ user_provided_variable = workspace_variables.where(variable_type: described_class::VARIABLE_ENV_TYPE)
+ .where.not(key: described_class::WORKSPACE_INTERNAL_VARIABLES).first
+
+ expect(workspace_static_file_variable.user_provided).to be(false)
+ expect(workspace_static_env_variable.user_provided).to be(false)
+ expect(user_provided_variable.user_provided).to be(true)
+ }
+ end
+ end
+end
diff --git a/tests.yml b/tests.yml
index 6c4a2fa01686e3e9f50e73d60f282454d645356b..0c9b2463dc0c7a997d20a8fd1e005816dffdad80 100644
--- a/tests.yml
+++ b/tests.yml
@@ -124,10 +124,11 @@ mapping:
test: 'spec/mailers/previews_spec.rb'
## BEGIN Remote development GraphQL resolvers (in alphabetical order by resolver source file path)
- - source: 'ee/app/graphql/resolvers/remote_development/admin_workspaces_resolver\.rb'
+ - source: 'ee/app/graphql/resolvers/remote_development/workspaces_admin_resolver\.rb'
test:
- 'ee/spec/requests/api/graphql/remote_development/workspace/*_spec.rb'
- 'ee/spec/requests/api/graphql/remote_development/workspaces/*_spec.rb'
+ - 'ee/spec/requests/api/graphql/remote_development/workspace_variables/*_spec.rb'
- source: 'ee/app/graphql/resolvers/remote_development/cluster_agent/remote_development_agent_config_resolver\.rb'
test:
@@ -140,10 +141,12 @@ mapping:
- source: 'ee/app/graphql/resolvers/remote_development/cluster_agent/workspaces_resolver\.rb'
test:
- 'ee/spec/requests/api/graphql/remote_development/cluster_agent/workspaces/*_spec.rb'
+ - 'ee/spec/requests/api/graphql/remote_development/workspace_variables/*_spec.rb'
- source: 'ee/app/graphql/resolvers/remote_development/workspaces_resolver\.rb'
test:
- 'ee/spec/requests/api/graphql/remote_development/current_user/workspaces/*_spec.rb'
+ - 'ee/spec/requests/api/graphql/remote_development/workspace_variables/*_spec.rb'
- source: 'ee/app/graphql/resolvers/remote_development/namespace/cluster_agents_resolver\.rb'
test:
@@ -154,13 +157,35 @@ mapping:
test:
- 'ee/spec/requests/api/graphql/remote_development/current_user/workspaces/*_spec.rb'
- 'ee/spec/requests/api/graphql/remote_development/workspace/*_spec.rb'
+ - 'ee/spec/requests/api/graphql/remote_development/workspace_variables/*_spec.rb'
## END Remote development GraphQL resolvers
+ ## BEGIN Remote development GraphQL types
+
- source: 'ee/app/graphql/types/remote_development/workspace_type\.rb'
test:
- 'ee/spec/requests/api/graphql/remote_development/workspace/with_id_arg_spec.rb'
+ - source: 'ee/app/graphql/types/remote_development/workspace_variable_input\.rb'
+ test:
+ - 'ee/spec/graphql/types/remote_development/workspace_variable_input_spec.rb'
+ - 'ee/spec/requests/api/graphql/mutations/remote_development/workspace_operations/create_spec.rb'
+
+ - source: 'ee/app/graphql/types/remote_development/workspace_variable_type\.rb'
+ test:
+ - 'ee/spec/requests/api/graphql/remote_development/workspace_variables/with_no_args_spec.rb'
+
+ - source: 'ee/app/graphql/types/remote_development/workspace_variable_type_enum\.rb'
+ test:
+ - 'ee/spec/requests/api/graphql/remote_development/workspace_variables/with_no_args_spec.rb'
+
+ - source: 'ee/app/graphql/types/remote_development/workspace_variable_input_type_enum\.rb'
+ test:
+ - 'ee/spec/requests/api/graphql/mutations/remote_development/workspace_operations/create_spec.rb'
+
+ ## END Remote development GraphQL types
+
# Usage metric schema changes should trigger validations for all metrics and tooling
- source: 'config/metrics/schema/.*\.json'
test: