From c2c16b1de4fabaea11738fb30cff3876230fe3cf Mon Sep 17 00:00:00 2001 From: Seth Boyles Date: Tue, 10 Feb 2026 15:36:01 -0700 Subject: [PATCH] Add build staged/failure audit events --- app/repositories/build_event_repository.rb | 67 ++++++ app/repositories/event_types.rb | 2 + .../resources/audit_events/_header.md.erb | 2 + .../diego/staging_completion_handler.rb | 3 + .../staging_completion_handler_spec.rb | 10 + .../build_event_repository_spec.rb | 195 +++++++++++++++++- spec/unit/repositories/event_types_spec.rb | 2 + 7 files changed, 279 insertions(+), 2 deletions(-) diff --git a/app/repositories/build_event_repository.rb b/app/repositories/build_event_repository.rb index 0eb7609f984..8267b8f4938 100644 --- a/app/repositories/build_event_repository.rb +++ b/app/repositories/build_event_repository.rb @@ -26,6 +26,73 @@ def self.record_build_create(build, user_audit_info, v3_app_name, space_guid, or organization_guid: org_guid ) end + + def self.record_build_staged(build, droplet) + app = build.app + VCAP::AppLogEmitter.emit(app.guid, "Staging complete for build #{build.guid}") + + metadata = { + build_guid: build.guid, + package_guid: build.package_guid, + droplet_guid: droplet.guid, + buildpacks: buildpack_info(droplet) + } + + Event.create( + type: EventTypes::APP_BUILD_STAGED, + actor: build.created_by_user_guid || UserAuditInfo::DATA_UNAVAILABLE, + actor_type: 'user', + actor_name: build.created_by_user_email, + actor_username: build.created_by_user_name, + actee: app.guid, + actee_type: 'app', + actee_name: app.name, + timestamp: Sequel::CURRENT_TIMESTAMP, + metadata: metadata, + space_guid: app.space_guid, + organization_guid: app.space.organization_guid + ) + end + + def self.record_build_failed(build, error_id, error_message) + app = build.app + VCAP::AppLogEmitter.emit(app.guid, "Staging failed for build #{build.guid}") + + metadata = { + build_guid: build.guid, + package_guid: build.package_guid, + error_id: error_id, + error_message: error_message + } + + Event.create( + type: EventTypes::APP_BUILD_FAILED, + actor: build.created_by_user_guid || UserAuditInfo::DATA_UNAVAILABLE, + actor_type: 'user', + actor_name: build.created_by_user_email, + actor_username: build.created_by_user_name, + actee: app.guid, + actee_type: 'app', + actee_name: app.name, + timestamp: Sequel::CURRENT_TIMESTAMP, + metadata: metadata, + space_guid: app.space_guid, + organization_guid: app.space.organization_guid + ) + end + + def self.buildpack_info(droplet) + return nil if droplet.docker? + + droplet.lifecycle_data.buildpack_lifecycle_buildpacks.map do |buildpack| + { + name: buildpack.admin_buildpack_name || CloudController::UrlSecretObfuscator.obfuscate(buildpack.buildpack_url), + buildpack_name: buildpack.buildpack_name, + version: buildpack.version + } + end + end + private_class_method :buildpack_info end end end diff --git a/app/repositories/event_types.rb b/app/repositories/event_types.rb index 5a078d4dc25..39c4e21febd 100644 --- a/app/repositories/event_types.rb +++ b/app/repositories/event_types.rb @@ -41,6 +41,8 @@ class EventTypesError < StandardError APP_UNMAP_ROUTE = 'audit.app.unmap-route'.freeze, APP_BUILD_CREATE = 'audit.app.build.create'.freeze, + APP_BUILD_STAGED = 'audit.app.build.staged'.freeze, + APP_BUILD_FAILED = 'audit.app.build.failed'.freeze, APP_ENVIRONMENT_SHOW = 'audit.app.environment.show'.freeze, APP_ENVIRONMENT_VARIABLE_SHOW = 'audit.app.environment_variables.show'.freeze, APP_REVISION_CREATE = 'audit.app.revision.create'.freeze, diff --git a/docs/v3/source/includes/resources/audit_events/_header.md.erb b/docs/v3/source/includes/resources/audit_events/_header.md.erb index e3545aa4925..4a03056ef78 100644 --- a/docs/v3/source/includes/resources/audit_events/_header.md.erb +++ b/docs/v3/source/includes/resources/audit_events/_header.md.erb @@ -9,6 +9,8 @@ For more information, see the [Cloud Foundry docs](https://docs.cloudfoundry.org ##### App lifecycle - `audit.app.apply_manifest` - `audit.app.build.create` +- `audit.app.build.failed` +- `audit.app.build.staged` - `audit.app.copy-bits` - `audit.app.create` - `audit.app.delete-request` diff --git a/lib/cloud_controller/diego/staging_completion_handler.rb b/lib/cloud_controller/diego/staging_completion_handler.rb index 41bc889e373..ceb43b9ff03 100644 --- a/lib/cloud_controller/diego/staging_completion_handler.rb +++ b/lib/cloud_controller/diego/staging_completion_handler.rb @@ -59,6 +59,7 @@ def handle_failure(payload, with_start) V2::AppStop.stop(build.app, StagingCancel.new(stagers)) if with_start end + Repositories::BuildEventRepository.record_build_failed(build, payload[:error][:id], payload[:error][:message]) rescue StandardError => e logger.error(logger_prefix + 'saving-staging-result-failed', staging_guid: build.guid, @@ -112,6 +113,8 @@ def handle_success(payload, with_start) return end + Repositories::BuildEventRepository.record_build_staged(build, droplet) + begin if with_start start_process diff --git a/spec/unit/lib/cloud_controller/diego/buildpack/staging_completion_handler_spec.rb b/spec/unit/lib/cloud_controller/diego/buildpack/staging_completion_handler_spec.rb index 232d44ef594..20984abd781 100644 --- a/spec/unit/lib/cloud_controller/diego/buildpack/staging_completion_handler_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/buildpack/staging_completion_handler_spec.rb @@ -149,6 +149,11 @@ module Buildpack subject.staging_complete(success_response) end + it 'records a build staged audit event' do + expect(Repositories::BuildEventRepository).to receive(:record_build_staged).with(build, droplet) + subject.staging_complete(success_response) + end + context 'when there are sidecars in the staging result' do before do success_response[:result][:sidecars] = [{ @@ -555,6 +560,11 @@ module Buildpack expect(VCAP::AppLogEmitter).to receive(:emit_error).with(build.app_guid, /Found no compatible cell/) subject.staging_complete(fail_response) end + + it 'records a build failed audit event' do + expect(Repositories::BuildEventRepository).to receive(:record_build_failed).with(build, 'NoCompatibleCell', 'Found no compatible cell') + subject.staging_complete(fail_response) + end end context 'when the build does not have a droplet' do diff --git a/spec/unit/repositories/build_event_repository_spec.rb b/spec/unit/repositories/build_event_repository_spec.rb index 3f71af804e0..b4949db9cc0 100644 --- a/spec/unit/repositories/build_event_repository_spec.rb +++ b/spec/unit/repositories/build_event_repository_spec.rb @@ -7,12 +7,20 @@ module Repositories let(:app) { AppModel.make(name: 'popsicle') } let(:user) { User.make } let(:package) { PackageModel.make(app_guid: app.guid) } - let(:build) { BuildModel.make(app_guid: app.guid, package: package) } let(:email) { 'user-email' } let(:user_name) { 'user-name' } + let(:build) do + BuildModel.make( + app_guid: app.guid, + package: package, + created_by_user_guid: user.guid, + created_by_user_name: user_name, + created_by_user_email: email + ) + end let(:user_audit_info) { UserAuditInfo.new(user_email: email, user_name: user_name, user_guid: user.guid) } - describe '#record_create_by_staging' do + describe '#record_build_create' do it 'creates a new audit.app.build.create event' do event = BuildEventRepository.record_build_create(build, user_audit_info, app.name, package.space.guid, package.space.organization.guid) event.reload @@ -32,6 +40,189 @@ module Repositories expect(metadata['package_guid']).to eq(package.guid) end end + + describe '#record_build_staged' do + let(:droplet) { DropletModel.make(app_guid: app.guid, package: package, build: build) } + + it 'creates a new audit.app.build.staged event' do + event = BuildEventRepository.record_build_staged(build, droplet) + event.reload + + expect(event.type).to eq('audit.app.build.staged') + expect(event.actor).to eq(user.guid) + expect(event.actor_type).to eq('user') + expect(event.actor_name).to eq(email) + expect(event.actor_username).to eq(user_name) + expect(event.actee).to eq(app.guid) + expect(event.actee_type).to eq('app') + expect(event.actee_name).to eq('popsicle') + expect(event.space_guid).to eq(app.space.guid) + expect(event.organization_guid).to eq(app.space.organization.guid) + + metadata = event.metadata + expect(metadata['build_guid']).to eq(build.guid) + expect(metadata['package_guid']).to eq(package.guid) + expect(metadata['droplet_guid']).to eq(droplet.guid) + expect(metadata['buildpacks']).to eq([]) + end + + context 'cnb lifecycle' do + let(:build) do + BuildModel.make(:cnb, + app_guid: app.guid, + package: package, + created_by_user_guid: user.guid, + created_by_user_name: user_name, + created_by_user_email: email) + end + let(:droplet) { DropletModel.make(:cnb, app_guid: app.guid, package: package, build: build) } + + it 'creates a new audit.app.build.staged event' do + event = BuildEventRepository.record_build_staged(build, droplet) + event.reload + + expect(event.type).to eq('audit.app.build.staged') + expect(event.metadata['buildpacks']).to eq([]) + end + end + + context 'docker lifecycle' do + let(:build) do + BuildModel.make(:docker, + app_guid: app.guid, + package: package, + created_by_user_guid: user.guid, + created_by_user_name: user_name, + created_by_user_email: email) + end + let(:droplet) { DropletModel.make(:docker, app_guid: app.guid, package: package, build: build) } + + it 'creates a new audit.app.build.staged event' do + event = BuildEventRepository.record_build_staged(build, droplet) + event.reload + + expect(event.type).to eq('audit.app.build.staged') + expect(event.metadata['buildpacks']).to be_nil + end + end + + context 'when the droplet has buildpack lifecycle data' do + let!(:admin_buildpack) { Buildpack.make(name: 'ruby_buildpack') } + let(:lifecycle_data) { BuildpackLifecycleDataModel.make(droplet:, build:) } + let!(:lifecycle_buildpack1) do + BuildpackLifecycleBuildpackModel.make( + buildpack_lifecycle_data: lifecycle_data, + admin_buildpack_name: 'ruby_buildpack', + buildpack_name: 'ruby', + version: '1.8.0' + ) + end + let!(:lifecycle_buildpack2) do + BuildpackLifecycleBuildpackModel.make( + buildpack_lifecycle_data: lifecycle_data, + admin_buildpack_name: nil, + buildpack_url: 'https://user:password@github.com/custom/buildpack', + buildpack_name: 'custom-bp', + version: '2.0.0' + ) + end + + before do + droplet.buildpack_lifecycle_data = lifecycle_data + droplet.save + end + + it 'includes buildpack information in metadata' do + event = BuildEventRepository.record_build_staged(build, droplet) + event.reload + + buildpacks = event.metadata['buildpacks'] + expect(buildpacks).to have(2).items + + expect(buildpacks[0]['name']).to eq('ruby_buildpack') + expect(buildpacks[0]['buildpack_name']).to eq('ruby') + expect(buildpacks[0]['version']).to eq('1.8.0') + + expect(buildpacks[1]['name']).to eq('https://***:***@github.com/custom/buildpack') + expect(buildpacks[1]['buildpack_name']).to eq('custom-bp') + expect(buildpacks[1]['version']).to eq('2.0.0') + end + end + + context 'when the droplet has no lifecycle data' do + it 'sets buildpacks to empty array in metadata' do + event = BuildEventRepository.record_build_staged(build, droplet) + event.reload + + expect(event.metadata['buildpacks']).to eq([]) + end + end + end + + describe '#record_build_failed' do + let(:error_id) { 'StagingError' } + let(:error_message) { 'Something went wrong during staging' } + + context 'buildpack lifecycle' do + it 'creates a new audit.app.build.failed event' do + event = BuildEventRepository.record_build_failed(build, error_id, error_message) + event.reload + + expect(event.type).to eq('audit.app.build.failed') + expect(event.actor).to eq(user.guid) + expect(event.actor_type).to eq('user') + expect(event.actor_name).to eq(email) + expect(event.actor_username).to eq(user_name) + expect(event.actee).to eq(app.guid) + expect(event.actee_type).to eq('app') + expect(event.actee_name).to eq('popsicle') + expect(event.space_guid).to eq(app.space.guid) + expect(event.organization_guid).to eq(app.space.organization.guid) + + metadata = event.metadata + expect(metadata['build_guid']).to eq(build.guid) + expect(metadata['package_guid']).to eq(package.guid) + expect(metadata['error_id']).to eq(error_id) + expect(metadata['error_message']).to eq(error_message) + end + end + + context 'cnb lifecycle' do + let(:build) do + BuildModel.make(:cnb, + app_guid: app.guid, + package: package, + created_by_user_guid: user.guid, + created_by_user_name: user_name, + created_by_user_email: email) + end + + it 'creates a new audit.app.build.failed event' do + event = BuildEventRepository.record_build_failed(build, error_id, error_message) + event.reload + + expect(event.type).to eq('audit.app.build.failed') + end + end + + context 'docker lifecycle' do + let(:build) do + BuildModel.make(:docker, + app_guid: app.guid, + package: package, + created_by_user_guid: user.guid, + created_by_user_name: user_name, + created_by_user_email: email) + end + + it 'creates a new audit.app.build.failed event' do + event = BuildEventRepository.record_build_failed(build, error_id, error_message) + event.reload + + expect(event.type).to eq('audit.app.build.failed') + end + end + end end end end diff --git a/spec/unit/repositories/event_types_spec.rb b/spec/unit/repositories/event_types_spec.rb index 797f71e43cf..1f4bfe3c62b 100644 --- a/spec/unit/repositories/event_types_spec.rb +++ b/spec/unit/repositories/event_types_spec.rb @@ -52,6 +52,8 @@ module Repositories 'audit.app.map-route', 'audit.app.unmap-route', 'audit.app.build.create', + 'audit.app.build.staged', + 'audit.app.build.failed', 'audit.app.environment.show', 'audit.app.environment_variables.show', 'audit.app.revision.create',