Commit 7a38be8b authored by Alex Kalderimis's avatar Alex Kalderimis

Merge branch 'bwill/starboard-vulnerability-api' into 'master'

Add starboard_vulnerability endpoint

See merge request gitlab-org/gitlab!69022
parents c1c20bb7 6e791b72
...@@ -501,6 +501,56 @@ curl --request POST --header "Gitlab-Kas-Api-Request: <JWT token>" \ ...@@ -501,6 +501,56 @@ curl --request POST --header "Gitlab-Kas-Api-Request: <JWT token>" \
"http://localhost:3000/api/v4/internal/kubernetes/modules/cilium_alert" "http://localhost:3000/api/v4/internal/kubernetes/modules/cilium_alert"
``` ```
### Create Starboard vulnerability
Called from the GitLab Kubernetes Agent Server (`kas`) to create a security vulnerability
from a Starboard vulnerability report. This request is idempotent. Multiple requests with the same data
create a single vulnerability.
| Attribute | Type | Required | Description |
|:----------------|:-------|:---------|:------------|
| `vulnerability` | Hash | yes | Vulnerability data matching the security report schema [`vulnerability` field](https://gitlab.com/gitlab-org/security-products/security-report-schemas/-/blob/master/src/security-report-format.json). |
| `scanner` | Hash | yes | Scanner data matching the security report schmea [`scanner` field](https://gitlab.com/gitlab-org/security-products/security-report-schemas/-/blob/master/src/security-report-format.json). |
```plaintext
PUT internal/kubernetes/modules/starboard_vulnerability
```
Example Request:
```shell
curl --request PUT --header "Gitlab-Kas-Api-Request: <JWT token>" \
--header "Authorization: Bearer <agent token>" --header "Content-Type: application/json" \
--url "http://localhost:3000/api/v4/internal/kubernetes/modules/starboard_vulnerability" \
--data '{
"vulnerability": {
"name": "CVE-123-4567 in libc",
"severity": "high",
"confidence": "unknown",
"location": {
"kubernetes_resource": {
"namespace": "production",
"kind": "deployment",
"name": "nginx",
"container": "nginx"
}
},
"identifiers": [
{
"type": "cve",
"name": "CVE-123-4567",
"value": "CVE-123-4567"
}
]
},
"scanner": {
"id": "starboard_trivy",
"name": "Trivy (via Starboard Operator)",
"vendor": "GitLab"
}
}'
```
## Subscriptions ## Subscriptions
The subscriptions endpoint is used by [CustomersDot](https://gitlab.com/gitlab-org/customers-gitlab-com) (`customers.gitlab.com`) The subscriptions endpoint is used by [CustomersDot](https://gitlab.com/gitlab-org/customers-gitlab-com) (`customers.gitlab.com`)
......
# frozen_string_literal: true
module Vulnerabilities
class CreateServiceBase
include Gitlab::Allowable
GENERIC_REPORT_TYPE = ::Enums::Vulnerability.report_types[:generic]
def initialize(project, author, params:)
@project = project
@author = author
@params = params
end
private
def authorized?
can?(@author, :create_vulnerability, @project)
end
def location_fingerprint(_location_hash)
raise NotImplmentedError, "location_fingerprint should be implemented by subclass"
end
def metadata_version
raise NotImplmentedError, "metadata_version should be implemented by subclass"
end
def report_type
GENERIC_REPORT_TYPE
end
def initialize_vulnerability(vulnerability_hash)
attributes = vulnerability_hash
.slice(*%i[
state
severity
confidence
detected_at
confirmed_at
resolved_at
dismissed_at
])
.merge(
project: @project,
author: @author,
title: vulnerability_hash[:title]&.truncate(::Issuable::TITLE_LENGTH_MAX),
report_type: report_type
)
vulnerability = Vulnerability.new(**attributes)
vulnerability.confirmed_by = @author if vulnerability.confirmed?
vulnerability.resolved_by = @author if vulnerability.resolved?
vulnerability.dismissed_by = @author if vulnerability.dismissed?
vulnerability
end
# rubocop: disable CodeReuse/ActiveRecord
def initialize_identifiers(identifier_hashes)
identifier_hashes.map do |identifier|
name = identifier[:name]
external_type = map_external_type_from_name(name)
external_id = name
fingerprint = Digest::SHA1.hexdigest("#{external_type}:#{external_id}")
url = identifier[:url]
Vulnerabilities::Identifier.find_or_initialize_by(name: name) do |i|
i.fingerprint = fingerprint
i.project = @project
i.external_type = external_type
i.external_id = external_id
i.url = url
end
end
end
# rubocop: enable CodeReuse/ActiveRecord
def map_external_type_from_name(name)
return 'cve' if name.match?(/CVE/i)
return 'cwe' if name.match?(/CWE/i)
'other'
end
# rubocop: disable CodeReuse/ActiveRecord
def initialize_scanner(scanner_hash)
name = scanner_hash[:name]
Vulnerabilities::Scanner.find_or_initialize_by(name: name) do |s|
s.project = @project
s.external_id = scanner_hash[:id]
end
end
# rubocop: enable CodeReuse/ActiveRecord
def initialize_finding(vulnerability:, identifiers:, scanner:, message:, description:, solution:)
location = @params[:vulnerability][:location]
loc_fingerprint = location_fingerprint(location)
uuid = ::Security::VulnerabilityUUID.generate(
report_type: report_type,
primary_identifier_fingerprint: identifiers.first.fingerprint,
location_fingerprint: loc_fingerprint,
project_id: @project.id
)
Vulnerabilities::Finding.new(
project: @project,
identifiers: identifiers,
primary_identifier: identifiers.first,
vulnerability: vulnerability,
name: vulnerability.title,
severity: vulnerability.severity,
confidence: vulnerability.confidence,
report_type: vulnerability.report_type,
project_fingerprint: Digest::SHA1.hexdigest(identifiers.first.name),
location_fingerprint: loc_fingerprint,
metadata_version: metadata_version,
raw_metadata: {
location: location
},
scanner: scanner,
uuid: uuid,
message: message,
description: description,
solution: solution
)
end
end
end
# frozen_string_literal: true # frozen_string_literal: true
module Vulnerabilities module Vulnerabilities
class ManuallyCreateService class ManuallyCreateService < CreateServiceBase
include Gitlab::Allowable include Gitlab::Allowable
METADATA_VERSION = "manual:1.0" METADATA_VERSION = "manual:1.0"
GENERIC_REPORT_TYPE = ::Enums::Vulnerability.report_types[:generic]
MANUAL_LOCATION_FINGERPRINT = Digest::SHA1.hexdigest("manually added").freeze MANUAL_LOCATION_FINGERPRINT = Digest::SHA1.hexdigest("manually added").freeze
CONFIRMED_MESSAGE = "confirmed_at can only be set when state is confirmed" CONFIRMED_MESSAGE = "confirmed_at can only be set when state is confirmed"
...@@ -22,7 +21,7 @@ module Vulnerabilities ...@@ -22,7 +21,7 @@ module Vulnerabilities
return ServiceResponse.error(message: "create_vulnerabilities_via_api feature flag is not enabled for this project") return ServiceResponse.error(message: "create_vulnerabilities_via_api feature flag is not enabled for this project")
end end
raise Gitlab::Access::AccessDeniedError unless can?(@author, :create_vulnerability, @project) raise Gitlab::Access::AccessDeniedError unless authorized?
timestamps_dont_match_state_message = match_state_fields_with_state timestamps_dont_match_state_message = match_state_fields_with_state
return ServiceResponse.error(message: timestamps_dont_match_state_message) if timestamps_dont_match_state_message return ServiceResponse.error(message: timestamps_dont_match_state_message) if timestamps_dont_match_state_message
...@@ -30,7 +29,14 @@ module Vulnerabilities ...@@ -30,7 +29,14 @@ module Vulnerabilities
vulnerability = initialize_vulnerability(@params[:vulnerability]) vulnerability = initialize_vulnerability(@params[:vulnerability])
identifiers = initialize_identifiers(@params[:vulnerability][:identifiers]) identifiers = initialize_identifiers(@params[:vulnerability][:identifiers])
scanner = initialize_scanner(@params[:vulnerability][:scanner]) scanner = initialize_scanner(@params[:vulnerability][:scanner])
finding = initialize_finding(vulnerability, identifiers, scanner, @params[:message], @params[:solution]) finding = initialize_finding(
vulnerability: vulnerability,
identifiers: identifiers,
scanner: scanner,
message: @params[:message],
description: @params[:description],
solution: @params[:solution]
)
Vulnerability.transaction do Vulnerability.transaction do
vulnerability.save! vulnerability.save!
...@@ -50,6 +56,25 @@ module Vulnerabilities ...@@ -50,6 +56,25 @@ module Vulnerabilities
private private
def location_fingerprint(_location_hash)
MANUAL_LOCATION_FINGERPRINT
end
def metadata_version
METADATA_VERSION
end
# rubocop: disable CodeReuse/ActiveRecord
def initialize_scanner(scanner_hash)
name = scanner_hash[:name]
Vulnerabilities::Scanner.find_or_initialize_by(name: name) do |s|
s.project = @project
s.external_id = Gitlab::Utils.slugify(name)
end
end
# rubocop: enable CodeReuse/ActiveRecord
def match_state_fields_with_state def match_state_fields_with_state
state = @params.dig(:vulnerability, :state) state = @params.dig(:vulnerability, :state)
...@@ -70,98 +95,5 @@ module Vulnerabilities ...@@ -70,98 +95,5 @@ module Vulnerabilities
def exists_in_vulnerability_params?(column_name) def exists_in_vulnerability_params?(column_name)
@params.dig(:vulnerability, column_name.to_sym).present? @params.dig(:vulnerability, column_name.to_sym).present?
end end
def initialize_vulnerability(vulnerability_hash)
attributes = vulnerability_hash
.slice(*%i[
state
severity
confidence
detected_at
confirmed_at
resolved_at
dismissed_at
])
.merge(
project: @project,
author: @author,
title: vulnerability_hash[:title]&.truncate(::Issuable::TITLE_LENGTH_MAX),
report_type: GENERIC_REPORT_TYPE
)
vulnerability = Vulnerability.new(**attributes)
vulnerability.confirmed_by = @author if vulnerability.confirmed?
vulnerability.resolved_by = @author if vulnerability.resolved?
vulnerability.dismissed_by = @author if vulnerability.dismissed?
vulnerability
end
# rubocop: disable CodeReuse/ActiveRecord
def initialize_identifiers(identifier_hashes)
identifier_hashes.map do |identifier|
name = identifier.dig(:name)
external_type = map_external_type_from_name(name)
external_id = name
fingerprint = Digest::SHA1.hexdigest("#{external_type}:#{external_id}")
url = identifier.dig(:url)
Vulnerabilities::Identifier.find_or_initialize_by(name: name) do |i|
i.fingerprint = fingerprint
i.project = @project
i.external_type = external_type
i.external_id = external_id
i.url = url
end
end
end
# rubocop: enable CodeReuse/ActiveRecord
def map_external_type_from_name(name)
return 'cve' if name.match?(/CVE/i)
return 'cwe' if name.match?(/CWE/i)
'other'
end
# rubocop: disable CodeReuse/ActiveRecord
def initialize_scanner(scanner_hash)
name = scanner_hash.dig(:name)
Vulnerabilities::Scanner.find_or_initialize_by(name: name) do |s|
s.project = @project
s.external_id = Gitlab::Utils.slugify(name)
end
end
# rubocop: enable CodeReuse/ActiveRecord
def initialize_finding(vulnerability, identifiers, scanner, message, solution)
uuid = ::Security::VulnerabilityUUID.generate(
report_type: GENERIC_REPORT_TYPE,
primary_identifier_fingerprint: identifiers.first.fingerprint,
location_fingerprint: MANUAL_LOCATION_FINGERPRINT,
project_id: @project.id
)
Vulnerabilities::Finding.new(
project: @project,
identifiers: identifiers,
primary_identifier: identifiers.first,
vulnerability: vulnerability,
name: vulnerability.title,
severity: vulnerability.severity,
confidence: vulnerability.confidence,
report_type: vulnerability.report_type,
project_fingerprint: Digest::SHA1.hexdigest(identifiers.first.name),
location_fingerprint: MANUAL_LOCATION_FINGERPRINT,
metadata_version: METADATA_VERSION,
raw_metadata: {},
scanner: scanner,
uuid: uuid,
message: message,
solution: solution
)
end
end end
end end
# frozen_string_literal: true
module Vulnerabilities
class StarboardVulnerabilityCreateService < CreateServiceBase
include Gitlab::Allowable
CLUSTER_IMAGE_SCANNING_REPORT_TYPE = ::Enums::Vulnerability.report_types[:cluster_image_scanning]
METADATA_VERSION = "cluster_image_scanning:1.0"
def initialize(agent, params:)
@agent = agent
@project = agent.project
@author = agent.created_by_user
@params = params
end
def execute
raise Gitlab::Access::AccessDeniedError unless authorized?
vulnerability_hash = @params[:vulnerability]
vulnerability_hash[:state] = :detected
vulnerability = initialize_vulnerability(vulnerability_hash)
vulnerability.title = vulnerability_hash[:name]&.truncate(::Issuable::TITLE_LENGTH_MAX)
identifiers = initialize_identifiers(@params.dig(:vulnerability, :identifiers))
scanner = initialize_scanner(@params[:scanner])
finding = initialize_finding(
vulnerability: vulnerability,
identifiers: identifiers,
scanner: scanner,
message: vulnerability_hash[:message],
description: vulnerability_hash[:description],
solution: vulnerability_hash[:solution]
)
Vulnerability.transaction do
vulnerability.save!
finding.save!
Statistics::UpdateService.update_for(vulnerability)
HistoricalStatistics::UpdateService.update_for(@project)
ServiceResponse.success(payload: { vulnerability: vulnerability })
end
rescue ActiveRecord::RecordNotUnique
# Requests to this service should be idempotent, so we will return success and do nothing.
ServiceResponse.success
rescue ActiveRecord::RecordInvalid => e
ServiceResponse.error(message: e.message)
end
private
def report_type
CLUSTER_IMAGE_SCANNING_REPORT_TYPE
end
def metadata_version
METADATA_VERSION
end
def location_fingerprint(location_hash)
kubernetes_resource = location_hash[:kubernetes_resource]
fingerprint_data = [
@agent.id,
kubernetes_resource[:namespace],
kubernetes_resource[:kind],
kubernetes_resource[:name],
kubernetes_resource[:container],
location_hash.dig(:dependency, :package, :name)
].join(':')
Digest::SHA1.hexdigest(fingerprint_data)
end
end
end
...@@ -30,6 +30,53 @@ module EE ...@@ -30,6 +30,53 @@ module EE
status result.http_status status result.http_status
end end
end end
namespace 'modules/starboard_vulnerability' do
desc 'PUT starboard vulnerability' do
detail 'Idempotently creates a security vulnerability from starboard'
end
params do
requires :vulnerability, type: Hash, desc: 'Vulnerability details matching the `vulnerability` object on the security report schema' do
requires :name, type: String
requires :severity, type: String
requires :confidence, type: String
requires :location, type: Hash
requires :identifiers, type: Array do
requires :type, type: String
requires :name, type: String
optional :value, type: String
optional :url, type: String
end
optional :message, type: String
optional :description, type: String
optional :solution, type: String
optional :links, type: Array
end
requires :scanner, type: Hash, desc: 'Scanner details matching the `.scan.scanner` field on the security report schema' do
requires :id, type: String
requires :name, type: String
optional :vendor, type: String
end
end
route_setting :authentication, cluster_agent_token_allowed: true
put '/' do
not_found! if agent.project.nil?
result = ::Vulnerabilities::StarboardVulnerabilityCreateService.new(
agent,
params: params
).execute
if result.success?
status result.http_status
else
render_api_error!(result.message, result.http_status)
end
end
end
end end
end end
end end
......
...@@ -10,6 +10,19 @@ RSpec.describe API::Internal::Kubernetes do ...@@ -10,6 +10,19 @@ RSpec.describe API::Internal::Kubernetes do
end end
let(:jwt_secret) { SecureRandom.random_bytes(Gitlab::Kas::SECRET_LENGTH) } let(:jwt_secret) { SecureRandom.random_bytes(Gitlab::Kas::SECRET_LENGTH) }
let(:agent_token) { create(:cluster_agent_token) }
let(:agent_token_headers) { { 'Authorization' => "Bearer #{agent_token.token}" } }
let(:agent) { agent_token.agent }
let(:project) { agent.project }
def send_request(params: {}, headers: agent_token_headers)
case method
when :post
post api(api_url), params: params, headers: headers.reverse_merge(jwt_auth_headers)
when :put
put api(api_url), params: params, headers: headers.reverse_merge(jwt_auth_headers)
end
end
before do before do
allow(Gitlab::Kas).to receive(:secret).and_return(jwt_secret) allow(Gitlab::Kas).to receive(:secret).and_return(jwt_secret)
...@@ -39,7 +52,7 @@ RSpec.describe API::Internal::Kubernetes do ...@@ -39,7 +52,7 @@ RSpec.describe API::Internal::Kubernetes do
shared_examples 'agent authentication' do shared_examples 'agent authentication' do
it 'returns 401 if Authorization header not sent' do it 'returns 401 if Authorization header not sent' do
send_request send_request(headers: {})
expect(response).to have_gitlab_http_status(:unauthorized) expect(response).to have_gitlab_http_status(:unauthorized)
end end
...@@ -52,17 +65,13 @@ RSpec.describe API::Internal::Kubernetes do ...@@ -52,17 +65,13 @@ RSpec.describe API::Internal::Kubernetes do
end end
describe 'POST /internal/kubernetes/modules/cilium_alert' do describe 'POST /internal/kubernetes/modules/cilium_alert' do
def send_request(headers: {}, params: {}) let(:method) { :post }
post api('/internal/kubernetes/modules/cilium_alert'), params: params, headers: headers.reverse_merge(jwt_auth_headers) let(:api_url) { '/internal/kubernetes/modules/cilium_alert' }
end
include_examples 'authorization' include_examples 'authorization'
include_examples 'agent authentication' include_examples 'agent authentication'
context 'is authenticated for an agent' do context 'is authenticated for an agent' do
let!(:agent_token) { create(:cluster_agent_token) }
let!(:agent) { agent_token.agent }
before do before do
stub_licensed_features(cilium_alerts: true) stub_licensed_features(cilium_alerts: true)
end end
...@@ -70,7 +79,7 @@ RSpec.describe API::Internal::Kubernetes do ...@@ -70,7 +79,7 @@ RSpec.describe API::Internal::Kubernetes do
let(:payload) { build(:network_alert_payload) } let(:payload) { build(:network_alert_payload) }
it 'returns no_content for valid alert payload' do it 'returns no_content for valid alert payload' do
send_request(params: { alert: payload }, headers: { 'Authorization' => "Bearer #{agent_token.token}" }) send_request(params: { alert: payload })
expect(AlertManagement::Alert.count).to eq(1) expect(AlertManagement::Alert.count).to eq(1)
expect(AlertManagement::Alert.all.first.project).to eq(agent.project) expect(AlertManagement::Alert.all.first.project).to eq(agent.project)
...@@ -81,7 +90,7 @@ RSpec.describe API::Internal::Kubernetes do ...@@ -81,7 +90,7 @@ RSpec.describe API::Internal::Kubernetes do
let(:payload) { { temp: {} } } let(:payload) { { temp: {} } }
it 'returns bad request' do it 'returns bad request' do
send_request(params: { alert: payload }, headers: { 'Authorization' => "Bearer #{agent_token.token}" }) send_request(params: { alert: payload })
expect(response).to have_gitlab_http_status(:bad_request) expect(response).to have_gitlab_http_status(:bad_request)
end end
end end
...@@ -92,7 +101,82 @@ RSpec.describe API::Internal::Kubernetes do ...@@ -92,7 +101,82 @@ RSpec.describe API::Internal::Kubernetes do
end end
it 'returns forbidden for non licensed project' do it 'returns forbidden for non licensed project' do
send_request(params: { alert: payload }, headers: { 'Authorization' => "Bearer #{agent_token.token}" }) send_request(params: { alert: payload })
expect(response).to have_gitlab_http_status(:forbidden)
end
end
end
end
describe 'PUT /internal/kubernetes/modules/starboard_vulnerability' do
let(:method) { :put }
let(:api_url) { '/internal/kubernetes/modules/starboard_vulnerability' }
include_examples 'authorization'
include_examples 'agent authentication'
context 'is authenticated for an agent' do
before do
stub_licensed_features(security_dashboard: true)
project.add_maintainer(agent.created_by_user)
end
let(:payload) do
{
vulnerability: {
name: 'CVE-123-4567 in libc',
severity: 'high',
confidence: 'unknown',
location: {
kubernetes_resource: {
namespace: 'production',
kind: 'deployment',
name: 'nginx',
container: 'nginx'
}
},
identifiers: [
{
type: 'cve',
name: 'CVE-123-4567',
value: 'CVE-123-4567'
}
]
},
scanner: {
id: 'starboard_trivy',
name: 'Trivy (via Starboard Operator)',
vendor: 'GitLab'
}
}
end
it 'returns ok when a vulnerability is created' do
send_request(params: payload)
expect(response).to have_gitlab_http_status(:ok)
expect(Vulnerability.count).to eq(1)
expect(Vulnerability.all.first.finding.name).to eq(payload[:vulnerability][:name])
end
context 'when payload is invalid' do
let(:payload) { { vulnerability: 'invalid' } }
it 'returns bad request' do
send_request(params: payload)
expect(response).to have_gitlab_http_status(:bad_request)
end
end
context 'when feature is not available' do
before do
stub_licensed_features(security_dashboard: false)
end
it 'returns forbidden for non licensed project' do
send_request(params: payload)
expect(response).to have_gitlab_http_status(:forbidden) expect(response).to have_gitlab_http_status(:forbidden)
end end
......
...@@ -136,6 +136,8 @@ RSpec.describe Vulnerabilities::ManuallyCreateService do ...@@ -136,6 +136,8 @@ RSpec.describe Vulnerabilities::ManuallyCreateService do
finding = vulnerability.finding finding = vulnerability.finding
expect(finding.report_type).to eq("generic") expect(finding.report_type).to eq("generic")
expect(finding.message).to eq(params.dig(:message))
expect(finding.description).to eq(params.dig(:description))
expect(finding.severity).to eq(params.dig(:vulnerability, :severity)) expect(finding.severity).to eq(params.dig(:vulnerability, :severity))
expect(finding.confidence).to eq(params.dig(:vulnerability, :confidence)) expect(finding.confidence).to eq(params.dig(:vulnerability, :confidence))
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Vulnerabilities::StarboardVulnerabilityCreateService do
let(:agent) { create(:cluster_agent) }
let(:project) { agent.project }
let(:user) { agent.created_by_user }
let(:params) do
{
vulnerability: {
name: 'CVE-123-4567 in libc',
message: 'Vulnerability message',
description: 'Vulnerability description',
severity: 'high',
confidence: 'unknown',
location: {
kubernetes_resource: {
namespace: 'production',
kind: 'deployment',
name: 'nginx',
container: 'nginx'
}
},
identifiers: [
{
type: 'cve',
name: 'CVE-123-4567',
value: 'CVE-123-4567'
}
]
},
scanner: {
id: 'starboard_trivy',
name: 'Trivy (via Starboard Operator)',
vendor: 'GitLab'
}
}
end
subject { described_class.new(agent, params: params).execute }
context 'with authorized user' do
before do
project.add_developer(user)
end
context 'with feature enabled' do
let(:vulnerability) { subject.payload[:vulnerability] }
before do
stub_licensed_features(security_dashboard: true)
end
it 'creates Vulnerability' do
expect { subject }.to change(Vulnerability, :count).by(1)
end
it 'has correct data' do
expect(vulnerability.report_type).to eq("cluster_image_scanning")
expect(vulnerability.title).to eq(params.dig(:vulnerability, :name))
finding = vulnerability.finding
expect(finding.message).to eq(params.dig(:vulnerability, :message))
expect(finding.description).to eq(params.dig(:vulnerability, :description))
expect(finding.severity).to eq(params.dig(:vulnerability, :severity))
expect(finding.confidence).to eq(params.dig(:vulnerability, :confidence))
scanner = finding.scanner
expect(scanner.external_id).to eq(params.dig(:scanner, :id))
expect(scanner.name).to eq(params.dig(:scanner, :name))
end
end
context 'with feature disabled' do
before do
stub_licensed_features(security_dashboard: false)
end
it 'raises AccessDeniedError' do
expect { subject }.to raise_error(Gitlab::Access::AccessDeniedError)
end
end
context 'with unauthorized user' do
before do
project.add_reporter(user)
end
it 'raises AccessDeniedError' do
expect { subject }.to raise_error(Gitlab::Access::AccessDeniedError)
end
end
end
end
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
FactoryBot.define do FactoryBot.define do
factory :cluster_agent, class: 'Clusters::Agent' do factory :cluster_agent, class: 'Clusters::Agent' do
project project
association :created_by_user, factory: :user
sequence(:name) { |n| "agent-#{n}" } sequence(:name) { |n| "agent-#{n}" }
end end
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment