InfoGrab DocsInfoGrab Docs

Enterprise Edition 기능 구현 가이드라인

요약

다음 다이어그램은 CE/EE/SaaS/Dedicated 계층 전반에서 기능을 어디에 어떻게 구현할지 결정하는 방법을 보여 줍니다. 이 다이어그램은 네 가지 주요 구현 계층을 보여 줍니다. SaaS에만 적용되는 기능(예: CustomersDot 통합)을 개발할 때는 다음 지침을 따릅니다.

  • 코드는 ee/에 둡니다: 모든 Enterprise Edition(EE) 코드를 최상위 ee/ 디렉터리 안에 둡니다. 나머지 코드는 Community Edition(CE) 파일과 최대한 가깝게 유지해야 합니다.
  • 테스트를 작성합니다: 다른 코드와 마찬가지로 EE 기능도 회귀를 방지할 수 있도록 충분한 테스트 커버리지를 갖춰야 합니다. ee/의 모든 코드에는 ee/ 안에 대응하는 테스트가 있어야 합니다.
  • 문서를 작성합니다.: doc/ 디렉터리에 문서를 추가합니다. 기능을 설명하고 해당하는 경우 스크린샷을 포함합니다. 기능이 적용되는 에디션을 명시합니다.
  • www-gitlab-com 프로젝트에 MR을 제출합니다.: 새 기능을 EE 기능 목록에 추가합니다.

개발 환경의 런타임 모드#

  1. EE Unlicensed: 메인 리포지터리에서 설치했다면 일반 GDK 설치 상태가 이 모드입니다.
  2. EE licensed: GDK에 유효한 라이선스를 추가한 경우입니다.
  3. GitLab.com: SaaS를 시뮬레이션하는 경우입니다.
  4. CE: 위의 어느 상태에서든 CE를 시뮬레이션하는 경우입니다.

기능 구현 결정 흐름#

다음 다이어그램은 CE/EE/SaaS/Dedicated 계층 전반에서 기능을 어디에 어떻게 구현할지 결정하는 방법을 보여 줍니다.

Mermaid 다이어그램 (57줄)
소스 코드 보기
%%{init: { "fontFamily": "GitLab Sans" }}%%
flowchart TD
    accTitle: Feature implementation decision flow
    accDescr: Diagram showing how to decide where and how to implement features across CE/EE/SaaS/Dedicated layers
A[Developer wants to implement a feature] --> B{What type of feature?}

B -->|CE Feature| C[Implement in main codebase]
B -->|EE Licensed Feature| D[EE Feature Path]
B -->|SaaS-only Feature| E[SaaS Feature Path]
B -->|Dedicated Feature| F[Dedicated Feature Path]

C --> C1[Place code in app/, lib/, etc.]
C --> C2[Write tests in spec/]
C --> C3[No license checks needed]

D --> D1{New or extending existing?}
D1 -->|New EE Feature| D2[Place in ee/ directory]
D1 -->|Extending CE| D3[Create EE module with prepend_mod]

D2 --> D4[Add to ee/app/models/gitlab_subscriptions/features.rb]
D3 --> D4
D4 --> D5{Which plan?}
D5 -->|Premium| D6[Add to PREMIUM_FEATURES]
D5 -->|Ultimate| D7[Add to ULTIMATE_FEATURES]
D5 -->|Global/Instance| D8[Add to GLOBAL_FEATURES]

D6 --> D9[Guard with project.licensed_feature_available?]
D7 --> D9
D8 --> D10[Guard with License.feature_available?]
D9 --> D11[Write tests in ee/spec/]
D10 --> D11
D11 --> D12[Use stub_licensed_features in tests]

E --> E1[Add feature to FEATURES in ee/lib/ee/gitlab/saas.rb]
E1 --> E2[Create YAML definition in ee/config/saas_features/]
E2 --> E3[Use bin/saas-feature.rb tool]
E3 --> E4[Guard with Gitlab::Saas.feature_available?]
E4 --> E5{Extending CE feature?}
E5 -->|Yes| E6[Create EE module that extends CE]
E5 -->|No| E7[Create new EE-only code]
E6 --> E8[Use prepend_mod pattern]
E7 --> E9[Place directly in ee/ directory]
E8 --> E10[Write tests in ee/spec/]
E9 --> E10
E10 --> E11[Use stub_saas_features helper]

F --> F1[Add to FEATURES in ee/lib/gitlab/dedicated.rb]
F1 --> F2[Create YAML definition with bin/dedicated-feature.rb]
F2 --> F3{Extending CE feature?}
F3 -->|Yes| F4[Create EE module that extends CE]
F3 -->|No| F5[Create new EE-only code]
F4 --> F6[Use prepend_mod pattern]
F5 --> F7[Place directly in ee/ directory]
F6 --> F8[Guard with Gitlab::Dedicated.feature_available?]
F7 --> F8
F8 --&gt; F9[Write tests in ee/spec/]</code></pre></details></div>

이 다이어그램은 네 가지 주요 구현 계층을 보여 줍니다.

  • CE (녹색): 라이선스 요구 사항이 없는 Community Edition 기능입니다. 대상 사용자가 GitLab.com의 무료 사용자라면 SaaS 결정 경로를 따릅니다.
  • EE (주황색): Premium/Ultimate 라이선스가 필요한 Enterprise Edition 기능입니다.
  • SaaS (분홍색): GitLab.com 인스턴스에서만 제공되는 기능입니다.
  • Dedicated (파란색): GitLab Dedicated 인스턴스에서 다르게 동작하는 기능입니다.

주요 결정 사항은 다음과 같습니다.

  • 파일 위치: CE 코드는 기본 디렉터리에, EE 코드는 ee/ 하위 디렉터리에 둡니다.
  • 기능 가드: 계층마다 방법이 다릅니다(licensed_feature_available?, License.feature_available?, Gitlab::Saas.feature_available?, Gitlab::Dedicated.feature_available?).
  • 테스트 방식: 계층마다 전용 헬퍼와 메타데이터가 있습니다.

SaaS 전용 기능#

SaaS에만 적용되는 기능(예: CustomersDot 통합)을 개발할 때는 다음 지침을 따릅니다.

일반적으로 기능은 SaaS와 Self-managed 배포 모두에 제공해야 합니다. 그러나 기능을 SaaS에서만 제공해야 하는 경우가 있으며, 이 가이드는 그 방법을 설명합니다.

Gitlab::Saas.feature_available?를 사용하는 것을 권장합니다. 이렇게 하면 해당 기능이 SaaS 전용인 이유에 대한 풍부한 컨텍스트 정의를 남길 수 있습니다.

Gitlab::Saas.feature_available?로 SaaS 전용 기능 구현#

FEATURES 상수에 추가#

  1. 새 SaaS 전용 기능의 이름을 정할 때는 네임스페이스 개념 가이드를 참고합니다.

  2. ee/lib/gitlab/saas.rb의 FEATURE에 새 기능을 추가합니다.

    FEATURES = %i[purchases_additional_minutes some_domain_new_feature_name].freeze
    
  3. 코드에서 Gitlab::Saas.feature_available?(:some_domain_new_feature_name)로 새 기능을 사용합니다.

SaaS 전용 기능 정의 및 검증#

이 절차는 코드베이스에서 SaaS 기능이 일관되게 사용되도록 하기 위한 것입니다. 모든 SaaS 기능은 다음을 충족해야 합니다.

  • 알려져 있어야 합니다. 명시적으로 정의된 SaaS 기능만 사용합니다.
  • 소유자가 있어야 합니다.

모든 SaaS 기능은 다음 위치에 저장된 YAML 파일에 스스로 문서화됩니다.

각 SaaS 기능은 여러 필드로 구성된 별도의 YAML 파일로 정의합니다.

필드 필수 설명
name 예 SaaS 기능의 이름입니다.
introduced_by_url 아니요 SaaS 기능을 도입한 머지 리퀘스트의 URL입니다.
milestone 아니요 SaaS 기능이 만들어진 마일스톤입니다.
group 아니요 해당 기능 플래그를 소유한 그룹입니다.

새 SaaS 기능 파일 정의 생성#

GitLab 코드베이스는 새 SaaS 기능 정의를 만드는 전용 도구인 bin/saas-feature.rb를 제공합니다. 이 도구는 새 SaaS 기능에 대해 여러 질문을 한 다음 ee/config/saas_features에 YAML 정의를 생성합니다.

YAML 정의 파일이 있는 SaaS 기능만 개발 또는 테스트 환경을 실행할 때 사용할 수 있습니다.

❯ bin/saas-feature.rb my_saas_feature
You picked the group 'group::acquisition'

>> URL of the MR introducing the SaaS feature (enter to skip and let Danger provide a suggestion directly in the MR):
?> https://gitlab.com/gitlab-org/gitlab/-/merge_requests/38602
create ee/config/saas_features/my_saas_feature.yml
---
name: my_saas_feature
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/38602
milestone: '16.8'
group: group::acquisition

다른 SaaS 인스턴스(JiHu)에서 SaaS 전용 기능 제외#

ee/lib/gitlab/saas.rb 클래스를 prepend 하고 Gitlab::Saas.feature_available? 메서드를 오버라이드합니다.

JH_DISABLED_FEATURES = %i[some_domain_new_feature_name].freeze

override :feature_available?
def feature_available?(feature)
  super && JH_DISABLED_FEATURES.exclude?(feature)
end

CE의 기능에 SaaS 전용 기능을 사용하지 않음#

Gitlab::Saas.feature_available?는 CE에 나타나서는 안 됩니다. EE로 CE 확장 가이드를 참고합니다.

테스트의 SaaS 전용 기능#

SaaS 전용 기능을 코드베이스에 도입하면 테스트해야 할 코드 경로가 추가됩니다. SaaS 전용 기능의 영향을 받는 모든 코드에 대해 기능이 활성화된 경우와 비활성화된 경우 모두 자동화된 테스트를 포함하여 기능이 올바르게 동작하는지 확인합니다.

애플리케이션 코드에서 어떤 것이 SaaS 전용인 이유를 전달하기 위해 Gitlab.com? 대신 Gitlab::Saas.feature_available?(:specific_feature)를 사용하는 것처럼, 테스트에서도 같은 이유로 구체적인 SaaS 기능 메타데이터 태그를 사용해야 합니다. 이렇게 하면 기능 구현과 테스트 사이에 명확한 연결이 생겨 코드베이스의 유지 관리성이 높아지고 스스로 문서화됩니다.

SaaS 기능 메타데이터 태그 사용(권장)#

대부분의 테스트 시나리오에서는 stub_saas_features를 직접 호출하지 않고 메타데이터 태그로 SaaS 기능을 자동으로 활성화합니다. 이 방식은 통합 테스트이거나 테스트 컨텍스트 전체에서 SaaS 기능을 활성화해야 할 때 특히 유용합니다.

SaaS 기능 이름 앞에 saas_를 붙여 테스트 컨텍스트 또는 개별 예제에 메타데이터로 추가합니다.

# Context-level metadata (applies to all examples in the context)
describe 'some feature', :saas_gitlab_com_subscriptions do
  it 'shows SaaS-specific functionality' do
    expect(page).to have_content('SaaS Feature')
  end
end

# Individual example metadata
describe 'some feature' do
  it 'shows SaaS-specific functionality', :saas_gitlab_com_subscriptions do
    expect(page).to have_content('SaaS Feature')
  end

  it 'works without SaaS features' do
    expect(page).not_to have_content('SaaS Feature')
  end
end

# Multiple SaaS features
context 'with multiple SaaS features', :saas_onboarding, :saas_gitlab_com_subscriptions do
  # Both 'onboarding' and 'duo_enterprise' features are enabled
end

이 메타데이터 방식은 다음과 같이 동작합니다.

  • 태그가 지정된 각 기능에 대해 stub_saas_features(feature_name: true)를 자동으로 호출합니다.
  • 컨텍스트 수준(describe/context 블록)과 개별 예제 수준(it 블록) 모두에서 동작합니다.
  • Gitlab::Saas::FEATURES에 정의된 모든 SaaS 기능에서 동작합니다.
  • before 블록에서 stub_saas_features를 직접 호출하는 것보다 깔끔합니다.

테스트 컨텍스트나 특정 예제에서 SaaS 기능을 활성화해야 할 때 이 방식을 사용합니다. 더 세밀한 제어가 필요하거나 같은 예제 안에서 활성화/비활성화 상태를 모두 테스트해야 할 때는 계속 stub_saas_features 헬퍼를 직접 사용합니다.

stub_saas_features 헬퍼 사용(고급 시나리오)#

기능 상태를 세밀하게 제어해야 하거나 같은 테스트 안에서 활성화/비활성화 경로를 모두 테스트해야 하는 복잡한 시나리오에서는 stub_saas_features 헬퍼를 직접 사용합니다.

테스트에서 SaaS 전용 기능을 활성화하려면 stub_saas_features 헬퍼를 사용합니다.

stub_saas_features(purchases_additional_minutes: true)

::Gitlab::Saas.feature_available?(:purchases_additional_minutes) # => true

두 경로를 모두 테스트하는 일반적인 패턴은 다음과 같습니다.

it 'purchases/additional_minutes is not available by default' do
  # tests assuming purchases_additional_minutes is not enabled by default
  ::Gitlab::Saas.feature_available?(:purchases_additional_minutes) # => false
end

context 'when purchases_additional_minutes is available' do
  before do
    stub_saas_features(purchases_additional_minutes: true)
  end

  it 'returns true' do
    ::Gitlab::Saas.feature_available?(:purchases_additional_minutes) # => true
  end
end

:saas 메타데이터 헬퍼 사용(특정 시나리오)#

:saas 메타데이터 헬퍼는 코드가 구체적인 SaaS 기능이 아니라 Gitlab.com? 방식에 의존하는 특정 시나리오에서 사용합니다. 여기에는 다음이 포함됩니다.

  • 아직 구체적인 SaaS 기능을 사용하도록 변환되지 않은 코드
  • 데이터베이스 마이그레이션처럼 Gitlab.com? 검사가 적절한 방식인 영역(SaaS 기능 패턴의 예외)

새 SaaS 전용 기능에는 SaaS 기능 메타데이터 태그를 대신 사용합니다.

테스트에 대한 자세한 내용은 SaaS에 의존하는 테스트를 참고합니다.

스펙에서의 사용 예시는 다음과 같습니다.

# spec/migrations/20240510113339_add_saas_specific_column_spec.rb
RSpec.describe AddSaasSpecificColumn do
  it 'adds column for self-managed instances' do
    migrate!

    expect(table(:projects)).to have_column(:some_column)
  end

  context 'when SaaS', :saas do
    it 'adds additional SaaS-specific column' do
      migrate!

      expect(table(:projects)).to have_column(:some_column)
      expect(table(:projects)).to have_column(:saas_specific_column)
    end
  end
end

SaaS 인스턴스 시뮬레이션#

로컬에서 개발하면서 인스턴스가 제품의 SaaS(GitLab.com) 버전을 시뮬레이션하도록 해야 한다면 다음을 수행합니다.

  1. 다음 환경 변수를 내보냅니다.

    export GITLAB_SIMULATE_SAAS=1
    

    로컬 GitLab 인스턴스에 환경 변수를 전달하는 방법은 여러 가지입니다. 예를 들어 gdk.yml 파일에 항목을 만들 수 있습니다.

  2. Allow use of licensed EE features를 활성화하여 프로젝트 네임스페이스의 플랜에 해당 기능이 포함된 경우에만 라이선스가 필요한 EE 기능을 프로젝트에서 사용할 수 있도록 합니다.

    1. 오른쪽 상단에서 Admin을 선택합니다.
    2. 왼쪽 사이드바에서 Settings > General을 선택합니다.
    3. Account and limit을 확장합니다.
    4. Allow use of licensed EE features 체크박스를 선택합니다.
    5. Save changes를 선택합니다.
  3. EE 기능을 테스트하려는 그룹이 실제로 EE 플랜을 사용 중인지 확인합니다.

    1. 오른쪽 상단에서 Admin을 선택합니다.
    2. 왼쪽 사이드바에서 Overview > Groups를 선택합니다.
    3. 수정할 그룹을 찾아 Edit를 선택합니다.
    4. Permissions and group features로 스크롤합니다. Plan에서 Ultimate를 선택합니다.
    5. Save changes를 선택합니다.

위 단계를 보여 주는 📺 동영상이 있습니다.

Dedicated 인스턴스 기능#

코드에서 GitLab Dedicated 인스턴스를 다르게 처리해야 할 때는 다음 지침을 따릅니다.

GitLab Dedicated 인스턴스는 Dedicated 아키텍처에 문서화된 대로 항상 Ultimate 티어로 프로비저닝됩니다. Dedicated는 Enterprise Edition 전용 제품이므로 Dedicated 전용 코드는 모두 ee/ 디렉터리 구조 안에 두어야 하며, 다른 EE 기능과 같은 패턴을 따릅니다.

일반적인 사용 사례#

Dedicated 전용 코드는 Dedicated 인스턴스에서만 제공해야 하는 기능을 위한 것입니다.

일반적으로 기능은 SaaS와 Self-managed 배포 모두에 제공해야 합니다. 그러나 Dedicated 전용 기능이 타당한 경우도 있습니다.

Gitlab::Dedicated 메서드 사용#

Gitlab::Dedicated 모듈은 Dedicated 전용 동작을 처리하는 feature_available? 메서드를 제공합니다.

기능을 Dedicated에서만 실행해야 할 때는 FEATURES 목록과 함께 feature_available?를 사용합니다.

return unless Gitlab::Dedicated.feature_available?(:custom_backup_strategy)

# Custom backup code that only runs on Dedicated

ee/lib/gitlab/dedicated.rb의 FEATURES에 기능을 추가합니다.

FEATURES = %i[custom_backup_strategy skip_ultimate_trial_experience].freeze

feature_available? 메서드는 ee/config/dedicated_features/의 YAML 파일을 통해 해당 기능이 Dedicated 인스턴스에서 다르게 동작하는 이유를 컨텍스트가 풍부하게 문서화할 수 있게 합니다.

Dedicated 기능 정의 및 검증#

이 절차는 코드베이스에서 Dedicated 기능이 일관되게 사용되도록 합니다. 모든 Dedicated 기능은 다음을 충족해야 합니다.

  • 알려져 있어야 합니다. FEATURES에 명시적으로 정의된 Dedicated 기능만 사용합니다.
  • 소유자가 있어야 합니다.

모든 Dedicated 기능은 다음 위치에 저장된 YAML 파일에 스스로 문서화됩니다.

각 Dedicated 기능은 여러 필드로 구성된 별도의 YAML 파일로 정의합니다.

필드 필수 설명
name 예 Dedicated 기능의 이름입니다.
introduced_by_url 아니요 Dedicated 기능을 도입한 머지 리퀘스트의 URL입니다.
milestone 아니요 Dedicated 기능이 만들어진 마일스톤입니다.
group 아니요 해당 기능을 소유한 그룹입니다.

새 Dedicated 기능 파일 정의 생성#

GitLab 코드베이스는 새 Dedicated 기능 정의를 만드는 도구인 bin/dedicated-feature.rb를 제공합니다. 이 도구는 새 Dedicated 기능에 대해 여러 질문을 한 다음 ee/config/dedicated_features에 YAML 정의를 생성합니다.

YAML 정의 파일이 있는 Dedicated 기능만 개발 또는 테스트 환경을 실행할 때 사용할 수 있습니다.

새 Dedicated 기능 정의를 만들려면 다음을 수행합니다.

  1. 기능의 이름을 정할 때는 네임스페이스 개념 가이드를 참고합니다.
  2. ee/lib/gitlab/dedicated.rb의 FEATURES에 기능을 추가합니다.
  3. bin/dedicated-feature.rb <feature-name>을 실행하여 ee/config/dedicated_features/에 YAML 정의를 생성합니다.

실행 예시는 다음과 같습니다.

❯ bin/dedicated-feature.rb my_dedicated_feature
You picked the group 'group::acquisition'

>> URL of the MR introducing the Dedicated feature (enter to skip and let Danger provide a suggestion directly in the MR):
?> https://gitlab.com/gitlab-org/gitlab/-/merge_requests/123456
create ee/config/dedicated_features/my_dedicated_feature.yml
---
name: my_dedicated_feature
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/123456
milestone: '19.1'
group: group::acquisition

Dedicated 코드가 ee/에 있어야 하는 이유#

Dedicated 전용 코드는 모두 ee/ 디렉터리 구조 안에 두어야 합니다. 이렇게 하면 다음이 보장됩니다.

  • Dedicated 기능은 EE 빌드에서만 사용할 수 있습니다.
  • 코드베이스에서 CE와 EE 기능이 명확히 분리됩니다.
  • Dedicated 인스턴스는 모든 Ultimate 기능과 Dedicated 전용 동작을 함께 사용할 수 있습니다.

SaaS 전용 기능과 마찬가지로 애플리케이션 코드에서 Gitlab::CurrentSettings.gitlab_dedicated_instance?를 직접 사용하지 않습니다. 대신 Gitlab::Dedicated.feature_available?(:specific_feature)를 사용하여 해당 기능이 Dedicated에서 다르게 동작하는 이유에 대한 컨텍스트를 제공합니다.

Gitlab/AvoidGitlabDedicatedInstanceChecks RuboCop 규칙은 RuboCop 설정에서 명시적으로 제외한 경우를 제외하고 Gitlab::CurrentSettings.gitlab_dedicated_instance?와 Gitlab::Dedicated.dedicated_instance?를 직접 호출하는 코드를 표시하여 이 규칙을 강제합니다.

데이터베이스 마이그레이션의 예외#

데이터베이스 마이그레이션은 배포 유형에 따라 마이그레이션이 다르게 동작해야 할 때 Gitlab::CurrentSettings.gitlab_dedicated_instance?로 Dedicated 인스턴스 여부를 확인해야 할 수 있습니다. 이는 Gitlab::Dedicated.feature_available? 패턴을 사용할 수 없는 마이그레이션에서는 허용됩니다.

새 EE 기능 구현#

GitLab Premium 또는 GitLab Ultimate 라이선스 기능을 개발한다면 다음 단계에 따라 새 기능을 추가하거나 기존 기능을 확장합니다.

GitLab 라이선스 기능은 ee/app/models/gitlab_subscriptions/features.rb에 추가합니다. 이 파일을 어떻게 수정할지 정하려면 먼저 기능이 라이선스 체계에 어떻게 들어맞는지 Product Manager와 논의합니다.

다음 질문을 참고합니다.

  1. 새 기능인지, 기존 라이선스 기능을 확장하는 것인지 판단합니다.
    • 기능이 이미 있다면 features.rb를 수정할 필요는 없지만, 기능을 보호하려면 기존 기능 식별자를 찾아야 합니다.
    • 새 기능이라면 my_feature_name 같은 식별자를 정하여 features.rb 파일에 추가합니다.
  2. GitLab Premium 기능인지 GitLab Ultimate 기능인지 판단합니다.
    • 기능을 사용할 플랜에 따라 기능 식별자를 PREMIUM_FEATURES 또는 ULTIMATE_FEATURES에 추가합니다.
  3. 이 기능을 전역으로(GitLab 인스턴스 전체에서) 사용할 수 있는지 판단합니다.
    • Geo와 Database Load Balancing 같은 기능은 인스턴스 전체에서 사용하며 개별 사용자 네임스페이스로 제한할 수 없습니다. 이러한 기능은 인스턴스 라이선스에 정의됩니다. 이러한 기능을 GLOBAL_FEATURES에 추가합니다.

EE 기능 보호#

라이선스가 필요한 기능은 라이선스가 있는 사용자만 사용할 수 있습니다. 사용자가 기능에 접근할 수 있는지 판단하려면 검사 또는 가드를 추가해야 합니다.

라이선스 기능을 보호하려면 다음을 수행합니다.

  1. ee/app/models/gitlab_subscriptions/features.rb에서 기능 식별자를 찾습니다.

  2. 다음 메서드를 사용합니다. 여기서 my_feature_name은 기능 식별자입니다.

    • 프로젝트 컨텍스트에서는 다음과 같이 합니다.

      my_project.licensed_feature_available?(:my_feature_name) # true if available for my_project
      
    • 그룹 또는 사용자 네임스페이스 컨텍스트에서는 다음과 같이 합니다.

      my_group.licensed_feature_available?(:my_feature_name) # true if available for my_group
      
    • 전역(시스템 전체) 기능에는 다음과 같이 합니다.

      License.feature_available?(:my_feature_name)  # true if available in this instance
      
  3. 선택 사항입니다. 전역 기능을 유료 플랜의 네임스페이스에서도 사용할 수 있다면, 두 기능 식별자를 조합하여 관리자와 그룹 사용자를 모두 허용합니다. 예:

    License.feature_available?(:my_feature_name) || group.licensed_feature_available?(:my_feature_name_for_namespace) # Both admins and group members can see this EE feature
    

라이선스가 없을 때 CE 인스턴스 시뮬레이션#

다음 작업이 구현된 이후로 GitLab CE 기능이 라이선스가 없는 EE 인스턴스에서 동작하도록 하는 작업 GitLab Enterprise Edition은 활성화된 라이선스가 없을 때 GitLab Community Edition처럼 동작합니다.

CE 스펙은 가능한 한 그대로 두고 EE용 스펙을 추가해야 합니다. 라이선스 기능은 EE::LicenseHelpers의 스펙 헬퍼 stub_licensed_features로 스텁 처리할 수 있습니다.

ee/ 디렉터리를 삭제하거나 FOSS_ONLY 환경 변수를 true로 평가되는 값으로 설정하면 GitLab이 CE로 동작하도록 강제할 수 있습니다. 테스트 실행에서도 같은 방식이 동작합니다(예: FOSS_ONLY=1 yarn jest).

라이선스가 있는 GDK에서 CE 인스턴스 시뮬레이션#

GDK의 라이선스를 삭제하지 않고 CE 인스턴스를 시뮬레이션하려면 다음을 수행합니다.

  1. gdk.yml에 다음 항목을 추가합니다.

    env:
      FOSS_ONLY: "1"
    
  2. 그런 다음 GDK를 다시 시작합니다.

    gdk restart
    

EE 설치로 되돌리려면 gdk.yml에서 환경 변수를 제거한 다음 2단계를 반복합니다.

CE로 기능 스펙 실행#

기능 스펙을 CE로 실행할 때는 백엔드와 프론트엔드의 에디션이 일치하는지 확인해야 합니다. 이를 위해 다음을 수행합니다.

  1. FOSS_ONLY=1 환경 변수를 설정합니다.

    export FOSS_ONLY=1
    
  2. GDK를 시작합니다.

    gdk start
    
  3. 기능 스펙을 실행합니다.

    bin/rspec spec/features/<path_to_your_spec>
    

FOSS 컨텍스트에서 CI 파이프라인 실행#

기본적으로 개발용 머지 리퀘스트 파이프라인은 EE 컨텍스트에서만 실행됩니다. FOSS와 EE에서 다르게 동작하는 기능을 개발한다면 FOSS 컨텍스트에서도 파이프라인을 실행해야 할 수 있습니다.

두 컨텍스트에서 모두 파이프라인을 실행하려면 머지 리퀘스트에 ~"pipeline:run-as-if-foss" 레이블을 추가합니다.

자세한 내용은 As-if-FOSS job과 크로스 프로젝트 다운스트림 파이프라인 파이프라인 문서를 참고합니다.

백엔드의 EE 코드 분리#

EE 전용 기능#

개발 중인 기능이 CE에 어떤 형태로도 존재하지 않는다면 코드를 EE 네임스페이스 아래에 둘 필요가 없습니다. 예를 들어 EE 모델은 클래스 이름을 Awesome으로 하여 ee/app/models/awesome.rb에 둘 수 있습니다. 이는 모델에만 적용되지 않습니다. 다른 예는 다음과 같습니다.

  • ee/app/controllers/foos_controller.rb
  • ee/app/finders/foos_finder.rb
  • ee/app/helpers/foos_helper.rb
  • ee/app/mailers/foos_mailer.rb
  • ee/app/models/foo.rb
  • ee/app/policies/foo_policy.rb
  • ee/app/serializers/foo_entity.rb
  • ee/app/serializers/foo_serializer.rb
  • ee/app/services/foo/create_service.rb
  • ee/app/validators/foo_attr_validator.rb
  • ee/app/workers/foo_worker.rb
  • ee/app/views/foo.html.haml
  • ee/app/views/foo/_bar.html.haml
  • ee/config/initializers/foo_bar.rb

CE의 eager-load/auto-load 경로마다 같은 경로 앞에 ee/를 붙인 경로를 config/application.rb에 추가하기 때문에 이 방식이 동작합니다. 뷰에도 마찬가지로 적용됩니다.

EE 전용 백엔드 기능 테스트#

CE에 없는 EE 클래스를 테스트하려면 평소처럼 ee/spec 디렉터리에 스펙 파일을 만들되, 두 번째 ee/ 하위 디렉터리는 두지 않습니다. 예를 들어 클래스 ee/app/models/vulnerability.rb의 테스트는 ee/spec/models/vulnerability_spec.rb에 둡니다.

기본적으로 specs/의 스펙에서는 라이선스 기능이 비활성화되어 있습니다. ee/spec 디렉터리의 스펙은 기본적으로 Starter 라이선스가 초기화되어 있습니다.

기능을 제대로 테스트하려면 다음 예시처럼 stub_licensed_features 헬퍼로 기능을 명시적으로 활성화해야 합니다.

  stub_licensed_features(my_awesome_feature_name: true)

EE 백엔드 코드로 CE 기능 확장#

기존 CE 기능을 기반으로 하는 기능은 EE 네임스페이스에 모듈을 작성하고, 클래스가 있는 파일의 마지막 줄에서 CE 클래스에 주입합니다. 이렇게 하면 CE 클래스에 모듈을 주입하는 한 줄만 추가되므로 CE에서 EE로 머지할 때 충돌이 발생할 가능성이 줄어듭니다. 예를 들어 User 클래스에 모듈을 prepend 하려면 다음 방식을 사용합니다.

class User < ActiveRecord::Base
  # ... lots of code here ...
end

User.prepend_mod

prepend, extend, include 같은 메서드는 사용하지 않습니다. 대신 prepend_mod, extend_mod, include_mod를 사용합니다. 이 메서드들은 수신 모듈의 이름으로 해당하는 EE 모듈을 찾으려 합니다. 예를 들면 다음과 같습니다.

module Vulnerabilities
  class Finding
    #...
  end
end

Vulnerabilities::Finding.prepend_mod

이 코드는 ::EE::Vulnerabilities::Finding이라는 이름의 모듈을 prepend 합니다.

확장 모듈이 이 명명 규칙을 따르지 않는다면 prepend_mod_with, extend_mod_with, include_mod_with로 모듈 이름을 직접 지정할 수도 있습니다. 이 메서드들은 모듈 자체가 아니라 전체 모듈 이름을 담은 _String_을 인자로 받으며, 다음과 같이 사용합니다.

class User
  #...
end

User.prepend_mod_with('UserExtension')

이 모듈에는 EE 네임스페이스가 필요하므로 파일도 ee/ 하위 디렉터리에 두어야 합니다. 예를 들어 EE에서 사용자 모델을 확장하려는 경우 ::EE::User라는 모듈을 ee/app/models/ee/user.rb 안에 둡니다.

이 방식도 모델에만 적용되지 않습니다. 다른 예는 다음과 같습니다.

  • ee/app/controllers/ee/foos_controller.rb
  • ee/app/finders/ee/foos_finder.rb
  • ee/app/helpers/ee/foos_helper.rb
  • ee/app/mailers/ee/foos_mailer.rb
  • ee/app/models/ee/foo.rb
  • ee/app/policies/ee/foo_policy.rb
  • ee/app/serializers/ee/foo_entity.rb
  • ee/app/serializers/ee/foo_serializer.rb
  • ee/app/services/ee/foo/create_service.rb
  • ee/app/validators/ee/foo_attr_validator.rb
  • ee/app/workers/ee/foo_worker.rb

CE 기능을 기반으로 하는 EE 기능 테스트#

CE 클래스를 EE 기능으로 확장하는 EE 네임스페이스 모듈을 테스트하려면 평소처럼 ee/spec 디렉터리에 스펙 파일을 만들되, 두 번째 ee/ 하위 디렉터리를 포함합니다. 예를 들어 확장 ee/app/models/ee/user.rb의 테스트는 ee/spec/models/ee/user_spec.rb에 둡니다.

RSpec.describe 호출에는 EE 모듈이 사용될 자리에 CE 클래스 이름을 사용합니다. 예를 들어 ee/spec/models/ee/user_spec.rb에서 테스트는 다음과 같이 시작합니다.

RSpec.describe User do
  describe 'ee feature added through extension'
end

CE 메서드 오버라이드#

CE 코드베이스에 있는 메서드를 오버라이드하려면 prepend를 사용합니다. 이를 통해 클래스의 메서드를 모듈의 메서드로 오버라이드하면서도 super로 클래스의 구현에 계속 접근할 수 있습니다.

이 방식에는 주의할 점이 몇 가지 있습니다.

  • 항상 extend ::Gitlab::Utils::Override를 사용하고 override로 overrider 메서드를 보호해야 합니다. 그래야 CE에서 메서드 이름이 바뀌더라도 EE 오버라이드가 조용히 잊히지 않습니다.

  • overrider가 CE 구현의 중간에 한 줄을 추가해야 한다면 CE 메서드를 리팩터링하여 더 작은 메서드로 나누어야 합니다. 또는 CE에서는 비어 있고 EE에서 EE 전용 구현을 갖는 "훅" 메서드를 만듭니다.

  • 원래 구현에 가드 절(예: return unless condition)이 있으면 메서드를 오버라이드하는 것만으로는 동작을 쉽게 확장할 수 없습니다. 오버라이드된 메서드(즉, 오버라이딩 메서드에서 super를 호출하는 것)가 언제 일찍 중단하려 할지 알 수 없기 때문입니다. 이 경우 단순히 오버라이드하지 말고, 원래 메서드를 수정하여 확장하려는 다른 메서드를 호출하게 합니다. 템플릿 메서드 패턴과 같습니다. 예를 들어 다음과 같은 기본 클래스가 있다고 하겠습니다.

      class Base
        def execute
          return unless enabled?
    
          # ...
          # ...
        end
      end
    

    Base#execute를 그냥 오버라이드하는 대신, 이를 수정하여 동작을 다른 메서드로 추출해야 합니다.

      class Base
        def execute
          return unless enabled?
    
          do_something
        end
    
        private
    
        def do_something
          # ...
          # ...
        end
      end
    

    그러면 가드를 걱정하지 않고 do_something을 자유롭게 오버라이드할 수 있습니다.

      module EE::Base
        extend ::Gitlab::Utils::Override
    
        override :do_something
        def do_something
          # Follow the above pattern to call super and extend it
        end
      end
    

prepend 할 때는 ee/ 전용 하위 디렉터리에 두고, 이름 충돌을 피하도록 클래스나 모듈을 module EE로 감쌉니다.

예를 들어 ApplicationController#after_sign_out_path_for의 CE 구현을 오버라이드하는 경우는 다음과 같습니다.

def after_sign_out_path_for(resource)
  current_application_settings.after_sign_out_path.presence || new_user_session_path
end

메서드를 제자리에서 수정하는 대신 기존 파일에 prepend를 추가해야 합니다.

class ApplicationController < ActionController::Base
  # ...

  def after_sign_out_path_for(resource)
    current_application_settings.after_sign_out_path.presence || new_user_session_path
  end

  # ...
end

ApplicationController.prepend_mod_with('ApplicationController')

그리고 ee/ 하위 디렉터리에 변경된 구현을 담은 새 파일을 만듭니다.

module EE
  module ApplicationController
    extend ::Gitlab::Utils::Override

    override :after_sign_out_path_for
    def after_sign_out_path_for(resource)
      if Gitlab::Geo.secondary?
        Gitlab::Geo.primary_node.oauth_logout_url(@geo_logout_state)
      else
        super
      end
    end
  end
end
CE 클래스 메서드 오버라이드#

클래스 메서드에도 같은 방식이 적용됩니다. 다만 ActiveSupport::Concern을 사용하고 extend ::Gitlab::Utils::Override를 class_methods 블록 안에 둡니다. 예시는 다음과 같습니다.

module EE
  module Groups
    module GroupMembersController
      extend ActiveSupport::Concern

      class_methods do
        extend ::Gitlab::Utils::Override

        override :admin_not_required_endpoints
        def admin_not_required_endpoints
          super.concat(%i[update override])
        end
      end
    end
  end
end

자기 설명적인 래퍼 메서드 사용#

메서드의 구현을 수정할 수 없거나 수정하는 것이 논리적이지 않다면 자기 설명적인 메서드로 감싸고 그 메서드를 사용합니다.

예를 들어 GitLab-FOSS에서 시스템이 만드는 유일한 사용자는 Users::Internal.in_organization(Organizations::Organization.first).ghost이지만, EE에는 실제로는 사용자가 아닌 봇 사용자 유형이 여러 가지 있습니다. User#ghost?의 구현을 오버라이드하면 올바르지 않으므로, 대신 app/models/user.rb에 #internal? 메서드를 추가합니다. 구현은 다음과 같습니다.

def internal?
  ghost?
end

EE의 구현 ee/app/models/ee/users.rb는 다음과 같습니다.

override :internal?
def internal?
  super || bot?
end

config/initializers의 코드#

Rails 초기화 코드는 다음 위치에 있습니다.

  • CE 전용 기능은 config/initializers
  • EE 기능은 ee/config/initializers

config/initializers에서는 분리할 수 없을 때만 Gitlab.ee { ... }/Gitlab.ee?를 사용합니다. 예:

SomeGem.configure do |config|
  config.base = 'https://example.com'

  config.encryption = true if Gitlab.ee?
end

클래스 메서드로 이니셜라이저 확장#

이니셜라이저에서 사용하는 클래스 메서드를 오버라이드해야 하는 더 복잡한 시나리오에서는 모델과 비슷하게 prepend_mod_with 패턴을 사용할 수 있습니다. 이 방식은 app/models를 확장하는 방식과 같으며 CE와 EE 로직을 깔끔하게 분리할 수 있습니다.

이 패턴은 이니셜라이저에서 구성해야 하는 SaaS 전용 기능에 특히 유용합니다. 그런 기능은 모든 EE 인스턴스가 아니라 SaaS 인스턴스에서만 활성화해야 하므로 Gitlab.ee? 만으로는 충분하지 않습니다.

예를 들어 config/initializers/doorkeeper.rb에서는 다음과 같습니다.

# The initializer calls a class method that can be overridden in EE
allow_grant_flow_for_client do |grant_flow, client|
  next false if Applications::CreateService.disable_ropc_for_all_applications?
  # ... other logic
end

CE 서비스(app/services/applications/create_service.rb)는 다음과 같습니다.

module Applications
  class CreateService
    # Define class methods that return false by default but can be overridden in EE
    def self.disable_ropc_for_all_applications?
      false
    end

    # ... other methods
  end
end

# Allow EE to extend this service
Applications::CreateService.prepend_mod_with('Applications::CreateService')

EE 확장(ee/app/services/ee/applications/create_service.rb)은 다음과 같습니다.

module EE
  module Applications
    module CreateService
      def self.prepended(base)
        base.singleton_class.prepend(ClassMethods)
      end

      module ClassMethods
        extend ::Gitlab::Utils::Override

        override :disable_ropc_for_all_applications?
        def disable_ropc_for_all_applications?
          ::Gitlab::Saas.feature_available?(:disable_ropc_for_all_applications)
        end
      end
    end
  end
end

이 패턴을 사용하면 이니셜라이저가 CE와 EE에서 동작이 다른 메서드를 호출할 수 있고, 이니셜라이저 코드 자체는 에디션 간에 변경하지 않아도 됩니다.

config/routes의 코드#

config/routes.rb에 draw_all :admin을 추가하면 애플리케이션은 config/routes/admin.rb에 있는 파일을 로드하려 하고, ee/config/routes/admin.rb에 있는 파일도 로드하려 합니다.

파일을 하나도 찾지 못하면 오류가 발생합니다.

EE에서는 최소 한 개, 최대 두 개의 파일을 로드해야 합니다. CE에서는 파일을 하나만 로드합니다.

CE와 EE 라우트 파일이 모두 있는 라우트에는 draw_all을 사용합니다.

EE 전용 라우트를 추가하려면 대신 Gitlab.ee와 함께 draw를 사용합니다.

Gitlab.ee do
  draw :ee_only
end

app/controllers/의 코드#

컨트롤러에서 가장 흔한 충돌 유형은 CE에서는 액션 목록을 가진 before_action에 EE가 액션을 추가하는 경우입니다.

params.require / params.permit 호출에서도 같은 문제가 자주 발생합니다.

완화 방법

CE와 EE의 액션/키워드를 분리합니다. 예를 들어 ProjectsController의 params.require는 다음과 같이 합니다.

def project_params
  params.require(:project).permit(project_params_attributes)
end

# Always returns an array of symbols, created however best fits the use case.
# It should be sorted alphabetically.
def project_params_attributes
  %i[
    description
    name
    path
  ]
end

EE::ProjectsController 모듈에서는 다음과 같이 합니다.

def project_params_attributes
  super + project_params_attributes_ee
end

def project_params_attributes_ee
  %i[
    approvals_before_merge
    issues_template
    merge_requests_template
    ...
  ]
end

app/models/의 코드#

EE 전용 모델은 ee/app/models/에 정의해야 합니다.

CE 모델을 오버라이드하려면 ee/app/models/ee/에 파일을 만들고 prepended 블록에 새 코드를 추가합니다.

ActiveRecord enums는 전부 FOSS에 정의해야 합니다.

app/views/의 코드#

EE가 CE 뷰에 EE 전용 뷰 코드를 추가하는 것은 매우 흔한 문제입니다. 예를 들어 프로젝트 설정 페이지의 승인 코드가 그렇습니다.

완화 방법

EE 전용 코드 블록은 partial로 옮겨야 합니다. 그러면 들여쓰기까지 얽힌 큰 HAML 코드 덩어리에서 발생하는, 해결하기 번거로운 충돌을 피할 수 있습니다.

EE 전용 뷰는 ee/app/views/에 두며, 적절하면 하위 디렉터리를 추가로 사용합니다.

render_if_exists 사용#

일반 render 대신 render_if_exists를 사용해야 합니다. 이 메서드는 해당 partial을 찾지 못하면 아무것도 렌더링하지 않습니다. 이렇게 하면 CE에 render_if_exists를 두어 CE와 EE 사이에 코드를 동일하게 유지할 수 있습니다.

장점은 다음과 같습니다.

  • CE 코드를 읽으면서 EE 뷰를 확장하는 위치를 매우 분명하게 알 수 있습니다.

단점은 다음과 같습니다.

  • partial 이름에 오타가 있으면 조용히 무시됩니다.
주의 사항#

render_if_exists의 뷰 경로 인자는 app/views/ 및 ee/app/views를 기준으로 한 상대 경로여야 합니다. CE 뷰 경로를 기준으로 한 상대 경로로 EE 템플릿 경로를 해석하는 것은 동작하지 않습니다.

- # app/views/projects/index.html.haml

= render_if_exists 'button' # Will not render `ee/app/views/projects/_button` and will quietly fail
= render_if_exists 'projects/button' # Will render `ee/app/views/projects/_button`

render_ce 사용#

render와 render_if_exists는 EE partial을 먼저 검색하고 그다음 CE partial을 검색합니다. 이름이 같은 모든 partial이 아니라 특정 partial 하나만 렌더링합니다. 이를 활용하면 같은 partial 경로(예: projects/settings/archive)가 CE에서는 CE partial(즉, app/views/projects/settings/_archive.html.haml)을, EE에서는 EE partial(즉, ee/app/views/projects/settings/_archive.html.haml)을 가리키게 할 수 있습니다. 이렇게 하면 CE와 EE에서 서로 다른 내용을 보여 줄 수 있습니다.

그러나 기존 CE partial에 무언가를 추가하기만 하려는 경우처럼 EE partial에서 CE partial을 재사용하고 싶을 때도 있습니다. 이름이 다른 partial을 하나 더 추가하는 방법으로 우회할 수도 있지만 그렇게 하기는 번거롭습니다.

이 경우 EE partial을 모두 무시하는 render_ce를 사용하면 됩니다. 한 가지 예는 ee/app/views/projects/settings/_archive.html.haml입니다.

- return if @project.self_deletion_scheduled?
= render_ce 'projects/settings/archive'

위 예시에서는 render 'projects/settings/archive'를 사용할 수 없습니다. 같은 EE partial을 찾아서 무한 재귀가 발생하기 때문입니다. 대신 render_ce를 사용하면 ee/의 partial을 모두 무시하고 같은 경로(즉, projects/settings/archive)에 대해 CE partial(즉, app/views/projects/settings/_archive.html.haml)을 렌더링합니다. 이렇게 하면 CE partial을 손쉽게 감쌀 수 있습니다.

lib/gitlab/background_migration/의 코드#

EE 전용 백그라운드 마이그레이션을 만들 때는 GitLab EE를 CE로 다운그레이드하는 사용자를 고려해야 합니다. 즉, 모든 EE 전용 마이그레이션은 CE 코드에도 구현 없이 존재해야 하며, EE 쪽에서 이를 확장해야 합니다.

GitLab CE는 다음과 같습니다.

# lib/gitlab/background_migration/prune_orphaned_geo_events.rb

module Gitlab
  module BackgroundMigration
    class PruneOrphanedGeoEvents
      def perform(table_name)
      end
    end
  end
end

Gitlab::BackgroundMigration::PruneOrphanedGeoEvents.prepend_mod_with('Gitlab::BackgroundMigration::PruneOrphanedGeoEvents')

GitLab EE는 다음과 같습니다.

# ee/lib/ee/gitlab/background_migration/prune_orphaned_geo_events.rb

module EE
  module Gitlab
    module BackgroundMigration
      module PruneOrphanedGeoEvents
        extend ::Gitlab::Utils::Override

        override :perform
        def perform(table_name = EVENT_TABLES.first)
          return if ::Gitlab::Database.read_only?

          deleted_rows = prune_orphaned_rows(table_name)
          table_name   = next_table(table_name) if deleted_rows.zero?

          ::Database::BatchedBackgroundMigrationWorker.perform_in(RESCHEDULE_DELAY, self.class.name.demodulize, table_name) if table_name
        end
      end
    end
  end
end

app/graphql/의 코드#

EE 전용 뮤테이션, 리졸버, 타입은 ee/app/graphql/{mutations,resolvers,types}에 추가해야 합니다.

CE 뮤테이션, 리졸버 또는 타입을 오버라이드하려면 ee/app/graphql/ee/{mutations,resolvers,types}에 파일을 만들고 prepended 블록에 새 코드를 추가합니다.

예를 들어 CE에 Mutations::Tanukis::Create라는 뮤테이션이 있고 새 인자를 추가하려는 경우, EE 오버라이드를 ee/app/graphql/ee/mutations/tanukis/create.rb에 둡니다.

module EE
  module Mutations
    module Tanukis
      module Create
        extend ActiveSupport::Concern

        prepended do
          argument :name,
                   GraphQL::Types::String,
                   required: false,
                   description: 'Tanuki name'
        end
      end
    end
  end
end

lib/의 코드#

CE를 오버라이드하는 EE 로직은 최상위 EE 모듈 네임스페이스에 둡니다. 클래스는 평소처럼 EE 모듈 아래의 네임스페이스에 둡니다.

예를 들어 CE에 lib/gitlab/ldap/의 LDAP 클래스가 있다면 EE 전용 오버라이드는 ee/lib/ee/gitlab/ldap에 둡니다.

CE에 대응하는 클래스가 없는 EE 전용 클래스는 ee/lib/gitlab/ldap에 둡니다.

lib/api/의 코드#

prepend_mod_with 한 줄로 EE 기능을 확장하기는 매우 까다로울 수 있으며, Grape 기능마다 확장에 서로 다른 전략이 필요할 수 있습니다. 서로 다른 전략을 쉽게 적용하기 위해 EE 모듈에서 extend ActiveSupport::Concern을 사용합니다.

EE 모듈 파일은 EE 백엔드 코드로 CE 기능 확장을 따라 둡니다.

EE API 라우트#

EE API 라우트는 prepended 블록에 둡니다.

module EE
  module API
    module MergeRequests
      extend ActiveSupport::Concern

      prepended do
        params do
          requires :id, types: [String, Integer], desc: 'The ID or URL-encoded path of the project'
        end
        resource :projects, requirements: ::API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
          # ...
        end
      end
    end
  end
end

네임스페이스 차이 때문에 일부 상수에는 전체 한정자를 사용해야 합니다.

EE 파라미터#

params를 정의하고 다른 params 정의에서 use를 사용하여 EE에 정의된 파라미터를 포함할 수 있습니다. 다만 EE가 이를 오버라이드하려면 먼저 CE에서 "인터페이스"를 정의해야 합니다. 다른 곳에서는 prepend_mod_with 덕분에 이렇게 할 필요가 없지만, Grape는 내부적으로 복잡하여 그렇게 하기 쉽지 않았으므로, 여기서는 인터페이스를 먼저 정의하는 일반적인 객체 지향 방식을 따릅니다.

예를 들어 EE에 선택 파라미터가 몇 개 더 있다고 가정합니다. 파라미터를 Grape::API::Instance 클래스에서 헬퍼 모듈로 옮기면 클래스에서 사용하기 전에 이를 주입할 수 있습니다.

module API
  class Projects < Grape::API::Instance
    helpers Helpers::ProjectsHelpers
  end
end

다음과 같은 CE API params가 있다고 하겠습니다.

module API
  module Helpers
    module ProjectsHelpers
      extend ActiveSupport::Concern
      extend Grape::API::Helpers

      params :optional_project_params_ce do
        # CE specific params go here...
      end

      params :optional_project_params_ee do
      end

      params :optional_project_params do
        use :optional_project_params_ce
        use :optional_project_params_ee
      end
    end
  end
end

API::Helpers::ProjectsHelpers.prepend_mod_with('API::Helpers::ProjectsHelpers')

EE 모듈에서 이를 오버라이드할 수 있습니다.

module EE
  module API
    module Helpers
      module ProjectsHelpers
        extend ActiveSupport::Concern

        prepended do
          params :optional_project_params_ee do
            # EE specific params go here...
          end
        end
      end
    end
  end
end

EE 헬퍼#

EE 모듈이 CE 헬퍼를 쉽게 오버라이드할 수 있도록 하려면 확장하려는 헬퍼를 먼저 정의해야 합니다. 쉽고 명확하도록 클래스 정의 직후에 정의합니다.

module API
  module Ci
    class JobArtifacts < Grape::API::Instance
      # EE::API::Ci::JobArtifacts would override the following helpers
      helpers do
        def authorize_download_artifacts!
          authorize_read_builds!
        end
      end
    end
  end
end

API::Ci::JobArtifacts.prepend_mod_with('API::Ci::JobArtifacts')

그런 다음 일반적인 객체 지향 방식으로 이를 오버라이드할 수 있습니다.

module EE
  module API
    module Ci
      module JobArtifacts
        extend ActiveSupport::Concern

        prepended do
          helpers do
            def authorize_download_artifacts!
              super
              check_cross_project_pipelines_feature!
            end
          end
        end
      end
    end
  end
end

EE 전용 동작#

일부 API에서 EE 전용 동작이 필요할 때가 있습니다. 보통은 EE 메서드로 CE 메서드를 오버라이드할 수 있지만, API 라우트는 메서드가 아니므로 오버라이드할 수 없습니다. 라우트를 독립된 메서드로 추출하거나, CE 라우트에 동작을 주입할 수 있는 "훅"을 도입해야 합니다. 다음과 같은 방식입니다.

module API
  class MergeRequests < Grape::API::Instance
    helpers do
      # EE::API::MergeRequests would override the following helpers
      def update_merge_request_ee(merge_request)
      end
    end

    put ':id/merge_requests/:merge_request_iid/merge' do
      merge_request = find_project_merge_request(params[:merge_request_iid])

      # ...

      update_merge_request_ee(merge_request)

      # ...
    end
  end
end

API::MergeRequests.prepend_mod_with('API::MergeRequests')

update_merge_request_ee는 CE에서는 아무 작업도 하지 않지만, EE에서 이를 오버라이드할 수 있습니다.

module EE
  module API
    module MergeRequests
      extend ActiveSupport::Concern

      prepended do
        helpers do
          def update_merge_request_ee(merge_request)
            # ...
          end
        end
      end
    end
  end
end

EE route_setting#

이는 EE 모듈에서 확장하기가 매우 어렵고, 특정 라우트의 메타데이터를 저장하는 용도입니다. 이를 고려하면 CE에서는 이 메타데이터를 사용하지 않고 해가 되지도 않으므로, EE route_setting은 CE에 그대로 둘 수 있습니다.

route_setting을 더 많이 사용하게 되거나 EE에서 이를 확장할 필요가 실제로 있는지 여부에 따라 이 방침을 다시 검토할 수 있습니다. 지금은 많이 사용하지 않습니다.

클래스 메서드로 EE 전용 데이터 설정#

특정 API 라우트에 다른 인자를 사용해야 하는데, Grape는 블록마다 컨텍스트가 달라서 EE 모듈로 쉽게 확장할 수 없는 경우가 있습니다. 이를 해결하려면 데이터를 별도의 모듈이나 클래스에 있는 클래스 메서드로 옮겨야 합니다. 그러면 CE 코드 중간에 prepend_mod_with를 두지 않고도 데이터가 사용되기 전에 해당 모듈이나 클래스를 확장할 수 있습니다.

예를 들어 한 곳에서는 API가 EE 전용 인자를 최소 인자로 간주하도록 at_least_one_of에 추가 인자를 전달해야 합니다. 다음과 같이 접근합니다.

# api/merge_requests/parameters.rb
module API
  class MergeRequests < Grape::API::Instance
    module Parameters
      def self.update_params_at_least_one_of
        %i[
          assignee_id
          description
        ]
      end
    end
  end
end

API::MergeRequests::Parameters.prepend_mod_with('API::MergeRequests::Parameters')

# api/merge_requests.rb
module API
  class MergeRequests < Grape::API::Instance
    params do
      at_least_one_of(*Parameters.update_params_at_least_one_of)
    end
  end
end

그러면 EE 클래스 메서드에서 그 인자를 손쉽게 확장할 수 있습니다.

module EE
  module API
    module MergeRequests
      module Parameters
        extend ActiveSupport::Concern

        class_methods do
          extend ::Gitlab::Utils::Override

          override :update_params_at_least_one_of
          def update_params_at_least_one_of
            super.push(*%i[
              squash
            ])
          end
        end
      end
    end
  end
end

라우트가 많아서 이 작업이 자주 필요하다면 번거로울 수 있지만, 현재로서는 가장 단순한 해결책일 수 있습니다.

이 방식은 모델이 클래스 메서드에 의존하는 유효성 검사를 정의할 때도 사용할 수 있습니다. 예:

# app/models/identity.rb
class Identity < ActiveRecord::Base
  def self.uniqueness_scope
    [:provider]
  end

  prepend_mod_with('Identity')

  validates :extern_uid,
    allow_blank: true,
    uniqueness: { scope: uniqueness_scope, case_sensitive: false }
end

# ee/app/models/ee/identity.rb
module EE
  module Identity
    extend ActiveSupport::Concern

    class_methods do
      extend ::Gitlab::Utils::Override

      def uniqueness_scope
        [*super, :saml_provider_id]
      end
    end
  end
end

이 방식을 택하는 대신 코드를 다음과 같이 리팩터링합니다.

# ee/app/models/ee/identity/uniqueness_scopes.rb
module EE
  module Identity
    module UniquenessScopes
      extend ActiveSupport::Concern

      class_methods do
        extend ::Gitlab::Utils::Override

        def uniqueness_scope
          [*super, :saml_provider_id]
        end
      end
    end
  end
end

# app/models/identity/uniqueness_scopes.rb
class Identity < ActiveRecord::Base
  module UniquenessScopes
    def self.uniqueness_scope
      [:provider]
    end
  end
end

Identity::UniquenessScopes.prepend_mod_with('Identity::UniquenessScopes')

# app/models/identity.rb
class Identity < ActiveRecord::Base
  validates :extern_uid,
    allow_blank: true,
    uniqueness: { scope: Identity::UniquenessScopes.scopes, case_sensitive: false }
end

spec/의 코드#

EE 전용 기능을 테스트할 때는 기존 CE 스펙에 예제를 추가하지 않습니다. 대신 EE 스펙을 ee/spec 폴더에 둡니다.

기본적으로 CE 스펙은 EE 코드가 로드된 상태로 실행됩니다. EE가 라이선스 없이 실행될 때도 그대로 동작해야 하기 때문입니다.

이 스펙은 EE 코드를 제거한 상태에서도 통과해야 합니다. CE 인스턴스를 시뮬레이션하여 EE 코드 없이 테스트를 실행할 수 있습니다.

spec/factories의 코드#

CE에 이미 정의된 팩토리를 확장하려면 FactoryBot.modify를 사용합니다.

FactoryBot.modify 블록 안에서는 새 팩토리(중첩된 팩토리 포함)를 정의할 수 없습니다. 아래 예시처럼 별도의 FactoryBot.define 블록에서는 정의할 수 있습니다.

# ee/spec/factories/notes.rb
FactoryBot.modify do
  factory :note do
    trait :on_epic do
      noteable { create(:epic) }
      project nil
    end
  end
end

FactoryBot.define do
  factory :note_on_epic, parent: :note, traits: [:on_epic]
end

프론트엔드의 EE 코드 분리#

EE 전용 JS 파일을 분리하려면 파일을 ee 폴더로 옮깁니다.

예를 들어 app/assets/javascripts/protected_branches/protected_branches_bundle.js가 있고 그에 대응하는 EE 파일 ee/app/assets/javascripts/protected_branches/protected_branches_bundle.js가 있을 수 있습니다. 이때 해당 import 문은 다음과 같습니다.

// app/assets/javascripts/protected_branches/protected_branches_bundle.js
import bundle from '~/protected_branches/protected_branches_bundle.js';

// ee/app/assets/javascripts/protected_branches/protected_branches_bundle.js
// (only works in EE)
import bundle from 'ee/protected_branches/protected_branches_bundle.js';

// in CE: app/assets/javascripts/protected_branches/protected_branches_bundle.js
// in EE: ee/app/assets/javascripts/protected_branches/protected_branches_bundle.js
import bundle from 'ee_else_ce/protected_branches/protected_branches_bundle.js';

프론트엔드에 새 EE 전용 기능 추가#

개발 중인 기능이 CE에 없다면 진입점을 ee/에 추가합니다. 예:

# Add HTML element to mount
ee/app/views/admin/geo/designs/index.html.haml

# Init the application
ee/app/assets/javascripts/pages/ee_only_feature/index.js

# Mount the feature
ee/app/assets/javascripts/ee_only_feature/index.js

licensed_feature_available?와 License.feature_available?를 사용한 기능 보호는 일반적으로 백엔드 가이드에 설명된 대로 컨트롤러에서 이루어집니다.

EE 전용 프론트엔드 기능 테스트#

CE에 사용하는 것과 같은 디렉터리 구조를 따라 EE 테스트를 ee/spec/frontend/에 추가합니다.

라이선스 기능을 활성화하는 방법은 EE 전용 백엔드 기능 테스트의 참고 사항을 확인합니다.

EE 프론트엔드 코드로 CE 기능 확장#

기존 뷰를 확장하는 프론트엔드 기능을 보호하려면 push_licensed_feature를 사용합니다.

# ee/app/controllers/ee/admin/my_controller.rb
before_action do
  push_licensed_feature(:my_feature_name) # for global features
end
# ee/app/controllers/ee/group/my_controller.rb
before_action do
  push_licensed_feature(:my_feature_name, @group) # for group pages
end
# ee/app/controllers/ee/project/my_controller.rb
before_action do
  push_licensed_feature(:my_feature_name, @group) # for group pages
  push_licensed_feature(:my_feature_name, @project) # for project pages
end

브라우저 콘솔의 gon.licensed_features에 기능이 나타나는지 확인합니다.

EE Vue 컴포넌트로 Vue 애플리케이션 확장#

UI의 기존 기능을 향상시키는 EE 라이선스 기능은 컴포넌트로서 Vue 애플리케이션에 새 요소나 상호 작용을 추가합니다.

CE 컴포넌트 안에서 EE 컴포넌트를 import 하여 EE 기능을 추가할 수 있습니다.

EE 컴포넌트를 import 하려면 ee_component 별칭을 사용합니다. EE에서 ee_component import 별칭은 ee/app/assets/javascripts 디렉터리를 가리킵니다. CE에서는 이 별칭이 아무것도 렌더링하지 않는 빈 컴포넌트로 해석됩니다.

다음은 EE 컴포넌트를 CE 컴포넌트로 import 하는 예시입니다.

<script>
// app/assets/javascripts/feature/components/form.vue

// In EE this will be resolved as `ee/app/assets/javascripts/feature/components/my_ee_component.vue`
// In CE as `app/assets/javascripts/vue_shared/components/empty_component.js`
import MyEeComponent from 'ee_component/feature/components/my_ee_component.vue';

export default {
  components: {
    MyEeComponent,
  },
};
</script>

<template>
  <div>
    <!-- ... -->
    <my-ee-component/>
    <!-- ... -->
  </div>

</template>
Note

CE 코드베이스 안에서 EE 컴포넌트의 렌더링이 어떤 검사(예: 기능 플래그 검사)에 의존한다면 EE 컴포넌트를 비동기로 import 할 수 있습니다.

glFeatures를 확인하여 Vue 컴포넌트가 보호되는지 확인합니다. 컴포넌트는 라이선스가 있을 때만 렌더링됩니다.

<script>
// ee/app/assets/javascripts/feature/components/special_component.vue

import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';

export default {
  mixins: [glFeatureFlagMixin()],
  computed: {
    shouldRenderComponent() {
      // Comes from gon.licensed_features as a camel-case version of `my_feature_name`
      return this.glFeatures.myFeatureName;
    }
  },
};
</script>

<template>
  <div v-if="shouldRenderComponent">
    <!-- EE licensed feature UI -->
  </div>

</template>
Note

반드시 필요한 경우가 아니라면 믹스인을 사용하지 않습니다. 대안이 되는 다른 패턴을 찾아봅니다.

권장 대안 방식(named/scoped 슬롯)#
  • 슬롯 또는 scoped 슬롯을 사용하면 믹스인으로 하던 것과 같은 일을 할 수 있습니다. EE 컴포넌트만 필요하다면 CE 컴포넌트를 만들 필요가 없습니다.
  1. 먼저, CE 기반 위에 EE 템플릿과 기능을 덧입혀야 하는 경우를 위해 슬롯을 렌더링할 수 있는 CE 컴포넌트를 둡니다.
// ./ce/my_component.vue

<script>
export default {
  props: {
    tooltipDefaultText: {
      type: String,
    },
  },
  computed: {
    tooltipText() {
      return this.tooltipDefaultText || "5 issues please";
    }
  },
}
</script>

<template>
  <span v-gl-tooltip :title="tooltipText" class="ce-text">Community Edition Only Text</span>
  <slot name="ee-specific-component">
</template>
  1. 다음으로 EE 컴포넌트를 렌더링하고, EE 컴포넌트 안에서 CE 컴포넌트를 렌더링하면서 슬롯에 추가 콘텐츠를 넣습니다.
// ./ee/my_component.vue

<script>
export default {
  computed: {
    tooltipText() {
      if (this.weight) {
        return "5 issues with weight 10";
      }
    }
  },
  methods: {
    submit() {
      // do something.
    }
  },
}
</script>

<template>
  <my-component :tooltipDefaultText="tooltipText">
    <template #ee-specific-component>
      <span class="some-ee-specific">EE Specific Value</span>
      <button @click="submit">Click Me</button>
    </template>
  </my-component>
</template>
  1. 마지막으로, 컴포넌트가 필요한 곳에서는 다음과 같이 require 합니다.

import MyComponent from 'ee_else_ce/path/my_component'.vue

  • 이렇게 하면 CE 또는 EE 구현에 맞는 올바른 컴포넌트가 포함됩니다.

같은 computed 값에 대해 다른 결과가 필요한 EE 컴포넌트는 예시와 같이 CE 래퍼에 props를 전달할 수 있습니다.

  • EE 추가 HTML
    • EE에서 HTML이 추가되는 템플릿은 새 컴포넌트로 옮기고 ee_else_ce import 별칭을 사용해야 합니다.

다른 JS 코드 확장#

JS 파일을 확장하려면 다음 단계를 완료합니다.

  1. ee_else_ce 헬퍼를 사용합니다. 이때 EE 전용 코드는 ee/ 폴더 안에 있어야 합니다.
    1. EE 부분만 담은 EE 파일을 만들고 이에 대응하는 CE 파일을 확장합니다.
    2. 함수 내부의 코드처럼 확장할 수 없는 코드는 새 파일로 옮기고 ee_else_ce 헬퍼를 사용합니다.
  import eeCode from 'ee_else_ce/ee_code';

  function test() {
    const test = 'a';

    eeCode();

    return test;
  }

경우에 따라 애플리케이션의 다른 로직을 확장해야 합니다. JS 모듈을 확장하려면 파일의 EE 버전을 만들고 사용자 지정 로직으로 확장합니다.

// app/assets/javascripts/feature/utils.js

export const myFunction = () => {
  // ...
};

// ... other CE functions ...
// ee/app/assets/javascripts/feature/utils.js
import {
  myFunction as ceMyFunction,
} from '~/feature/utils';

/* eslint-disable import/export */

// Export same utils as CE
export * from '~/feature/utils';

// Only override `myFunction`
export const myFunction = () => {
  const result = ceMyFunction();
  // add EE feature logic
  return result;
};

/* eslint-enable import/export */

EE/CE 별칭을 사용하는 모듈 테스트#

프론트엔드 테스트를 작성할 때, 테스트 대상 모듈이 ee_else_ce/...로 다른 모듈을 import 하고 해당 테스트에서도 그 모듈이 필요하다면, 해당 테스트는 그 모듈을 ee_else_ce/...로 import 해야 합니다. 이렇게 하면 예기치 않은 EE 또는 FOSS 실패를 피할 수 있고, EE가 라이선스가 없을 때 CE 처럼 동작하도록 하는 데 도움이 됩니다.

예:

<script>
// ~/foo/component_under_test.vue

import FriendComponent from 'ee_else_ce/components/friend.vue;'

export default {
  name: 'ComponentUnderTest',
  components: { FriendComponent }.
}
</script>

<template>
  <friend-component />
</template>
// spec/frontend/foo/component_under_test_spec.js

// ...
// because we referenced the component using ee_else_ce we have to do the same in the spec.
import Friend from 'ee_else_ce/components/friend.vue;'

describe('ComponentUnderTest', () => {
  const findFriend = () => wrapper.find(Friend);

  it('renders friend', () => {
    // This would fail in CE if we did `ee/component...`
    // and would fail in EE if we did `~/component...`
    expect(findFriend().exists()).toBe(true);
  });
});

EE 테스트와 CE 테스트 실행#

CE와 EE 환경 모두를 위한 테스트를 만들 때는 로컬과 파이프라인에서 실행할 때 두 테스트가 모두 통과하도록 몇 가지 단계를 거쳐야 합니다.

  • 기본적으로 테스트는 EE 환경에서 실행되며 EE 테스트와 CE 테스트를 모두 실행합니다.
  • FOSS 환경에서 CE 파일만 테스트하려면 다음 명령을 실행해야 합니다.
FOSS_ONLY=1 yarn jest path/to/spec/file.spec.js

CE 테스트에는 CE 기능만 추가하므로, EE 전용 목(mock) 데이터가 없으면 EE 환경에서 실패할 수 있습니다. CE 테스트가 두 환경에서 모두 동작하도록 하려면 다음을 수행합니다.

  • 목 데이터를 import 할 때 ee_else_ce_jest 별칭을 사용합니다. 예:
import { sidebarDataCountResponse } from 'ee_else_ce_jest/super_sidebar/mock_data';
  • CE와 EE의 mock_data 파일에 해당 데이터를 담은 객체(위 예시에서는 sidebarDataCountResponse)가 각각 있는지 확인합니다. CE 파일에는 CE 기능 데이터만 담고, EE 파일에는 CE와 EE 기능 데이터를 모두 담습니다.
  • CE 파일의 expect 블록에서 객체를 비교해야 한다면 toEqual 대신 toMatchObject를 사용하여 CE 데이터에 EE 데이터가 존재하리라고 기대하지 않도록 합니다. 예:
expect(findPinnedSection().props('asyncCount')).toMatchObject(asyncCountData);

assets/stylesheets의 SCSS 코드#

스타일을 추가하는 컴포넌트가 EE에 한정된다면 app/assets/stylesheets 안의 적절한 디렉터리에 별도의 SCSS 파일을 두는 것이 좋습니다.

경우에 따라서는 이렇게 하기 어렵거나 전용 SCSS 파일을 만드는 것이 과도할 수 있습니다. 예를 들어 어떤 컴포넌트의 텍스트 스타일이 EE에서만 다른 경우입니다. 이런 경우 스타일은 대개 CE와 EE가 공통으로 사용하는 스타일시트에 두며, CE에서 EE로 머지할 때 충돌을 피하기 위해 이러한 규칙 집합을 나머지 CE 규칙과 분리하는 것이 현명합니다 (같은 내용을 설명하는 주석도 함께 추가합니다).

// Bad
.section-body {
  .section-title {
    background: $gl-header-color;
  }

  &.ee-section-body {
    .section-title {
      background: $gl-header-color-cyan;
    }
  }
}
// Good
.section-body {
  .section-title {
    background: $gl-header-color;
  }
}

// EE-specific start
.section-body.ee-section-body {
  .section-title {
    background: $gl-header-color-cyan;
  }
}
// EE-specific end

GitLab-svgs#

app/assets/images/icons.json 또는 app/assets/images/icons.svg의 충돌은 yarn run svg로 해당 에셋을 다시 생성하여 해결할 수 있습니다.

Enterprise Edition 기능 구현 가이드라인

GitLab v19.4
원문 보기

요약

다음 다이어그램은 CE/EE/SaaS/Dedicated 계층 전반에서 기능을 어디에 어떻게 구현할지 결정하는 방법을 보여 줍니다. 이 다이어그램은 네 가지 주요 구현 계층을 보여 줍니다. SaaS에만 적용되는 기능(예: CustomersDot 통합)을 개발할 때는 다음 지침을 따릅니다.

  • 코드는 ee/에 둡니다: 모든 Enterprise Edition(EE) 코드를 최상위 ee/ 디렉터리 안에 둡니다. 나머지 코드는 Community Edition(CE) 파일과 최대한 가깝게 유지해야 합니다.
  • 테스트를 작성합니다: 다른 코드와 마찬가지로 EE 기능도 회귀를 방지할 수 있도록 충분한 테스트 커버리지를 갖춰야 합니다. ee/의 모든 코드에는 ee/ 안에 대응하는 테스트가 있어야 합니다.
  • 문서를 작성합니다.: doc/ 디렉터리에 문서를 추가합니다. 기능을 설명하고 해당하는 경우 스크린샷을 포함합니다. 기능이 적용되는 에디션을 명시합니다.
  • www-gitlab-com 프로젝트에 MR을 제출합니다.: 새 기능을 EE 기능 목록에 추가합니다.

개발 환경의 런타임 모드#

  1. EE Unlicensed: 메인 리포지터리에서 설치했다면 일반 GDK 설치 상태가 이 모드입니다.
  2. EE licensed: GDK에 유효한 라이선스를 추가한 경우입니다.
  3. GitLab.com: SaaS를 시뮬레이션하는 경우입니다.
  4. CE: 위의 어느 상태에서든 CE를 시뮬레이션하는 경우입니다.

기능 구현 결정 흐름#

다음 다이어그램은 CE/EE/SaaS/Dedicated 계층 전반에서 기능을 어디에 어떻게 구현할지 결정하는 방법을 보여 줍니다.

Mermaid 다이어그램 (57줄)
소스 코드 보기
%%{init: { "fontFamily": "GitLab Sans" }}%%
flowchart TD
    accTitle: Feature implementation decision flow
    accDescr: Diagram showing how to decide where and how to implement features across CE/EE/SaaS/Dedicated layers
A[Developer wants to implement a feature] --&gt; B{What type of feature?}

B --&gt;|CE Feature| C[Implement in main codebase]
B --&gt;|EE Licensed Feature| D[EE Feature Path]
B --&gt;|SaaS-only Feature| E[SaaS Feature Path]
B --&gt;|Dedicated Feature| F[Dedicated Feature Path]

C --&gt; C1[Place code in app/, lib/, etc.]
C --&gt; C2[Write tests in spec/]
C --&gt; C3[No license checks needed]

D --&gt; D1{New or extending existing?}
D1 --&gt;|New EE Feature| D2[Place in ee/ directory]
D1 --&gt;|Extending CE| D3[Create EE module with prepend_mod]

D2 --&gt; D4[Add to ee/app/models/gitlab_subscriptions/features.rb]
D3 --&gt; D4
D4 --&gt; D5{Which plan?}
D5 --&gt;|Premium| D6[Add to PREMIUM_FEATURES]
D5 --&gt;|Ultimate| D7[Add to ULTIMATE_FEATURES]
D5 --&gt;|Global/Instance| D8[Add to GLOBAL_FEATURES]

D6 --&gt; D9[Guard with project.licensed_feature_available?]
D7 --&gt; D9
D8 --&gt; D10[Guard with License.feature_available?]
D9 --&gt; D11[Write tests in ee/spec/]
D10 --&gt; D11
D11 --&gt; D12[Use stub_licensed_features in tests]

E --&gt; E1[Add feature to FEATURES in ee/lib/ee/gitlab/saas.rb]
E1 --&gt; E2[Create YAML definition in ee/config/saas_features/]
E2 --&gt; E3[Use bin/saas-feature.rb tool]
E3 --&gt; E4[Guard with Gitlab::Saas.feature_available?]
E4 --&gt; E5{Extending CE feature?}
E5 --&gt;|Yes| E6[Create EE module that extends CE]
E5 --&gt;|No| E7[Create new EE-only code]
E6 --&gt; E8[Use prepend_mod pattern]
E7 --&gt; E9[Place directly in ee/ directory]
E8 --&gt; E10[Write tests in ee/spec/]
E9 --&gt; E10
E10 --&gt; E11[Use stub_saas_features helper]

F --&gt; F1[Add to FEATURES in ee/lib/gitlab/dedicated.rb]
F1 --&gt; F2[Create YAML definition with bin/dedicated-feature.rb]
F2 --&gt; F3{Extending CE feature?}
F3 --&gt;|Yes| F4[Create EE module that extends CE]
F3 --&gt;|No| F5[Create new EE-only code]
F4 --&gt; F6[Use prepend_mod pattern]
F5 --&gt; F7[Place directly in ee/ directory]
F6 --&gt; F8[Guard with Gitlab::Dedicated.feature_available?]
F7 --&gt; F8
F8 --&gt; F9[Write tests in ee/spec/]</code></pre></details></div>

이 다이어그램은 네 가지 주요 구현 계층을 보여 줍니다.

  • CE (녹색): 라이선스 요구 사항이 없는 Community Edition 기능입니다. 대상 사용자가 GitLab.com의 무료 사용자라면 SaaS 결정 경로를 따릅니다.
  • EE (주황색): Premium/Ultimate 라이선스가 필요한 Enterprise Edition 기능입니다.
  • SaaS (분홍색): GitLab.com 인스턴스에서만 제공되는 기능입니다.
  • Dedicated (파란색): GitLab Dedicated 인스턴스에서 다르게 동작하는 기능입니다.

주요 결정 사항은 다음과 같습니다.

  • 파일 위치: CE 코드는 기본 디렉터리에, EE 코드는 ee/ 하위 디렉터리에 둡니다.
  • 기능 가드: 계층마다 방법이 다릅니다(licensed_feature_available?, License.feature_available?, Gitlab::Saas.feature_available?, Gitlab::Dedicated.feature_available?).
  • 테스트 방식: 계층마다 전용 헬퍼와 메타데이터가 있습니다.

SaaS 전용 기능#

SaaS에만 적용되는 기능(예: CustomersDot 통합)을 개발할 때는 다음 지침을 따릅니다.

일반적으로 기능은 SaaS와 Self-managed 배포 모두에 제공해야 합니다. 그러나 기능을 SaaS에서만 제공해야 하는 경우가 있으며, 이 가이드는 그 방법을 설명합니다.

Gitlab::Saas.feature_available?를 사용하는 것을 권장합니다. 이렇게 하면 해당 기능이 SaaS 전용인 이유에 대한 풍부한 컨텍스트 정의를 남길 수 있습니다.

Gitlab::Saas.feature_available?로 SaaS 전용 기능 구현#

FEATURES 상수에 추가#

  1. 새 SaaS 전용 기능의 이름을 정할 때는 네임스페이스 개념 가이드를 참고합니다.

  2. ee/lib/gitlab/saas.rb의 FEATURE에 새 기능을 추가합니다.

    FEATURES = %i[purchases_additional_minutes some_domain_new_feature_name].freeze
    
  3. 코드에서 Gitlab::Saas.feature_available?(:some_domain_new_feature_name)로 새 기능을 사용합니다.

SaaS 전용 기능 정의 및 검증#

이 절차는 코드베이스에서 SaaS 기능이 일관되게 사용되도록 하기 위한 것입니다. 모든 SaaS 기능은 다음을 충족해야 합니다.

  • 알려져 있어야 합니다. 명시적으로 정의된 SaaS 기능만 사용합니다.
  • 소유자가 있어야 합니다.

모든 SaaS 기능은 다음 위치에 저장된 YAML 파일에 스스로 문서화됩니다.

각 SaaS 기능은 여러 필드로 구성된 별도의 YAML 파일로 정의합니다.

필드 필수 설명
name 예 SaaS 기능의 이름입니다.
introduced_by_url 아니요 SaaS 기능을 도입한 머지 리퀘스트의 URL입니다.
milestone 아니요 SaaS 기능이 만들어진 마일스톤입니다.
group 아니요 해당 기능 플래그를 소유한 그룹입니다.

새 SaaS 기능 파일 정의 생성#

GitLab 코드베이스는 새 SaaS 기능 정의를 만드는 전용 도구인 bin/saas-feature.rb를 제공합니다. 이 도구는 새 SaaS 기능에 대해 여러 질문을 한 다음 ee/config/saas_features에 YAML 정의를 생성합니다.

YAML 정의 파일이 있는 SaaS 기능만 개발 또는 테스트 환경을 실행할 때 사용할 수 있습니다.

❯ bin/saas-feature.rb my_saas_feature
You picked the group 'group::acquisition'

>> URL of the MR introducing the SaaS feature (enter to skip and let Danger provide a suggestion directly in the MR):
?> https://gitlab.com/gitlab-org/gitlab/-/merge_requests/38602
create ee/config/saas_features/my_saas_feature.yml
---
name: my_saas_feature
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/38602
milestone: '16.8'
group: group::acquisition

다른 SaaS 인스턴스(JiHu)에서 SaaS 전용 기능 제외#

ee/lib/gitlab/saas.rb 클래스를 prepend 하고 Gitlab::Saas.feature_available? 메서드를 오버라이드합니다.

JH_DISABLED_FEATURES = %i[some_domain_new_feature_name].freeze

override :feature_available?
def feature_available?(feature)
  super && JH_DISABLED_FEATURES.exclude?(feature)
end

CE의 기능에 SaaS 전용 기능을 사용하지 않음#

Gitlab::Saas.feature_available?는 CE에 나타나서는 안 됩니다. EE로 CE 확장 가이드를 참고합니다.

테스트의 SaaS 전용 기능#

SaaS 전용 기능을 코드베이스에 도입하면 테스트해야 할 코드 경로가 추가됩니다. SaaS 전용 기능의 영향을 받는 모든 코드에 대해 기능이 활성화된 경우와 비활성화된 경우 모두 자동화된 테스트를 포함하여 기능이 올바르게 동작하는지 확인합니다.

애플리케이션 코드에서 어떤 것이 SaaS 전용인 이유를 전달하기 위해 Gitlab.com? 대신 Gitlab::Saas.feature_available?(:specific_feature)를 사용하는 것처럼, 테스트에서도 같은 이유로 구체적인 SaaS 기능 메타데이터 태그를 사용해야 합니다. 이렇게 하면 기능 구현과 테스트 사이에 명확한 연결이 생겨 코드베이스의 유지 관리성이 높아지고 스스로 문서화됩니다.

SaaS 기능 메타데이터 태그 사용(권장)#

대부분의 테스트 시나리오에서는 stub_saas_features를 직접 호출하지 않고 메타데이터 태그로 SaaS 기능을 자동으로 활성화합니다. 이 방식은 통합 테스트이거나 테스트 컨텍스트 전체에서 SaaS 기능을 활성화해야 할 때 특히 유용합니다.

SaaS 기능 이름 앞에 saas_를 붙여 테스트 컨텍스트 또는 개별 예제에 메타데이터로 추가합니다.

# Context-level metadata (applies to all examples in the context)
describe 'some feature', :saas_gitlab_com_subscriptions do
  it 'shows SaaS-specific functionality' do
    expect(page).to have_content('SaaS Feature')
  end
end

# Individual example metadata
describe 'some feature' do
  it 'shows SaaS-specific functionality', :saas_gitlab_com_subscriptions do
    expect(page).to have_content('SaaS Feature')
  end

  it 'works without SaaS features' do
    expect(page).not_to have_content('SaaS Feature')
  end
end

# Multiple SaaS features
context 'with multiple SaaS features', :saas_onboarding, :saas_gitlab_com_subscriptions do
  # Both 'onboarding' and 'duo_enterprise' features are enabled
end

이 메타데이터 방식은 다음과 같이 동작합니다.

  • 태그가 지정된 각 기능에 대해 stub_saas_features(feature_name: true)를 자동으로 호출합니다.
  • 컨텍스트 수준(describe/context 블록)과 개별 예제 수준(it 블록) 모두에서 동작합니다.
  • Gitlab::Saas::FEATURES에 정의된 모든 SaaS 기능에서 동작합니다.
  • before 블록에서 stub_saas_features를 직접 호출하는 것보다 깔끔합니다.

테스트 컨텍스트나 특정 예제에서 SaaS 기능을 활성화해야 할 때 이 방식을 사용합니다. 더 세밀한 제어가 필요하거나 같은 예제 안에서 활성화/비활성화 상태를 모두 테스트해야 할 때는 계속 stub_saas_features 헬퍼를 직접 사용합니다.

stub_saas_features 헬퍼 사용(고급 시나리오)#

기능 상태를 세밀하게 제어해야 하거나 같은 테스트 안에서 활성화/비활성화 경로를 모두 테스트해야 하는 복잡한 시나리오에서는 stub_saas_features 헬퍼를 직접 사용합니다.

테스트에서 SaaS 전용 기능을 활성화하려면 stub_saas_features 헬퍼를 사용합니다.

stub_saas_features(purchases_additional_minutes: true)

::Gitlab::Saas.feature_available?(:purchases_additional_minutes) # => true

두 경로를 모두 테스트하는 일반적인 패턴은 다음과 같습니다.

it 'purchases/additional_minutes is not available by default' do
  # tests assuming purchases_additional_minutes is not enabled by default
  ::Gitlab::Saas.feature_available?(:purchases_additional_minutes) # => false
end

context 'when purchases_additional_minutes is available' do
  before do
    stub_saas_features(purchases_additional_minutes: true)
  end

  it 'returns true' do
    ::Gitlab::Saas.feature_available?(:purchases_additional_minutes) # => true
  end
end

:saas 메타데이터 헬퍼 사용(특정 시나리오)#

:saas 메타데이터 헬퍼는 코드가 구체적인 SaaS 기능이 아니라 Gitlab.com? 방식에 의존하는 특정 시나리오에서 사용합니다. 여기에는 다음이 포함됩니다.

  • 아직 구체적인 SaaS 기능을 사용하도록 변환되지 않은 코드
  • 데이터베이스 마이그레이션처럼 Gitlab.com? 검사가 적절한 방식인 영역(SaaS 기능 패턴의 예외)

새 SaaS 전용 기능에는 SaaS 기능 메타데이터 태그를 대신 사용합니다.

테스트에 대한 자세한 내용은 SaaS에 의존하는 테스트를 참고합니다.

스펙에서의 사용 예시는 다음과 같습니다.

# spec/migrations/20240510113339_add_saas_specific_column_spec.rb
RSpec.describe AddSaasSpecificColumn do
  it 'adds column for self-managed instances' do
    migrate!

    expect(table(:projects)).to have_column(:some_column)
  end

  context 'when SaaS', :saas do
    it 'adds additional SaaS-specific column' do
      migrate!

      expect(table(:projects)).to have_column(:some_column)
      expect(table(:projects)).to have_column(:saas_specific_column)
    end
  end
end

SaaS 인스턴스 시뮬레이션#

로컬에서 개발하면서 인스턴스가 제품의 SaaS(GitLab.com) 버전을 시뮬레이션하도록 해야 한다면 다음을 수행합니다.

  1. 다음 환경 변수를 내보냅니다.

    export GITLAB_SIMULATE_SAAS=1
    

    로컬 GitLab 인스턴스에 환경 변수를 전달하는 방법은 여러 가지입니다. 예를 들어 gdk.yml 파일에 항목을 만들 수 있습니다.

  2. Allow use of licensed EE features를 활성화하여 프로젝트 네임스페이스의 플랜에 해당 기능이 포함된 경우에만 라이선스가 필요한 EE 기능을 프로젝트에서 사용할 수 있도록 합니다.

    1. 오른쪽 상단에서 Admin을 선택합니다.
    2. 왼쪽 사이드바에서 Settings > General을 선택합니다.
    3. Account and limit을 확장합니다.
    4. Allow use of licensed EE features 체크박스를 선택합니다.
    5. Save changes를 선택합니다.
  3. EE 기능을 테스트하려는 그룹이 실제로 EE 플랜을 사용 중인지 확인합니다.

    1. 오른쪽 상단에서 Admin을 선택합니다.
    2. 왼쪽 사이드바에서 Overview > Groups를 선택합니다.
    3. 수정할 그룹을 찾아 Edit를 선택합니다.
    4. Permissions and group features로 스크롤합니다. Plan에서 Ultimate를 선택합니다.
    5. Save changes를 선택합니다.

위 단계를 보여 주는 📺 동영상이 있습니다.

Dedicated 인스턴스 기능#

코드에서 GitLab Dedicated 인스턴스를 다르게 처리해야 할 때는 다음 지침을 따릅니다.

GitLab Dedicated 인스턴스는 Dedicated 아키텍처에 문서화된 대로 항상 Ultimate 티어로 프로비저닝됩니다. Dedicated는 Enterprise Edition 전용 제품이므로 Dedicated 전용 코드는 모두 ee/ 디렉터리 구조 안에 두어야 하며, 다른 EE 기능과 같은 패턴을 따릅니다.

일반적인 사용 사례#

Dedicated 전용 코드는 Dedicated 인스턴스에서만 제공해야 하는 기능을 위한 것입니다.

일반적으로 기능은 SaaS와 Self-managed 배포 모두에 제공해야 합니다. 그러나 Dedicated 전용 기능이 타당한 경우도 있습니다.

Gitlab::Dedicated 메서드 사용#

Gitlab::Dedicated 모듈은 Dedicated 전용 동작을 처리하는 feature_available? 메서드를 제공합니다.

기능을 Dedicated에서만 실행해야 할 때는 FEATURES 목록과 함께 feature_available?를 사용합니다.

return unless Gitlab::Dedicated.feature_available?(:custom_backup_strategy)

# Custom backup code that only runs on Dedicated

ee/lib/gitlab/dedicated.rb의 FEATURES에 기능을 추가합니다.

FEATURES = %i[custom_backup_strategy skip_ultimate_trial_experience].freeze

feature_available? 메서드는 ee/config/dedicated_features/의 YAML 파일을 통해 해당 기능이 Dedicated 인스턴스에서 다르게 동작하는 이유를 컨텍스트가 풍부하게 문서화할 수 있게 합니다.

Dedicated 기능 정의 및 검증#

이 절차는 코드베이스에서 Dedicated 기능이 일관되게 사용되도록 합니다. 모든 Dedicated 기능은 다음을 충족해야 합니다.

  • 알려져 있어야 합니다. FEATURES에 명시적으로 정의된 Dedicated 기능만 사용합니다.
  • 소유자가 있어야 합니다.

모든 Dedicated 기능은 다음 위치에 저장된 YAML 파일에 스스로 문서화됩니다.

각 Dedicated 기능은 여러 필드로 구성된 별도의 YAML 파일로 정의합니다.

필드 필수 설명
name 예 Dedicated 기능의 이름입니다.
introduced_by_url 아니요 Dedicated 기능을 도입한 머지 리퀘스트의 URL입니다.
milestone 아니요 Dedicated 기능이 만들어진 마일스톤입니다.
group 아니요 해당 기능을 소유한 그룹입니다.

새 Dedicated 기능 파일 정의 생성#

GitLab 코드베이스는 새 Dedicated 기능 정의를 만드는 도구인 bin/dedicated-feature.rb를 제공합니다. 이 도구는 새 Dedicated 기능에 대해 여러 질문을 한 다음 ee/config/dedicated_features에 YAML 정의를 생성합니다.

YAML 정의 파일이 있는 Dedicated 기능만 개발 또는 테스트 환경을 실행할 때 사용할 수 있습니다.

새 Dedicated 기능 정의를 만들려면 다음을 수행합니다.

  1. 기능의 이름을 정할 때는 네임스페이스 개념 가이드를 참고합니다.
  2. ee/lib/gitlab/dedicated.rb의 FEATURES에 기능을 추가합니다.
  3. bin/dedicated-feature.rb <feature-name>을 실행하여 ee/config/dedicated_features/에 YAML 정의를 생성합니다.

실행 예시는 다음과 같습니다.

❯ bin/dedicated-feature.rb my_dedicated_feature
You picked the group 'group::acquisition'

>> URL of the MR introducing the Dedicated feature (enter to skip and let Danger provide a suggestion directly in the MR):
?> https://gitlab.com/gitlab-org/gitlab/-/merge_requests/123456
create ee/config/dedicated_features/my_dedicated_feature.yml
---
name: my_dedicated_feature
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/123456
milestone: '19.1'
group: group::acquisition

Dedicated 코드가 ee/에 있어야 하는 이유#

Dedicated 전용 코드는 모두 ee/ 디렉터리 구조 안에 두어야 합니다. 이렇게 하면 다음이 보장됩니다.

  • Dedicated 기능은 EE 빌드에서만 사용할 수 있습니다.
  • 코드베이스에서 CE와 EE 기능이 명확히 분리됩니다.
  • Dedicated 인스턴스는 모든 Ultimate 기능과 Dedicated 전용 동작을 함께 사용할 수 있습니다.

SaaS 전용 기능과 마찬가지로 애플리케이션 코드에서 Gitlab::CurrentSettings.gitlab_dedicated_instance?를 직접 사용하지 않습니다. 대신 Gitlab::Dedicated.feature_available?(:specific_feature)를 사용하여 해당 기능이 Dedicated에서 다르게 동작하는 이유에 대한 컨텍스트를 제공합니다.

Gitlab/AvoidGitlabDedicatedInstanceChecks RuboCop 규칙은 RuboCop 설정에서 명시적으로 제외한 경우를 제외하고 Gitlab::CurrentSettings.gitlab_dedicated_instance?와 Gitlab::Dedicated.dedicated_instance?를 직접 호출하는 코드를 표시하여 이 규칙을 강제합니다.

데이터베이스 마이그레이션의 예외#

데이터베이스 마이그레이션은 배포 유형에 따라 마이그레이션이 다르게 동작해야 할 때 Gitlab::CurrentSettings.gitlab_dedicated_instance?로 Dedicated 인스턴스 여부를 확인해야 할 수 있습니다. 이는 Gitlab::Dedicated.feature_available? 패턴을 사용할 수 없는 마이그레이션에서는 허용됩니다.

새 EE 기능 구현#

GitLab Premium 또는 GitLab Ultimate 라이선스 기능을 개발한다면 다음 단계에 따라 새 기능을 추가하거나 기존 기능을 확장합니다.

GitLab 라이선스 기능은 ee/app/models/gitlab_subscriptions/features.rb에 추가합니다. 이 파일을 어떻게 수정할지 정하려면 먼저 기능이 라이선스 체계에 어떻게 들어맞는지 Product Manager와 논의합니다.

다음 질문을 참고합니다.

  1. 새 기능인지, 기존 라이선스 기능을 확장하는 것인지 판단합니다.
    • 기능이 이미 있다면 features.rb를 수정할 필요는 없지만, 기능을 보호하려면 기존 기능 식별자를 찾아야 합니다.
    • 새 기능이라면 my_feature_name 같은 식별자를 정하여 features.rb 파일에 추가합니다.
  2. GitLab Premium 기능인지 GitLab Ultimate 기능인지 판단합니다.
    • 기능을 사용할 플랜에 따라 기능 식별자를 PREMIUM_FEATURES 또는 ULTIMATE_FEATURES에 추가합니다.
  3. 이 기능을 전역으로(GitLab 인스턴스 전체에서) 사용할 수 있는지 판단합니다.
    • Geo와 Database Load Balancing 같은 기능은 인스턴스 전체에서 사용하며 개별 사용자 네임스페이스로 제한할 수 없습니다. 이러한 기능은 인스턴스 라이선스에 정의됩니다. 이러한 기능을 GLOBAL_FEATURES에 추가합니다.

EE 기능 보호#

라이선스가 필요한 기능은 라이선스가 있는 사용자만 사용할 수 있습니다. 사용자가 기능에 접근할 수 있는지 판단하려면 검사 또는 가드를 추가해야 합니다.

라이선스 기능을 보호하려면 다음을 수행합니다.

  1. ee/app/models/gitlab_subscriptions/features.rb에서 기능 식별자를 찾습니다.

  2. 다음 메서드를 사용합니다. 여기서 my_feature_name은 기능 식별자입니다.

    • 프로젝트 컨텍스트에서는 다음과 같이 합니다.

      my_project.licensed_feature_available?(:my_feature_name) # true if available for my_project
      
    • 그룹 또는 사용자 네임스페이스 컨텍스트에서는 다음과 같이 합니다.

      my_group.licensed_feature_available?(:my_feature_name) # true if available for my_group
      
    • 전역(시스템 전체) 기능에는 다음과 같이 합니다.

      License.feature_available?(:my_feature_name)  # true if available in this instance
      
  3. 선택 사항입니다. 전역 기능을 유료 플랜의 네임스페이스에서도 사용할 수 있다면, 두 기능 식별자를 조합하여 관리자와 그룹 사용자를 모두 허용합니다. 예:

    License.feature_available?(:my_feature_name) || group.licensed_feature_available?(:my_feature_name_for_namespace) # Both admins and group members can see this EE feature
    

라이선스가 없을 때 CE 인스턴스 시뮬레이션#

다음 작업이 구현된 이후로 GitLab CE 기능이 라이선스가 없는 EE 인스턴스에서 동작하도록 하는 작업 GitLab Enterprise Edition은 활성화된 라이선스가 없을 때 GitLab Community Edition처럼 동작합니다.

CE 스펙은 가능한 한 그대로 두고 EE용 스펙을 추가해야 합니다. 라이선스 기능은 EE::LicenseHelpers의 스펙 헬퍼 stub_licensed_features로 스텁 처리할 수 있습니다.

ee/ 디렉터리를 삭제하거나 FOSS_ONLY 환경 변수를 true로 평가되는 값으로 설정하면 GitLab이 CE로 동작하도록 강제할 수 있습니다. 테스트 실행에서도 같은 방식이 동작합니다(예: FOSS_ONLY=1 yarn jest).

라이선스가 있는 GDK에서 CE 인스턴스 시뮬레이션#

GDK의 라이선스를 삭제하지 않고 CE 인스턴스를 시뮬레이션하려면 다음을 수행합니다.

  1. gdk.yml에 다음 항목을 추가합니다.

    env:
      FOSS_ONLY: "1"
    
  2. 그런 다음 GDK를 다시 시작합니다.

    gdk restart
    

EE 설치로 되돌리려면 gdk.yml에서 환경 변수를 제거한 다음 2단계를 반복합니다.

CE로 기능 스펙 실행#

기능 스펙을 CE로 실행할 때는 백엔드와 프론트엔드의 에디션이 일치하는지 확인해야 합니다. 이를 위해 다음을 수행합니다.

  1. FOSS_ONLY=1 환경 변수를 설정합니다.

    export FOSS_ONLY=1
    
  2. GDK를 시작합니다.

    gdk start
    
  3. 기능 스펙을 실행합니다.

    bin/rspec spec/features/<path_to_your_spec>
    

FOSS 컨텍스트에서 CI 파이프라인 실행#

기본적으로 개발용 머지 리퀘스트 파이프라인은 EE 컨텍스트에서만 실행됩니다. FOSS와 EE에서 다르게 동작하는 기능을 개발한다면 FOSS 컨텍스트에서도 파이프라인을 실행해야 할 수 있습니다.

두 컨텍스트에서 모두 파이프라인을 실행하려면 머지 리퀘스트에 ~"pipeline:run-as-if-foss" 레이블을 추가합니다.

자세한 내용은 As-if-FOSS job과 크로스 프로젝트 다운스트림 파이프라인 파이프라인 문서를 참고합니다.

백엔드의 EE 코드 분리#

EE 전용 기능#

개발 중인 기능이 CE에 어떤 형태로도 존재하지 않는다면 코드를 EE 네임스페이스 아래에 둘 필요가 없습니다. 예를 들어 EE 모델은 클래스 이름을 Awesome으로 하여 ee/app/models/awesome.rb에 둘 수 있습니다. 이는 모델에만 적용되지 않습니다. 다른 예는 다음과 같습니다.

  • ee/app/controllers/foos_controller.rb
  • ee/app/finders/foos_finder.rb
  • ee/app/helpers/foos_helper.rb
  • ee/app/mailers/foos_mailer.rb
  • ee/app/models/foo.rb
  • ee/app/policies/foo_policy.rb
  • ee/app/serializers/foo_entity.rb
  • ee/app/serializers/foo_serializer.rb
  • ee/app/services/foo/create_service.rb
  • ee/app/validators/foo_attr_validator.rb
  • ee/app/workers/foo_worker.rb
  • ee/app/views/foo.html.haml
  • ee/app/views/foo/_bar.html.haml
  • ee/config/initializers/foo_bar.rb

CE의 eager-load/auto-load 경로마다 같은 경로 앞에 ee/를 붙인 경로를 config/application.rb에 추가하기 때문에 이 방식이 동작합니다. 뷰에도 마찬가지로 적용됩니다.

EE 전용 백엔드 기능 테스트#

CE에 없는 EE 클래스를 테스트하려면 평소처럼 ee/spec 디렉터리에 스펙 파일을 만들되, 두 번째 ee/ 하위 디렉터리는 두지 않습니다. 예를 들어 클래스 ee/app/models/vulnerability.rb의 테스트는 ee/spec/models/vulnerability_spec.rb에 둡니다.

기본적으로 specs/의 스펙에서는 라이선스 기능이 비활성화되어 있습니다. ee/spec 디렉터리의 스펙은 기본적으로 Starter 라이선스가 초기화되어 있습니다.

기능을 제대로 테스트하려면 다음 예시처럼 stub_licensed_features 헬퍼로 기능을 명시적으로 활성화해야 합니다.

  stub_licensed_features(my_awesome_feature_name: true)

EE 백엔드 코드로 CE 기능 확장#

기존 CE 기능을 기반으로 하는 기능은 EE 네임스페이스에 모듈을 작성하고, 클래스가 있는 파일의 마지막 줄에서 CE 클래스에 주입합니다. 이렇게 하면 CE 클래스에 모듈을 주입하는 한 줄만 추가되므로 CE에서 EE로 머지할 때 충돌이 발생할 가능성이 줄어듭니다. 예를 들어 User 클래스에 모듈을 prepend 하려면 다음 방식을 사용합니다.

class User < ActiveRecord::Base
  # ... lots of code here ...
end

User.prepend_mod

prepend, extend, include 같은 메서드는 사용하지 않습니다. 대신 prepend_mod, extend_mod, include_mod를 사용합니다. 이 메서드들은 수신 모듈의 이름으로 해당하는 EE 모듈을 찾으려 합니다. 예를 들면 다음과 같습니다.

module Vulnerabilities
  class Finding
    #...
  end
end

Vulnerabilities::Finding.prepend_mod

이 코드는 ::EE::Vulnerabilities::Finding이라는 이름의 모듈을 prepend 합니다.

확장 모듈이 이 명명 규칙을 따르지 않는다면 prepend_mod_with, extend_mod_with, include_mod_with로 모듈 이름을 직접 지정할 수도 있습니다. 이 메서드들은 모듈 자체가 아니라 전체 모듈 이름을 담은 _String_을 인자로 받으며, 다음과 같이 사용합니다.

class User
  #...
end

User.prepend_mod_with('UserExtension')

이 모듈에는 EE 네임스페이스가 필요하므로 파일도 ee/ 하위 디렉터리에 두어야 합니다. 예를 들어 EE에서 사용자 모델을 확장하려는 경우 ::EE::User라는 모듈을 ee/app/models/ee/user.rb 안에 둡니다.

이 방식도 모델에만 적용되지 않습니다. 다른 예는 다음과 같습니다.

  • ee/app/controllers/ee/foos_controller.rb
  • ee/app/finders/ee/foos_finder.rb
  • ee/app/helpers/ee/foos_helper.rb
  • ee/app/mailers/ee/foos_mailer.rb
  • ee/app/models/ee/foo.rb
  • ee/app/policies/ee/foo_policy.rb
  • ee/app/serializers/ee/foo_entity.rb
  • ee/app/serializers/ee/foo_serializer.rb
  • ee/app/services/ee/foo/create_service.rb
  • ee/app/validators/ee/foo_attr_validator.rb
  • ee/app/workers/ee/foo_worker.rb

CE 기능을 기반으로 하는 EE 기능 테스트#

CE 클래스를 EE 기능으로 확장하는 EE 네임스페이스 모듈을 테스트하려면 평소처럼 ee/spec 디렉터리에 스펙 파일을 만들되, 두 번째 ee/ 하위 디렉터리를 포함합니다. 예를 들어 확장 ee/app/models/ee/user.rb의 테스트는 ee/spec/models/ee/user_spec.rb에 둡니다.

RSpec.describe 호출에는 EE 모듈이 사용될 자리에 CE 클래스 이름을 사용합니다. 예를 들어 ee/spec/models/ee/user_spec.rb에서 테스트는 다음과 같이 시작합니다.

RSpec.describe User do
  describe 'ee feature added through extension'
end

CE 메서드 오버라이드#

CE 코드베이스에 있는 메서드를 오버라이드하려면 prepend를 사용합니다. 이를 통해 클래스의 메서드를 모듈의 메서드로 오버라이드하면서도 super로 클래스의 구현에 계속 접근할 수 있습니다.

이 방식에는 주의할 점이 몇 가지 있습니다.

  • 항상 extend ::Gitlab::Utils::Override를 사용하고 override로 overrider 메서드를 보호해야 합니다. 그래야 CE에서 메서드 이름이 바뀌더라도 EE 오버라이드가 조용히 잊히지 않습니다.

  • overrider가 CE 구현의 중간에 한 줄을 추가해야 한다면 CE 메서드를 리팩터링하여 더 작은 메서드로 나누어야 합니다. 또는 CE에서는 비어 있고 EE에서 EE 전용 구현을 갖는 "훅" 메서드를 만듭니다.

  • 원래 구현에 가드 절(예: return unless condition)이 있으면 메서드를 오버라이드하는 것만으로는 동작을 쉽게 확장할 수 없습니다. 오버라이드된 메서드(즉, 오버라이딩 메서드에서 super를 호출하는 것)가 언제 일찍 중단하려 할지 알 수 없기 때문입니다. 이 경우 단순히 오버라이드하지 말고, 원래 메서드를 수정하여 확장하려는 다른 메서드를 호출하게 합니다. 템플릿 메서드 패턴과 같습니다. 예를 들어 다음과 같은 기본 클래스가 있다고 하겠습니다.

      class Base
        def execute
          return unless enabled?
    
          # ...
          # ...
        end
      end
    

    Base#execute를 그냥 오버라이드하는 대신, 이를 수정하여 동작을 다른 메서드로 추출해야 합니다.

      class Base
        def execute
          return unless enabled?
    
          do_something
        end
    
        private
    
        def do_something
          # ...
          # ...
        end
      end
    

    그러면 가드를 걱정하지 않고 do_something을 자유롭게 오버라이드할 수 있습니다.

      module EE::Base
        extend ::Gitlab::Utils::Override
    
        override :do_something
        def do_something
          # Follow the above pattern to call super and extend it
        end
      end
    

prepend 할 때는 ee/ 전용 하위 디렉터리에 두고, 이름 충돌을 피하도록 클래스나 모듈을 module EE로 감쌉니다.

예를 들어 ApplicationController#after_sign_out_path_for의 CE 구현을 오버라이드하는 경우는 다음과 같습니다.

def after_sign_out_path_for(resource)
  current_application_settings.after_sign_out_path.presence || new_user_session_path
end

메서드를 제자리에서 수정하는 대신 기존 파일에 prepend를 추가해야 합니다.

class ApplicationController < ActionController::Base
  # ...

  def after_sign_out_path_for(resource)
    current_application_settings.after_sign_out_path.presence || new_user_session_path
  end

  # ...
end

ApplicationController.prepend_mod_with('ApplicationController')

그리고 ee/ 하위 디렉터리에 변경된 구현을 담은 새 파일을 만듭니다.

module EE
  module ApplicationController
    extend ::Gitlab::Utils::Override

    override :after_sign_out_path_for
    def after_sign_out_path_for(resource)
      if Gitlab::Geo.secondary?
        Gitlab::Geo.primary_node.oauth_logout_url(@geo_logout_state)
      else
        super
      end
    end
  end
end
CE 클래스 메서드 오버라이드#

클래스 메서드에도 같은 방식이 적용됩니다. 다만 ActiveSupport::Concern을 사용하고 extend ::Gitlab::Utils::Override를 class_methods 블록 안에 둡니다. 예시는 다음과 같습니다.

module EE
  module Groups
    module GroupMembersController
      extend ActiveSupport::Concern

      class_methods do
        extend ::Gitlab::Utils::Override

        override :admin_not_required_endpoints
        def admin_not_required_endpoints
          super.concat(%i[update override])
        end
      end
    end
  end
end

자기 설명적인 래퍼 메서드 사용#

메서드의 구현을 수정할 수 없거나 수정하는 것이 논리적이지 않다면 자기 설명적인 메서드로 감싸고 그 메서드를 사용합니다.

예를 들어 GitLab-FOSS에서 시스템이 만드는 유일한 사용자는 Users::Internal.in_organization(Organizations::Organization.first).ghost이지만, EE에는 실제로는 사용자가 아닌 봇 사용자 유형이 여러 가지 있습니다. User#ghost?의 구현을 오버라이드하면 올바르지 않으므로, 대신 app/models/user.rb에 #internal? 메서드를 추가합니다. 구현은 다음과 같습니다.

def internal?
  ghost?
end

EE의 구현 ee/app/models/ee/users.rb는 다음과 같습니다.

override :internal?
def internal?
  super || bot?
end

config/initializers의 코드#

Rails 초기화 코드는 다음 위치에 있습니다.

  • CE 전용 기능은 config/initializers
  • EE 기능은 ee/config/initializers

config/initializers에서는 분리할 수 없을 때만 Gitlab.ee { ... }/Gitlab.ee?를 사용합니다. 예:

SomeGem.configure do |config|
  config.base = 'https://example.com'

  config.encryption = true if Gitlab.ee?
end

클래스 메서드로 이니셜라이저 확장#

이니셜라이저에서 사용하는 클래스 메서드를 오버라이드해야 하는 더 복잡한 시나리오에서는 모델과 비슷하게 prepend_mod_with 패턴을 사용할 수 있습니다. 이 방식은 app/models를 확장하는 방식과 같으며 CE와 EE 로직을 깔끔하게 분리할 수 있습니다.

이 패턴은 이니셜라이저에서 구성해야 하는 SaaS 전용 기능에 특히 유용합니다. 그런 기능은 모든 EE 인스턴스가 아니라 SaaS 인스턴스에서만 활성화해야 하므로 Gitlab.ee? 만으로는 충분하지 않습니다.

예를 들어 config/initializers/doorkeeper.rb에서는 다음과 같습니다.

# The initializer calls a class method that can be overridden in EE
allow_grant_flow_for_client do |grant_flow, client|
  next false if Applications::CreateService.disable_ropc_for_all_applications?
  # ... other logic
end

CE 서비스(app/services/applications/create_service.rb)는 다음과 같습니다.

module Applications
  class CreateService
    # Define class methods that return false by default but can be overridden in EE
    def self.disable_ropc_for_all_applications?
      false
    end

    # ... other methods
  end
end

# Allow EE to extend this service
Applications::CreateService.prepend_mod_with('Applications::CreateService')

EE 확장(ee/app/services/ee/applications/create_service.rb)은 다음과 같습니다.

module EE
  module Applications
    module CreateService
      def self.prepended(base)
        base.singleton_class.prepend(ClassMethods)
      end

      module ClassMethods
        extend ::Gitlab::Utils::Override

        override :disable_ropc_for_all_applications?
        def disable_ropc_for_all_applications?
          ::Gitlab::Saas.feature_available?(:disable_ropc_for_all_applications)
        end
      end
    end
  end
end

이 패턴을 사용하면 이니셜라이저가 CE와 EE에서 동작이 다른 메서드를 호출할 수 있고, 이니셜라이저 코드 자체는 에디션 간에 변경하지 않아도 됩니다.

config/routes의 코드#

config/routes.rb에 draw_all :admin을 추가하면 애플리케이션은 config/routes/admin.rb에 있는 파일을 로드하려 하고, ee/config/routes/admin.rb에 있는 파일도 로드하려 합니다.

파일을 하나도 찾지 못하면 오류가 발생합니다.

EE에서는 최소 한 개, 최대 두 개의 파일을 로드해야 합니다. CE에서는 파일을 하나만 로드합니다.

CE와 EE 라우트 파일이 모두 있는 라우트에는 draw_all을 사용합니다.

EE 전용 라우트를 추가하려면 대신 Gitlab.ee와 함께 draw를 사용합니다.

Gitlab.ee do
  draw :ee_only
end

app/controllers/의 코드#

컨트롤러에서 가장 흔한 충돌 유형은 CE에서는 액션 목록을 가진 before_action에 EE가 액션을 추가하는 경우입니다.

params.require / params.permit 호출에서도 같은 문제가 자주 발생합니다.

완화 방법

CE와 EE의 액션/키워드를 분리합니다. 예를 들어 ProjectsController의 params.require는 다음과 같이 합니다.

def project_params
  params.require(:project).permit(project_params_attributes)
end

# Always returns an array of symbols, created however best fits the use case.
# It should be sorted alphabetically.
def project_params_attributes
  %i[
    description
    name
    path
  ]
end

EE::ProjectsController 모듈에서는 다음과 같이 합니다.

def project_params_attributes
  super + project_params_attributes_ee
end

def project_params_attributes_ee
  %i[
    approvals_before_merge
    issues_template
    merge_requests_template
    ...
  ]
end

app/models/의 코드#

EE 전용 모델은 ee/app/models/에 정의해야 합니다.

CE 모델을 오버라이드하려면 ee/app/models/ee/에 파일을 만들고 prepended 블록에 새 코드를 추가합니다.

ActiveRecord enums는 전부 FOSS에 정의해야 합니다.

app/views/의 코드#

EE가 CE 뷰에 EE 전용 뷰 코드를 추가하는 것은 매우 흔한 문제입니다. 예를 들어 프로젝트 설정 페이지의 승인 코드가 그렇습니다.

완화 방법

EE 전용 코드 블록은 partial로 옮겨야 합니다. 그러면 들여쓰기까지 얽힌 큰 HAML 코드 덩어리에서 발생하는, 해결하기 번거로운 충돌을 피할 수 있습니다.

EE 전용 뷰는 ee/app/views/에 두며, 적절하면 하위 디렉터리를 추가로 사용합니다.

render_if_exists 사용#

일반 render 대신 render_if_exists를 사용해야 합니다. 이 메서드는 해당 partial을 찾지 못하면 아무것도 렌더링하지 않습니다. 이렇게 하면 CE에 render_if_exists를 두어 CE와 EE 사이에 코드를 동일하게 유지할 수 있습니다.

장점은 다음과 같습니다.

  • CE 코드를 읽으면서 EE 뷰를 확장하는 위치를 매우 분명하게 알 수 있습니다.

단점은 다음과 같습니다.

  • partial 이름에 오타가 있으면 조용히 무시됩니다.
주의 사항#

render_if_exists의 뷰 경로 인자는 app/views/ 및 ee/app/views를 기준으로 한 상대 경로여야 합니다. CE 뷰 경로를 기준으로 한 상대 경로로 EE 템플릿 경로를 해석하는 것은 동작하지 않습니다.

- # app/views/projects/index.html.haml

= render_if_exists 'button' # Will not render `ee/app/views/projects/_button` and will quietly fail
= render_if_exists 'projects/button' # Will render `ee/app/views/projects/_button`

render_ce 사용#

render와 render_if_exists는 EE partial을 먼저 검색하고 그다음 CE partial을 검색합니다. 이름이 같은 모든 partial이 아니라 특정 partial 하나만 렌더링합니다. 이를 활용하면 같은 partial 경로(예: projects/settings/archive)가 CE에서는 CE partial(즉, app/views/projects/settings/_archive.html.haml)을, EE에서는 EE partial(즉, ee/app/views/projects/settings/_archive.html.haml)을 가리키게 할 수 있습니다. 이렇게 하면 CE와 EE에서 서로 다른 내용을 보여 줄 수 있습니다.

그러나 기존 CE partial에 무언가를 추가하기만 하려는 경우처럼 EE partial에서 CE partial을 재사용하고 싶을 때도 있습니다. 이름이 다른 partial을 하나 더 추가하는 방법으로 우회할 수도 있지만 그렇게 하기는 번거롭습니다.

이 경우 EE partial을 모두 무시하는 render_ce를 사용하면 됩니다. 한 가지 예는 ee/app/views/projects/settings/_archive.html.haml입니다.

- return if @project.self_deletion_scheduled?
= render_ce 'projects/settings/archive'

위 예시에서는 render 'projects/settings/archive'를 사용할 수 없습니다. 같은 EE partial을 찾아서 무한 재귀가 발생하기 때문입니다. 대신 render_ce를 사용하면 ee/의 partial을 모두 무시하고 같은 경로(즉, projects/settings/archive)에 대해 CE partial(즉, app/views/projects/settings/_archive.html.haml)을 렌더링합니다. 이렇게 하면 CE partial을 손쉽게 감쌀 수 있습니다.

lib/gitlab/background_migration/의 코드#

EE 전용 백그라운드 마이그레이션을 만들 때는 GitLab EE를 CE로 다운그레이드하는 사용자를 고려해야 합니다. 즉, 모든 EE 전용 마이그레이션은 CE 코드에도 구현 없이 존재해야 하며, EE 쪽에서 이를 확장해야 합니다.

GitLab CE는 다음과 같습니다.

# lib/gitlab/background_migration/prune_orphaned_geo_events.rb

module Gitlab
  module BackgroundMigration
    class PruneOrphanedGeoEvents
      def perform(table_name)
      end
    end
  end
end

Gitlab::BackgroundMigration::PruneOrphanedGeoEvents.prepend_mod_with('Gitlab::BackgroundMigration::PruneOrphanedGeoEvents')

GitLab EE는 다음과 같습니다.

# ee/lib/ee/gitlab/background_migration/prune_orphaned_geo_events.rb

module EE
  module Gitlab
    module BackgroundMigration
      module PruneOrphanedGeoEvents
        extend ::Gitlab::Utils::Override

        override :perform
        def perform(table_name = EVENT_TABLES.first)
          return if ::Gitlab::Database.read_only?

          deleted_rows = prune_orphaned_rows(table_name)
          table_name   = next_table(table_name) if deleted_rows.zero?

          ::Database::BatchedBackgroundMigrationWorker.perform_in(RESCHEDULE_DELAY, self.class.name.demodulize, table_name) if table_name
        end
      end
    end
  end
end

app/graphql/의 코드#

EE 전용 뮤테이션, 리졸버, 타입은 ee/app/graphql/{mutations,resolvers,types}에 추가해야 합니다.

CE 뮤테이션, 리졸버 또는 타입을 오버라이드하려면 ee/app/graphql/ee/{mutations,resolvers,types}에 파일을 만들고 prepended 블록에 새 코드를 추가합니다.

예를 들어 CE에 Mutations::Tanukis::Create라는 뮤테이션이 있고 새 인자를 추가하려는 경우, EE 오버라이드를 ee/app/graphql/ee/mutations/tanukis/create.rb에 둡니다.

module EE
  module Mutations
    module Tanukis
      module Create
        extend ActiveSupport::Concern

        prepended do
          argument :name,
                   GraphQL::Types::String,
                   required: false,
                   description: 'Tanuki name'
        end
      end
    end
  end
end

lib/의 코드#

CE를 오버라이드하는 EE 로직은 최상위 EE 모듈 네임스페이스에 둡니다. 클래스는 평소처럼 EE 모듈 아래의 네임스페이스에 둡니다.

예를 들어 CE에 lib/gitlab/ldap/의 LDAP 클래스가 있다면 EE 전용 오버라이드는 ee/lib/ee/gitlab/ldap에 둡니다.

CE에 대응하는 클래스가 없는 EE 전용 클래스는 ee/lib/gitlab/ldap에 둡니다.

lib/api/의 코드#

prepend_mod_with 한 줄로 EE 기능을 확장하기는 매우 까다로울 수 있으며, Grape 기능마다 확장에 서로 다른 전략이 필요할 수 있습니다. 서로 다른 전략을 쉽게 적용하기 위해 EE 모듈에서 extend ActiveSupport::Concern을 사용합니다.

EE 모듈 파일은 EE 백엔드 코드로 CE 기능 확장을 따라 둡니다.

EE API 라우트#

EE API 라우트는 prepended 블록에 둡니다.

module EE
  module API
    module MergeRequests
      extend ActiveSupport::Concern

      prepended do
        params do
          requires :id, types: [String, Integer], desc: 'The ID or URL-encoded path of the project'
        end
        resource :projects, requirements: ::API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
          # ...
        end
      end
    end
  end
end

네임스페이스 차이 때문에 일부 상수에는 전체 한정자를 사용해야 합니다.

EE 파라미터#

params를 정의하고 다른 params 정의에서 use를 사용하여 EE에 정의된 파라미터를 포함할 수 있습니다. 다만 EE가 이를 오버라이드하려면 먼저 CE에서 "인터페이스"를 정의해야 합니다. 다른 곳에서는 prepend_mod_with 덕분에 이렇게 할 필요가 없지만, Grape는 내부적으로 복잡하여 그렇게 하기 쉽지 않았으므로, 여기서는 인터페이스를 먼저 정의하는 일반적인 객체 지향 방식을 따릅니다.

예를 들어 EE에 선택 파라미터가 몇 개 더 있다고 가정합니다. 파라미터를 Grape::API::Instance 클래스에서 헬퍼 모듈로 옮기면 클래스에서 사용하기 전에 이를 주입할 수 있습니다.

module API
  class Projects < Grape::API::Instance
    helpers Helpers::ProjectsHelpers
  end
end

다음과 같은 CE API params가 있다고 하겠습니다.

module API
  module Helpers
    module ProjectsHelpers
      extend ActiveSupport::Concern
      extend Grape::API::Helpers

      params :optional_project_params_ce do
        # CE specific params go here...
      end

      params :optional_project_params_ee do
      end

      params :optional_project_params do
        use :optional_project_params_ce
        use :optional_project_params_ee
      end
    end
  end
end

API::Helpers::ProjectsHelpers.prepend_mod_with('API::Helpers::ProjectsHelpers')

EE 모듈에서 이를 오버라이드할 수 있습니다.

module EE
  module API
    module Helpers
      module ProjectsHelpers
        extend ActiveSupport::Concern

        prepended do
          params :optional_project_params_ee do
            # EE specific params go here...
          end
        end
      end
    end
  end
end

EE 헬퍼#

EE 모듈이 CE 헬퍼를 쉽게 오버라이드할 수 있도록 하려면 확장하려는 헬퍼를 먼저 정의해야 합니다. 쉽고 명확하도록 클래스 정의 직후에 정의합니다.

module API
  module Ci
    class JobArtifacts < Grape::API::Instance
      # EE::API::Ci::JobArtifacts would override the following helpers
      helpers do
        def authorize_download_artifacts!
          authorize_read_builds!
        end
      end
    end
  end
end

API::Ci::JobArtifacts.prepend_mod_with('API::Ci::JobArtifacts')

그런 다음 일반적인 객체 지향 방식으로 이를 오버라이드할 수 있습니다.

module EE
  module API
    module Ci
      module JobArtifacts
        extend ActiveSupport::Concern

        prepended do
          helpers do
            def authorize_download_artifacts!
              super
              check_cross_project_pipelines_feature!
            end
          end
        end
      end
    end
  end
end

EE 전용 동작#

일부 API에서 EE 전용 동작이 필요할 때가 있습니다. 보통은 EE 메서드로 CE 메서드를 오버라이드할 수 있지만, API 라우트는 메서드가 아니므로 오버라이드할 수 없습니다. 라우트를 독립된 메서드로 추출하거나, CE 라우트에 동작을 주입할 수 있는 "훅"을 도입해야 합니다. 다음과 같은 방식입니다.

module API
  class MergeRequests < Grape::API::Instance
    helpers do
      # EE::API::MergeRequests would override the following helpers
      def update_merge_request_ee(merge_request)
      end
    end

    put ':id/merge_requests/:merge_request_iid/merge' do
      merge_request = find_project_merge_request(params[:merge_request_iid])

      # ...

      update_merge_request_ee(merge_request)

      # ...
    end
  end
end

API::MergeRequests.prepend_mod_with('API::MergeRequests')

update_merge_request_ee는 CE에서는 아무 작업도 하지 않지만, EE에서 이를 오버라이드할 수 있습니다.

module EE
  module API
    module MergeRequests
      extend ActiveSupport::Concern

      prepended do
        helpers do
          def update_merge_request_ee(merge_request)
            # ...
          end
        end
      end
    end
  end
end

EE route_setting#

이는 EE 모듈에서 확장하기가 매우 어렵고, 특정 라우트의 메타데이터를 저장하는 용도입니다. 이를 고려하면 CE에서는 이 메타데이터를 사용하지 않고 해가 되지도 않으므로, EE route_setting은 CE에 그대로 둘 수 있습니다.

route_setting을 더 많이 사용하게 되거나 EE에서 이를 확장할 필요가 실제로 있는지 여부에 따라 이 방침을 다시 검토할 수 있습니다. 지금은 많이 사용하지 않습니다.

클래스 메서드로 EE 전용 데이터 설정#

특정 API 라우트에 다른 인자를 사용해야 하는데, Grape는 블록마다 컨텍스트가 달라서 EE 모듈로 쉽게 확장할 수 없는 경우가 있습니다. 이를 해결하려면 데이터를 별도의 모듈이나 클래스에 있는 클래스 메서드로 옮겨야 합니다. 그러면 CE 코드 중간에 prepend_mod_with를 두지 않고도 데이터가 사용되기 전에 해당 모듈이나 클래스를 확장할 수 있습니다.

예를 들어 한 곳에서는 API가 EE 전용 인자를 최소 인자로 간주하도록 at_least_one_of에 추가 인자를 전달해야 합니다. 다음과 같이 접근합니다.

# api/merge_requests/parameters.rb
module API
  class MergeRequests < Grape::API::Instance
    module Parameters
      def self.update_params_at_least_one_of
        %i[
          assignee_id
          description
        ]
      end
    end
  end
end

API::MergeRequests::Parameters.prepend_mod_with('API::MergeRequests::Parameters')

# api/merge_requests.rb
module API
  class MergeRequests < Grape::API::Instance
    params do
      at_least_one_of(*Parameters.update_params_at_least_one_of)
    end
  end
end

그러면 EE 클래스 메서드에서 그 인자를 손쉽게 확장할 수 있습니다.

module EE
  module API
    module MergeRequests
      module Parameters
        extend ActiveSupport::Concern

        class_methods do
          extend ::Gitlab::Utils::Override

          override :update_params_at_least_one_of
          def update_params_at_least_one_of
            super.push(*%i[
              squash
            ])
          end
        end
      end
    end
  end
end

라우트가 많아서 이 작업이 자주 필요하다면 번거로울 수 있지만, 현재로서는 가장 단순한 해결책일 수 있습니다.

이 방식은 모델이 클래스 메서드에 의존하는 유효성 검사를 정의할 때도 사용할 수 있습니다. 예:

# app/models/identity.rb
class Identity < ActiveRecord::Base
  def self.uniqueness_scope
    [:provider]
  end

  prepend_mod_with('Identity')

  validates :extern_uid,
    allow_blank: true,
    uniqueness: { scope: uniqueness_scope, case_sensitive: false }
end

# ee/app/models/ee/identity.rb
module EE
  module Identity
    extend ActiveSupport::Concern

    class_methods do
      extend ::Gitlab::Utils::Override

      def uniqueness_scope
        [*super, :saml_provider_id]
      end
    end
  end
end

이 방식을 택하는 대신 코드를 다음과 같이 리팩터링합니다.

# ee/app/models/ee/identity/uniqueness_scopes.rb
module EE
  module Identity
    module UniquenessScopes
      extend ActiveSupport::Concern

      class_methods do
        extend ::Gitlab::Utils::Override

        def uniqueness_scope
          [*super, :saml_provider_id]
        end
      end
    end
  end
end

# app/models/identity/uniqueness_scopes.rb
class Identity < ActiveRecord::Base
  module UniquenessScopes
    def self.uniqueness_scope
      [:provider]
    end
  end
end

Identity::UniquenessScopes.prepend_mod_with('Identity::UniquenessScopes')

# app/models/identity.rb
class Identity < ActiveRecord::Base
  validates :extern_uid,
    allow_blank: true,
    uniqueness: { scope: Identity::UniquenessScopes.scopes, case_sensitive: false }
end

spec/의 코드#

EE 전용 기능을 테스트할 때는 기존 CE 스펙에 예제를 추가하지 않습니다. 대신 EE 스펙을 ee/spec 폴더에 둡니다.

기본적으로 CE 스펙은 EE 코드가 로드된 상태로 실행됩니다. EE가 라이선스 없이 실행될 때도 그대로 동작해야 하기 때문입니다.

이 스펙은 EE 코드를 제거한 상태에서도 통과해야 합니다. CE 인스턴스를 시뮬레이션하여 EE 코드 없이 테스트를 실행할 수 있습니다.

spec/factories의 코드#

CE에 이미 정의된 팩토리를 확장하려면 FactoryBot.modify를 사용합니다.

FactoryBot.modify 블록 안에서는 새 팩토리(중첩된 팩토리 포함)를 정의할 수 없습니다. 아래 예시처럼 별도의 FactoryBot.define 블록에서는 정의할 수 있습니다.

# ee/spec/factories/notes.rb
FactoryBot.modify do
  factory :note do
    trait :on_epic do
      noteable { create(:epic) }
      project nil
    end
  end
end

FactoryBot.define do
  factory :note_on_epic, parent: :note, traits: [:on_epic]
end

프론트엔드의 EE 코드 분리#

EE 전용 JS 파일을 분리하려면 파일을 ee 폴더로 옮깁니다.

예를 들어 app/assets/javascripts/protected_branches/protected_branches_bundle.js가 있고 그에 대응하는 EE 파일 ee/app/assets/javascripts/protected_branches/protected_branches_bundle.js가 있을 수 있습니다. 이때 해당 import 문은 다음과 같습니다.

// app/assets/javascripts/protected_branches/protected_branches_bundle.js
import bundle from '~/protected_branches/protected_branches_bundle.js';

// ee/app/assets/javascripts/protected_branches/protected_branches_bundle.js
// (only works in EE)
import bundle from 'ee/protected_branches/protected_branches_bundle.js';

// in CE: app/assets/javascripts/protected_branches/protected_branches_bundle.js
// in EE: ee/app/assets/javascripts/protected_branches/protected_branches_bundle.js
import bundle from 'ee_else_ce/protected_branches/protected_branches_bundle.js';

프론트엔드에 새 EE 전용 기능 추가#

개발 중인 기능이 CE에 없다면 진입점을 ee/에 추가합니다. 예:

# Add HTML element to mount
ee/app/views/admin/geo/designs/index.html.haml

# Init the application
ee/app/assets/javascripts/pages/ee_only_feature/index.js

# Mount the feature
ee/app/assets/javascripts/ee_only_feature/index.js

licensed_feature_available?와 License.feature_available?를 사용한 기능 보호는 일반적으로 백엔드 가이드에 설명된 대로 컨트롤러에서 이루어집니다.

EE 전용 프론트엔드 기능 테스트#

CE에 사용하는 것과 같은 디렉터리 구조를 따라 EE 테스트를 ee/spec/frontend/에 추가합니다.

라이선스 기능을 활성화하는 방법은 EE 전용 백엔드 기능 테스트의 참고 사항을 확인합니다.

EE 프론트엔드 코드로 CE 기능 확장#

기존 뷰를 확장하는 프론트엔드 기능을 보호하려면 push_licensed_feature를 사용합니다.

# ee/app/controllers/ee/admin/my_controller.rb
before_action do
  push_licensed_feature(:my_feature_name) # for global features
end
# ee/app/controllers/ee/group/my_controller.rb
before_action do
  push_licensed_feature(:my_feature_name, @group) # for group pages
end
# ee/app/controllers/ee/project/my_controller.rb
before_action do
  push_licensed_feature(:my_feature_name, @group) # for group pages
  push_licensed_feature(:my_feature_name, @project) # for project pages
end

브라우저 콘솔의 gon.licensed_features에 기능이 나타나는지 확인합니다.

EE Vue 컴포넌트로 Vue 애플리케이션 확장#

UI의 기존 기능을 향상시키는 EE 라이선스 기능은 컴포넌트로서 Vue 애플리케이션에 새 요소나 상호 작용을 추가합니다.

CE 컴포넌트 안에서 EE 컴포넌트를 import 하여 EE 기능을 추가할 수 있습니다.

EE 컴포넌트를 import 하려면 ee_component 별칭을 사용합니다. EE에서 ee_component import 별칭은 ee/app/assets/javascripts 디렉터리를 가리킵니다. CE에서는 이 별칭이 아무것도 렌더링하지 않는 빈 컴포넌트로 해석됩니다.

다음은 EE 컴포넌트를 CE 컴포넌트로 import 하는 예시입니다.

<script>
// app/assets/javascripts/feature/components/form.vue

// In EE this will be resolved as `ee/app/assets/javascripts/feature/components/my_ee_component.vue`
// In CE as `app/assets/javascripts/vue_shared/components/empty_component.js`
import MyEeComponent from 'ee_component/feature/components/my_ee_component.vue';

export default {
  components: {
    MyEeComponent,
  },
};
</script>

<template>
  <div>
    <!-- ... -->
    <my-ee-component/>
    <!-- ... -->
  </div>

</template>
Note

CE 코드베이스 안에서 EE 컴포넌트의 렌더링이 어떤 검사(예: 기능 플래그 검사)에 의존한다면 EE 컴포넌트를 비동기로 import 할 수 있습니다.

glFeatures를 확인하여 Vue 컴포넌트가 보호되는지 확인합니다. 컴포넌트는 라이선스가 있을 때만 렌더링됩니다.

<script>
// ee/app/assets/javascripts/feature/components/special_component.vue

import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';

export default {
  mixins: [glFeatureFlagMixin()],
  computed: {
    shouldRenderComponent() {
      // Comes from gon.licensed_features as a camel-case version of `my_feature_name`
      return this.glFeatures.myFeatureName;
    }
  },
};
</script>

<template>
  <div v-if="shouldRenderComponent">
    <!-- EE licensed feature UI -->
  </div>

</template>
Note

반드시 필요한 경우가 아니라면 믹스인을 사용하지 않습니다. 대안이 되는 다른 패턴을 찾아봅니다.

권장 대안 방식(named/scoped 슬롯)#
  • 슬롯 또는 scoped 슬롯을 사용하면 믹스인으로 하던 것과 같은 일을 할 수 있습니다. EE 컴포넌트만 필요하다면 CE 컴포넌트를 만들 필요가 없습니다.
  1. 먼저, CE 기반 위에 EE 템플릿과 기능을 덧입혀야 하는 경우를 위해 슬롯을 렌더링할 수 있는 CE 컴포넌트를 둡니다.
// ./ce/my_component.vue

<script>
export default {
  props: {
    tooltipDefaultText: {
      type: String,
    },
  },
  computed: {
    tooltipText() {
      return this.tooltipDefaultText || "5 issues please";
    }
  },
}
</script>

<template>
  <span v-gl-tooltip :title="tooltipText" class="ce-text">Community Edition Only Text</span>
  <slot name="ee-specific-component">
</template>
  1. 다음으로 EE 컴포넌트를 렌더링하고, EE 컴포넌트 안에서 CE 컴포넌트를 렌더링하면서 슬롯에 추가 콘텐츠를 넣습니다.
// ./ee/my_component.vue

<script>
export default {
  computed: {
    tooltipText() {
      if (this.weight) {
        return "5 issues with weight 10";
      }
    }
  },
  methods: {
    submit() {
      // do something.
    }
  },
}
</script>

<template>
  <my-component :tooltipDefaultText="tooltipText">
    <template #ee-specific-component>
      <span class="some-ee-specific">EE Specific Value</span>
      <button @click="submit">Click Me</button>
    </template>
  </my-component>
</template>
  1. 마지막으로, 컴포넌트가 필요한 곳에서는 다음과 같이 require 합니다.

import MyComponent from 'ee_else_ce/path/my_component'.vue

  • 이렇게 하면 CE 또는 EE 구현에 맞는 올바른 컴포넌트가 포함됩니다.

같은 computed 값에 대해 다른 결과가 필요한 EE 컴포넌트는 예시와 같이 CE 래퍼에 props를 전달할 수 있습니다.

  • EE 추가 HTML
    • EE에서 HTML이 추가되는 템플릿은 새 컴포넌트로 옮기고 ee_else_ce import 별칭을 사용해야 합니다.

다른 JS 코드 확장#

JS 파일을 확장하려면 다음 단계를 완료합니다.

  1. ee_else_ce 헬퍼를 사용합니다. 이때 EE 전용 코드는 ee/ 폴더 안에 있어야 합니다.
    1. EE 부분만 담은 EE 파일을 만들고 이에 대응하는 CE 파일을 확장합니다.
    2. 함수 내부의 코드처럼 확장할 수 없는 코드는 새 파일로 옮기고 ee_else_ce 헬퍼를 사용합니다.
  import eeCode from 'ee_else_ce/ee_code';

  function test() {
    const test = 'a';

    eeCode();

    return test;
  }

경우에 따라 애플리케이션의 다른 로직을 확장해야 합니다. JS 모듈을 확장하려면 파일의 EE 버전을 만들고 사용자 지정 로직으로 확장합니다.

// app/assets/javascripts/feature/utils.js

export const myFunction = () => {
  // ...
};

// ... other CE functions ...
// ee/app/assets/javascripts/feature/utils.js
import {
  myFunction as ceMyFunction,
} from '~/feature/utils';

/* eslint-disable import/export */

// Export same utils as CE
export * from '~/feature/utils';

// Only override `myFunction`
export const myFunction = () => {
  const result = ceMyFunction();
  // add EE feature logic
  return result;
};

/* eslint-enable import/export */

EE/CE 별칭을 사용하는 모듈 테스트#

프론트엔드 테스트를 작성할 때, 테스트 대상 모듈이 ee_else_ce/...로 다른 모듈을 import 하고 해당 테스트에서도 그 모듈이 필요하다면, 해당 테스트는 그 모듈을 ee_else_ce/...로 import 해야 합니다. 이렇게 하면 예기치 않은 EE 또는 FOSS 실패를 피할 수 있고, EE가 라이선스가 없을 때 CE 처럼 동작하도록 하는 데 도움이 됩니다.

예:

<script>
// ~/foo/component_under_test.vue

import FriendComponent from 'ee_else_ce/components/friend.vue;'

export default {
  name: 'ComponentUnderTest',
  components: { FriendComponent }.
}
</script>

<template>
  <friend-component />
</template>
// spec/frontend/foo/component_under_test_spec.js

// ...
// because we referenced the component using ee_else_ce we have to do the same in the spec.
import Friend from 'ee_else_ce/components/friend.vue;'

describe('ComponentUnderTest', () => {
  const findFriend = () => wrapper.find(Friend);

  it('renders friend', () => {
    // This would fail in CE if we did `ee/component...`
    // and would fail in EE if we did `~/component...`
    expect(findFriend().exists()).toBe(true);
  });
});

EE 테스트와 CE 테스트 실행#

CE와 EE 환경 모두를 위한 테스트를 만들 때는 로컬과 파이프라인에서 실행할 때 두 테스트가 모두 통과하도록 몇 가지 단계를 거쳐야 합니다.

  • 기본적으로 테스트는 EE 환경에서 실행되며 EE 테스트와 CE 테스트를 모두 실행합니다.
  • FOSS 환경에서 CE 파일만 테스트하려면 다음 명령을 실행해야 합니다.
FOSS_ONLY=1 yarn jest path/to/spec/file.spec.js

CE 테스트에는 CE 기능만 추가하므로, EE 전용 목(mock) 데이터가 없으면 EE 환경에서 실패할 수 있습니다. CE 테스트가 두 환경에서 모두 동작하도록 하려면 다음을 수행합니다.

  • 목 데이터를 import 할 때 ee_else_ce_jest 별칭을 사용합니다. 예:
import { sidebarDataCountResponse } from 'ee_else_ce_jest/super_sidebar/mock_data';
  • CE와 EE의 mock_data 파일에 해당 데이터를 담은 객체(위 예시에서는 sidebarDataCountResponse)가 각각 있는지 확인합니다. CE 파일에는 CE 기능 데이터만 담고, EE 파일에는 CE와 EE 기능 데이터를 모두 담습니다.
  • CE 파일의 expect 블록에서 객체를 비교해야 한다면 toEqual 대신 toMatchObject를 사용하여 CE 데이터에 EE 데이터가 존재하리라고 기대하지 않도록 합니다. 예:
expect(findPinnedSection().props('asyncCount')).toMatchObject(asyncCountData);

assets/stylesheets의 SCSS 코드#

스타일을 추가하는 컴포넌트가 EE에 한정된다면 app/assets/stylesheets 안의 적절한 디렉터리에 별도의 SCSS 파일을 두는 것이 좋습니다.

경우에 따라서는 이렇게 하기 어렵거나 전용 SCSS 파일을 만드는 것이 과도할 수 있습니다. 예를 들어 어떤 컴포넌트의 텍스트 스타일이 EE에서만 다른 경우입니다. 이런 경우 스타일은 대개 CE와 EE가 공통으로 사용하는 스타일시트에 두며, CE에서 EE로 머지할 때 충돌을 피하기 위해 이러한 규칙 집합을 나머지 CE 규칙과 분리하는 것이 현명합니다 (같은 내용을 설명하는 주석도 함께 추가합니다).

// Bad
.section-body {
  .section-title {
    background: $gl-header-color;
  }

  &.ee-section-body {
    .section-title {
      background: $gl-header-color-cyan;
    }
  }
}
// Good
.section-body {
  .section-title {
    background: $gl-header-color;
  }
}

// EE-specific start
.section-body.ee-section-body {
  .section-title {
    background: $gl-header-color-cyan;
  }
}
// EE-specific end

GitLab-svgs#

app/assets/images/icons.json 또는 app/assets/images/icons.svg의 충돌은 yarn run svg로 해당 에셋을 다시 생성하여 해결할 수 있습니다.