From 6d1b08f290ad173afdf64df9fbe579e92b134cc0 Mon Sep 17 00:00:00 2001 From: David Riddle Date: Mon, 9 Feb 2026 11:13:50 -0500 Subject: [PATCH] feat: Add usage snapshots API for point-in-time app and service metrics Introduces new V3 API endpoints for capturing point-in-time usage data: App Usage Snapshots (/v3/app_usage/snapshots): - Creates snapshots of all running processes across the platform - Captures instance counts, memory allocation, and buildpack info - Data organized by organization and space in paginated chunks Service Usage Snapshots (/v3/service_usage/snapshots): - Creates snapshots of all service instances across the platform - Captures service plan, offering, and broker information - Supports both managed and user-provided service instances Both snapshot types: - Are admin-only operations that run asynchronously via pollable jobs - Include a checkpoint reference (GUID) to the most recent usage event - Support automatic cleanup of old and stale snapshots via daily jobs - Expose Prometheus metrics for generation duration and failure tracking --- .../v3/app_usage_snapshots_controller.rb | 90 ++++ .../v3/service_usage_snapshots_controller.rb | 89 ++++ .../app_usage_snapshot_list_fetcher.rb | 17 + .../service_usage_snapshot_list_fetcher.rb | 17 + .../runtime/app_usage_snapshot_cleanup.rb | 45 ++ .../app_usage_snapshot_generator_job.rb | 47 ++ .../runtime/service_usage_snapshot_cleanup.rb | 45 ++ .../service_usage_snapshot_generator_job.rb | 47 ++ .../app_usage_snapshots_create_message.rb | 13 + .../app_usage_snapshots_list_message.rb | 17 + .../service_usage_snapshots_create_message.rb | 13 + .../service_usage_snapshots_list_message.rb | 17 + app/models.rb | 5 + app/models/runtime/app_usage_snapshot.rb | 23 + .../runtime/app_usage_snapshot_chunk.rb | 17 + app/models/runtime/pollable_job_model.rb | 4 + app/models/runtime/service_usage_snapshot.rb | 25 ++ .../runtime/service_usage_snapshot_chunk.rb | 17 + .../v3/app_usage_snapshot_chunk_presenter.rb | 26 ++ .../v3/app_usage_snapshot_presenter.rb | 45 ++ .../service_usage_snapshot_chunk_presenter.rb | 26 ++ .../v3/service_usage_snapshot_presenter.rb | 44 ++ .../app_usage_snapshot_repository.rb | 181 ++++++++ .../service_usage_snapshot_repository.rb | 178 ++++++++ config/cloud_controller.yml | 6 + config/routes.rb | 12 + ...260114200000_create_app_usage_snapshots.rb | 60 +++ ...14200100_create_service_usage_snapshots.rb | 54 +++ errors/v2.yml | 10 + lib/cloud_controller/clock/scheduler.rb | 4 +- .../config_schemas/clock_schema.rb | 2 + lib/cloud_controller/jobs.rb | 4 + .../metrics/prometheus_updater.rb | 7 +- spec/request/app_usage_snapshots_spec.rb | 407 ++++++++++++++++++ spec/request/service_usage_snapshots_spec.rb | 290 +++++++++++++ spec/support/fakes/blueprints.rb | 41 ++ .../app_usage_snapshot_cleanup_spec.rb | 129 ++++++ .../app_usage_snapshot_generator_job_spec.rb | 104 +++++ .../service_usage_snapshot_cleanup_spec.rb | 129 ++++++ ...rvice_usage_snapshot_generator_job_spec.rb | 104 +++++ .../cloud_controller/clock/scheduler_spec.rb | 18 +- .../runtime/app_usage_snapshot_chunk_spec.rb | 143 ++++++ .../models/runtime/app_usage_snapshot_spec.rb | 93 ++++ .../service_usage_snapshot_chunk_spec.rb | 143 ++++++ .../runtime/service_usage_snapshot_spec.rb | 77 ++++ .../app_usage_snapshot_repository_spec.rb | 349 +++++++++++++++ .../service_usage_snapshot_repository_spec.rb | 329 ++++++++++++++ 47 files changed, 3560 insertions(+), 3 deletions(-) create mode 100644 app/controllers/v3/app_usage_snapshots_controller.rb create mode 100644 app/controllers/v3/service_usage_snapshots_controller.rb create mode 100644 app/fetchers/app_usage_snapshot_list_fetcher.rb create mode 100644 app/fetchers/service_usage_snapshot_list_fetcher.rb create mode 100644 app/jobs/runtime/app_usage_snapshot_cleanup.rb create mode 100644 app/jobs/runtime/app_usage_snapshot_generator_job.rb create mode 100644 app/jobs/runtime/service_usage_snapshot_cleanup.rb create mode 100644 app/jobs/runtime/service_usage_snapshot_generator_job.rb create mode 100644 app/messages/app_usage_snapshots_create_message.rb create mode 100644 app/messages/app_usage_snapshots_list_message.rb create mode 100644 app/messages/service_usage_snapshots_create_message.rb create mode 100644 app/messages/service_usage_snapshots_list_message.rb create mode 100644 app/models/runtime/app_usage_snapshot.rb create mode 100644 app/models/runtime/app_usage_snapshot_chunk.rb create mode 100644 app/models/runtime/service_usage_snapshot.rb create mode 100644 app/models/runtime/service_usage_snapshot_chunk.rb create mode 100644 app/presenters/v3/app_usage_snapshot_chunk_presenter.rb create mode 100644 app/presenters/v3/app_usage_snapshot_presenter.rb create mode 100644 app/presenters/v3/service_usage_snapshot_chunk_presenter.rb create mode 100644 app/presenters/v3/service_usage_snapshot_presenter.rb create mode 100644 app/repositories/app_usage_snapshot_repository.rb create mode 100644 app/repositories/service_usage_snapshot_repository.rb create mode 100644 db/migrations/20260114200000_create_app_usage_snapshots.rb create mode 100644 db/migrations/20260114200100_create_service_usage_snapshots.rb create mode 100644 spec/request/app_usage_snapshots_spec.rb create mode 100644 spec/request/service_usage_snapshots_spec.rb create mode 100644 spec/unit/jobs/runtime/app_usage_snapshot_cleanup_spec.rb create mode 100644 spec/unit/jobs/runtime/app_usage_snapshot_generator_job_spec.rb create mode 100644 spec/unit/jobs/runtime/service_usage_snapshot_cleanup_spec.rb create mode 100644 spec/unit/jobs/runtime/service_usage_snapshot_generator_job_spec.rb create mode 100644 spec/unit/models/runtime/app_usage_snapshot_chunk_spec.rb create mode 100644 spec/unit/models/runtime/app_usage_snapshot_spec.rb create mode 100644 spec/unit/models/runtime/service_usage_snapshot_chunk_spec.rb create mode 100644 spec/unit/models/runtime/service_usage_snapshot_spec.rb create mode 100644 spec/unit/repositories/app_usage_snapshot_repository_spec.rb create mode 100644 spec/unit/repositories/service_usage_snapshot_repository_spec.rb diff --git a/app/controllers/v3/app_usage_snapshots_controller.rb b/app/controllers/v3/app_usage_snapshots_controller.rb new file mode 100644 index 00000000000..355d7f85d3b --- /dev/null +++ b/app/controllers/v3/app_usage_snapshots_controller.rb @@ -0,0 +1,90 @@ +require 'presenters/v3/app_usage_snapshot_presenter' +require 'presenters/v3/app_usage_snapshot_chunk_presenter' +require 'messages/app_usage_snapshots_create_message' +require 'messages/app_usage_snapshots_list_message' +require 'fetchers/app_usage_snapshot_list_fetcher' +require 'jobs/runtime/app_usage_snapshot_generator_job' + +class AppUsageSnapshotsController < ApplicationController + def index + message = AppUsageSnapshotsListMessage.from_params(query_params) + unprocessable!(message.errors.full_messages) unless message.valid? + + dataset = AppUsageSnapshot.where(guid: []) + dataset = AppUsageSnapshotListFetcher.fetch_all(message, AppUsageSnapshot.dataset) if permission_queryer.can_read_globally? + + render status: :ok, json: Presenters::V3::PaginatedListPresenter.new( + presenter: Presenters::V3::AppUsageSnapshotPresenter, + paginated_result: SequelPaginator.new.get_page(dataset, message.try(:pagination_options)), + path: '/v3/app_usage/snapshots', + message: message + ) + end + + def show + snapshot_not_found! unless permission_queryer.can_read_globally? + + snapshot = AppUsageSnapshot.first(guid: hashed_params[:guid]) + snapshot_not_found! unless snapshot + + render status: :ok, json: Presenters::V3::AppUsageSnapshotPresenter.new(snapshot) + end + + def create + message = AppUsageSnapshotsCreateMessage.new(hashed_params[:body]) + unprocessable!(message.errors.full_messages) unless message.valid? + + unauthorized! unless permission_queryer.can_write_globally? + + existing_snapshot = AppUsageSnapshot.where(completed_at: nil).first + raise CloudController::Errors::ApiError.new_from_details('AppUsageSnapshotGenerationInProgress') if existing_snapshot + + snapshot = AppUsageSnapshot.create( + checkpoint_event_guid: nil, + created_at: Time.now.utc, + completed_at: nil, + instance_count: 0, + organization_count: 0, + space_count: 0, + app_count: 0, + chunk_count: 0 + ) + + begin + job = Jobs::Runtime::AppUsageSnapshotGeneratorJob.new(snapshot.guid) + pollable_job = Jobs::Enqueuer.new(queue: Jobs::Queues.generic).enqueue_pollable(job) + rescue StandardError + snapshot.destroy + raise + end + + head :accepted, 'Location' => url_builder.build_url(path: "/v3/jobs/#{pollable_job.guid}") + end + + def chunks + snapshot_not_found! unless permission_queryer.can_read_globally? + + snapshot = AppUsageSnapshot.first(guid: hashed_params[:guid]) + snapshot_not_found! unless snapshot + + unprocessable!('Snapshot is still processing') unless snapshot.complete? + + pagination_options = PaginationOptions.from_params(query_params) + paginated_result = SequelPaginator.new.get_page( + snapshot.app_usage_snapshot_chunks_dataset, + pagination_options + ) + + render status: :ok, json: Presenters::V3::PaginatedListPresenter.new( + presenter: Presenters::V3::AppUsageSnapshotChunkPresenter, + paginated_result: paginated_result, + path: "/v3/app_usage/snapshots/#{snapshot.guid}/chunks" + ) + end + + private + + def snapshot_not_found! + resource_not_found!(:app_usage_snapshot) + end +end diff --git a/app/controllers/v3/service_usage_snapshots_controller.rb b/app/controllers/v3/service_usage_snapshots_controller.rb new file mode 100644 index 00000000000..8c10c491baf --- /dev/null +++ b/app/controllers/v3/service_usage_snapshots_controller.rb @@ -0,0 +1,89 @@ +require 'presenters/v3/service_usage_snapshot_presenter' +require 'presenters/v3/service_usage_snapshot_chunk_presenter' +require 'messages/service_usage_snapshots_create_message' +require 'messages/service_usage_snapshots_list_message' +require 'fetchers/service_usage_snapshot_list_fetcher' +require 'jobs/runtime/service_usage_snapshot_generator_job' + +class ServiceUsageSnapshotsController < ApplicationController + def index + message = ServiceUsageSnapshotsListMessage.from_params(query_params) + unprocessable!(message.errors.full_messages) unless message.valid? + + dataset = ServiceUsageSnapshot.where(guid: []) + dataset = ServiceUsageSnapshotListFetcher.fetch_all(message, ServiceUsageSnapshot.dataset) if permission_queryer.can_read_globally? + + render status: :ok, json: Presenters::V3::PaginatedListPresenter.new( + presenter: Presenters::V3::ServiceUsageSnapshotPresenter, + paginated_result: SequelPaginator.new.get_page(dataset, message.try(:pagination_options)), + path: '/v3/service_usage/snapshots', + message: message + ) + end + + def show + snapshot_not_found! unless permission_queryer.can_read_globally? + + snapshot = ServiceUsageSnapshot.first(guid: hashed_params[:guid]) + snapshot_not_found! unless snapshot + + render status: :ok, json: Presenters::V3::ServiceUsageSnapshotPresenter.new(snapshot) + end + + def create + message = ServiceUsageSnapshotsCreateMessage.new(hashed_params[:body]) + unprocessable!(message.errors.full_messages) unless message.valid? + + unauthorized! unless permission_queryer.can_write_globally? + + existing_snapshot = ServiceUsageSnapshot.where(completed_at: nil).first + raise CloudController::Errors::ApiError.new_from_details('ServiceUsageSnapshotGenerationInProgress') if existing_snapshot + + snapshot = ServiceUsageSnapshot.create( + checkpoint_event_guid: nil, + created_at: Time.now.utc, + completed_at: nil, + service_instance_count: 0, + organization_count: 0, + space_count: 0, + chunk_count: 0 + ) + + begin + job = Jobs::Runtime::ServiceUsageSnapshotGeneratorJob.new(snapshot.guid) + pollable_job = Jobs::Enqueuer.new(queue: Jobs::Queues.generic).enqueue_pollable(job) + rescue StandardError + snapshot.destroy + raise + end + + head :accepted, 'Location' => url_builder.build_url(path: "/v3/jobs/#{pollable_job.guid}") + end + + def chunks + snapshot_not_found! unless permission_queryer.can_read_globally? + + snapshot = ServiceUsageSnapshot.first(guid: hashed_params[:guid]) + snapshot_not_found! unless snapshot + + unprocessable!('Snapshot is still processing') unless snapshot.complete? + + pagination_options = PaginationOptions.from_params(query_params) + paginated_result = SequelPaginator.new.get_page( + snapshot.service_usage_snapshot_chunks_dataset, + pagination_options + ) + + render status: :ok, json: Presenters::V3::PaginatedListPresenter.new( + presenter: Presenters::V3::ServiceUsageSnapshotChunkPresenter, + paginated_result: paginated_result, + path: "/v3/service_usage/snapshots/#{snapshot.guid}/chunks" + ) + end + + private + + def snapshot_not_found! + resource_not_found!(:service_usage_snapshot) + end +end diff --git a/app/fetchers/app_usage_snapshot_list_fetcher.rb b/app/fetchers/app_usage_snapshot_list_fetcher.rb new file mode 100644 index 00000000000..11b56104e5c --- /dev/null +++ b/app/fetchers/app_usage_snapshot_list_fetcher.rb @@ -0,0 +1,17 @@ +require 'fetchers/base_list_fetcher' + +module VCAP::CloudController + class AppUsageSnapshotListFetcher < BaseListFetcher + class << self + def fetch_all(message, dataset) + filter(message, dataset) + end + + private + + def filter(message, dataset) + super(message, dataset, AppUsageSnapshot) + end + end + end +end diff --git a/app/fetchers/service_usage_snapshot_list_fetcher.rb b/app/fetchers/service_usage_snapshot_list_fetcher.rb new file mode 100644 index 00000000000..65e16bd77d0 --- /dev/null +++ b/app/fetchers/service_usage_snapshot_list_fetcher.rb @@ -0,0 +1,17 @@ +require 'fetchers/base_list_fetcher' + +module VCAP::CloudController + class ServiceUsageSnapshotListFetcher < BaseListFetcher + class << self + def fetch_all(message, dataset) + filter(message, dataset) + end + + private + + def filter(message, dataset) + super(message, dataset, ServiceUsageSnapshot) + end + end + end +end diff --git a/app/jobs/runtime/app_usage_snapshot_cleanup.rb b/app/jobs/runtime/app_usage_snapshot_cleanup.rb new file mode 100644 index 00000000000..6893a0bd949 --- /dev/null +++ b/app/jobs/runtime/app_usage_snapshot_cleanup.rb @@ -0,0 +1,45 @@ +module VCAP::CloudController + module Jobs + module Runtime + class AppUsageSnapshotCleanup < VCAP::CloudController::Jobs::CCJob + attr_accessor :cutoff_age_in_days + + def initialize(cutoff_age_in_days) + @cutoff_age_in_days = cutoff_age_in_days + end + + def perform + logger = Steno.logger('cc.background') + logger.info("Cleaning up usage snapshots older than #{cutoff_age_in_days} days") + + cutoff_time = Time.now.utc - cutoff_age_in_days.days + + old_completed = AppUsageSnapshot.where( + Sequel.lit('created_at < ? AND completed_at IS NOT NULL', cutoff_time) + ) + + stale_timeout = Time.now.utc - 1.hour + stale_in_progress = AppUsageSnapshot.where( + Sequel.lit('created_at < ? AND completed_at IS NULL', stale_timeout) + ) + + completed_count = old_completed.count + stale_count = stale_in_progress.count + + old_completed.delete + stale_in_progress.delete + + logger.info("Deleted #{completed_count} old completed snapshots and #{stale_count} stale in-progress snapshots") + end + + def job_name_in_configuration + :app_usage_snapshot_cleanup + end + + def max_attempts + 1 + end + end + end + end +end diff --git a/app/jobs/runtime/app_usage_snapshot_generator_job.rb b/app/jobs/runtime/app_usage_snapshot_generator_job.rb new file mode 100644 index 00000000000..e1cc2f71d78 --- /dev/null +++ b/app/jobs/runtime/app_usage_snapshot_generator_job.rb @@ -0,0 +1,47 @@ +require 'repositories/app_usage_snapshot_repository' + +module VCAP::CloudController + module Jobs + module Runtime + class AppUsageSnapshotGeneratorJob < VCAP::CloudController::Jobs::CCJob + attr_reader :resource_guid + + def initialize(snapshot_guid) + @resource_guid = snapshot_guid + end + + def perform + logger = Steno.logger('cc.background') + logger.info("Starting usage snapshot generation for snapshot #{@resource_guid}") + + snapshot = AppUsageSnapshot.first(guid: @resource_guid) + raise "Snapshot not found: #{@resource_guid}" unless snapshot + + repository = Repositories::AppUsageSnapshotRepository.new + repository.populate_snapshot!(snapshot) + + logger.info("Usage snapshot #{snapshot.guid} completed: #{snapshot.instance_count} instances") + rescue StandardError => e + logger.error("Usage snapshot generation failed: #{e.message}\n#{e.backtrace.join("\n")}") + raise + end + + def job_name_in_configuration + :app_usage_snapshot_generator + end + + def max_attempts + 1 + end + + def resource_type + 'app_usage_snapshot' + end + + def display_name + 'app_usage_snapshot.generate' + end + end + end + end +end diff --git a/app/jobs/runtime/service_usage_snapshot_cleanup.rb b/app/jobs/runtime/service_usage_snapshot_cleanup.rb new file mode 100644 index 00000000000..8a53daad072 --- /dev/null +++ b/app/jobs/runtime/service_usage_snapshot_cleanup.rb @@ -0,0 +1,45 @@ +module VCAP::CloudController + module Jobs + module Runtime + class ServiceUsageSnapshotCleanup < VCAP::CloudController::Jobs::CCJob + attr_accessor :cutoff_age_in_days + + def initialize(cutoff_age_in_days) + @cutoff_age_in_days = cutoff_age_in_days + end + + def perform + logger = Steno.logger('cc.background') + logger.info("Cleaning up service usage snapshots older than #{cutoff_age_in_days} days") + + cutoff_time = Time.now.utc - cutoff_age_in_days.days + + old_completed = ServiceUsageSnapshot.where( + Sequel.lit('created_at < ? AND completed_at IS NOT NULL', cutoff_time) + ) + + stale_timeout = Time.now.utc - 1.hour + stale_in_progress = ServiceUsageSnapshot.where( + Sequel.lit('created_at < ? AND completed_at IS NULL', stale_timeout) + ) + + completed_count = old_completed.count + stale_count = stale_in_progress.count + + old_completed.delete + stale_in_progress.delete + + logger.info("Deleted #{completed_count} old completed snapshots and #{stale_count} stale in-progress snapshots") + end + + def job_name_in_configuration + :service_usage_snapshot_cleanup + end + + def max_attempts + 1 + end + end + end + end +end diff --git a/app/jobs/runtime/service_usage_snapshot_generator_job.rb b/app/jobs/runtime/service_usage_snapshot_generator_job.rb new file mode 100644 index 00000000000..afc37a110a8 --- /dev/null +++ b/app/jobs/runtime/service_usage_snapshot_generator_job.rb @@ -0,0 +1,47 @@ +require 'repositories/service_usage_snapshot_repository' + +module VCAP::CloudController + module Jobs + module Runtime + class ServiceUsageSnapshotGeneratorJob < VCAP::CloudController::Jobs::CCJob + attr_reader :resource_guid + + def initialize(snapshot_guid) + @resource_guid = snapshot_guid + end + + def perform + logger = Steno.logger('cc.background') + logger.info("Starting service usage snapshot generation for snapshot #{@resource_guid}") + + snapshot = ServiceUsageSnapshot.first(guid: @resource_guid) + raise "Snapshot not found: #{@resource_guid}" unless snapshot + + repository = Repositories::ServiceUsageSnapshotRepository.new + repository.populate_snapshot!(snapshot) + + logger.info("Service usage snapshot #{snapshot.guid} completed: #{snapshot.service_instance_count} service instances") + rescue StandardError => e + logger.error("Service usage snapshot generation failed: #{e.message}\n#{e.backtrace.join("\n")}") + raise + end + + def job_name_in_configuration + :service_usage_snapshot_generator + end + + def max_attempts + 1 + end + + def resource_type + 'service_usage_snapshot' + end + + def display_name + 'service_usage_snapshot.generate' + end + end + end + end +end diff --git a/app/messages/app_usage_snapshots_create_message.rb b/app/messages/app_usage_snapshots_create_message.rb new file mode 100644 index 00000000000..63ff495275e --- /dev/null +++ b/app/messages/app_usage_snapshots_create_message.rb @@ -0,0 +1,13 @@ +require 'messages/base_message' + +module VCAP::CloudController + class AppUsageSnapshotsCreateMessage < BaseMessage + register_allowed_keys [] + + validates_with NoAdditionalParamsValidator + + def self.from_params(params) + super(params, []) + end + end +end diff --git a/app/messages/app_usage_snapshots_list_message.rb b/app/messages/app_usage_snapshots_list_message.rb new file mode 100644 index 00000000000..5135df3da72 --- /dev/null +++ b/app/messages/app_usage_snapshots_list_message.rb @@ -0,0 +1,17 @@ +require 'messages/list_message' + +module VCAP::CloudController + class AppUsageSnapshotsListMessage < ListMessage + register_allowed_keys [] + + validates_with NoAdditionalParamsValidator + + def self.from_params(params) + super(params, []) + end + + def valid_order_by_values + super + [:created_at] + end + end +end diff --git a/app/messages/service_usage_snapshots_create_message.rb b/app/messages/service_usage_snapshots_create_message.rb new file mode 100644 index 00000000000..069301a4afb --- /dev/null +++ b/app/messages/service_usage_snapshots_create_message.rb @@ -0,0 +1,13 @@ +require 'messages/base_message' + +module VCAP::CloudController + class ServiceUsageSnapshotsCreateMessage < BaseMessage + register_allowed_keys [] + + validates_with NoAdditionalParamsValidator + + def self.from_params(params) + super(params, []) + end + end +end diff --git a/app/messages/service_usage_snapshots_list_message.rb b/app/messages/service_usage_snapshots_list_message.rb new file mode 100644 index 00000000000..8340363161a --- /dev/null +++ b/app/messages/service_usage_snapshots_list_message.rb @@ -0,0 +1,17 @@ +require 'messages/list_message' + +module VCAP::CloudController + class ServiceUsageSnapshotsListMessage < ListMessage + register_allowed_keys [] + + validates_with NoAdditionalParamsValidator + + def self.from_params(params) + super(params, []) + end + + def valid_order_by_values + super + [:created_at] + end + end +end diff --git a/app/models.rb b/app/models.rb index 93e1594b38d..d79fe4aff17 100644 --- a/app/models.rb +++ b/app/models.rb @@ -159,3 +159,8 @@ require 'models/runtime/organization_billing_manager' require 'models/runtime/role' require 'models/runtime/asg_latest_update' + +require 'models/runtime/app_usage_snapshot' +require 'models/runtime/service_usage_snapshot' +require 'models/runtime/app_usage_snapshot_chunk' +require 'models/runtime/service_usage_snapshot_chunk' diff --git a/app/models/runtime/app_usage_snapshot.rb b/app/models/runtime/app_usage_snapshot.rb new file mode 100644 index 00000000000..c1a91d4b4ca --- /dev/null +++ b/app/models/runtime/app_usage_snapshot.rb @@ -0,0 +1,23 @@ +module VCAP::CloudController + class AppUsageSnapshot < Sequel::Model(:app_usage_snapshots) + one_to_many :app_usage_snapshot_chunks + + def validate + super + validates_presence :created_at + validates_presence :instance_count + validates_presence :organization_count + validates_presence :space_count + validates_presence :app_count + validates_presence :chunk_count + end + + def processing? + completed_at.nil? + end + + def complete? + !completed_at.nil? + end + end +end diff --git a/app/models/runtime/app_usage_snapshot_chunk.rb b/app/models/runtime/app_usage_snapshot_chunk.rb new file mode 100644 index 00000000000..7b64f467e47 --- /dev/null +++ b/app/models/runtime/app_usage_snapshot_chunk.rb @@ -0,0 +1,17 @@ +module VCAP::CloudController + class AppUsageSnapshotChunk < Sequel::Model(:app_usage_snapshot_chunks) + plugin :serialization + + many_to_one :app_usage_snapshot + + serialize_attributes :json, :processes + + def validate + super + validates_presence :app_usage_snapshot_id + validates_presence :organization_guid + validates_presence :space_guid + validates_presence :chunk_index + end + end +end diff --git a/app/models/runtime/pollable_job_model.rb b/app/models/runtime/pollable_job_model.rb index 29dbf4a44aa..aae9c273353 100644 --- a/app/models/runtime/pollable_job_model.rb +++ b/app/models/runtime/pollable_job_model.rb @@ -28,6 +28,10 @@ def resource_exists? RouteBinding when 'service_credential_binding' ServiceCredentialBinding::View + when 'app_usage_snapshot' + AppUsageSnapshot + when 'service_usage_snapshot' + ServiceUsageSnapshot else Sequel::Model(ActiveSupport::Inflector.pluralize(resource_type).to_sym) end diff --git a/app/models/runtime/service_usage_snapshot.rb b/app/models/runtime/service_usage_snapshot.rb new file mode 100644 index 00000000000..54324a52671 --- /dev/null +++ b/app/models/runtime/service_usage_snapshot.rb @@ -0,0 +1,25 @@ +module VCAP::CloudController + class ServiceUsageSnapshot < Sequel::Model(:service_usage_snapshots) + one_to_many :service_usage_snapshot_chunks + + def validate + super + # NOTE: checkpoint_event_guid and checkpoint_event_created_at can be NULL when + # the snapshot is first created (placeholder) or when there are no usage events + # (empty system). The columns are intentionally nullable in the migration. + validates_presence :created_at + validates_presence :service_instance_count + validates_presence :organization_count + validates_presence :space_count + validates_presence :chunk_count + end + + def processing? + completed_at.nil? + end + + def complete? + !completed_at.nil? + end + end +end diff --git a/app/models/runtime/service_usage_snapshot_chunk.rb b/app/models/runtime/service_usage_snapshot_chunk.rb new file mode 100644 index 00000000000..36f45574651 --- /dev/null +++ b/app/models/runtime/service_usage_snapshot_chunk.rb @@ -0,0 +1,17 @@ +module VCAP::CloudController + class ServiceUsageSnapshotChunk < Sequel::Model(:service_usage_snapshot_chunks) + plugin :serialization + + many_to_one :service_usage_snapshot + + serialize_attributes :json, :service_instances + + def validate + super + validates_presence :service_usage_snapshot_id + validates_presence :organization_guid + validates_presence :space_guid + validates_presence :chunk_index + end + end +end diff --git a/app/presenters/v3/app_usage_snapshot_chunk_presenter.rb b/app/presenters/v3/app_usage_snapshot_chunk_presenter.rb new file mode 100644 index 00000000000..3654acce6ae --- /dev/null +++ b/app/presenters/v3/app_usage_snapshot_chunk_presenter.rb @@ -0,0 +1,26 @@ +require 'presenters/v3/base_presenter' + +module VCAP::CloudController + module Presenters + module V3 + class AppUsageSnapshotChunkPresenter < BasePresenter + def to_hash + { + organization_guid: chunk.organization_guid, + organization_name: chunk.organization_name, + space_guid: chunk.space_guid, + space_name: chunk.space_name, + chunk_index: chunk.chunk_index, + processes: chunk.processes || [] + } + end + + private + + def chunk + @resource + end + end + end + end +end diff --git a/app/presenters/v3/app_usage_snapshot_presenter.rb b/app/presenters/v3/app_usage_snapshot_presenter.rb new file mode 100644 index 00000000000..efdb9bc7f7c --- /dev/null +++ b/app/presenters/v3/app_usage_snapshot_presenter.rb @@ -0,0 +1,45 @@ +require 'presenters/v3/base_presenter' + +module VCAP::CloudController + module Presenters + module V3 + class AppUsageSnapshotPresenter < BasePresenter + def to_hash + { + guid: snapshot.guid, + created_at: snapshot.created_at, + completed_at: snapshot.completed_at, + checkpoint_event_guid: snapshot.checkpoint_event_guid, + checkpoint_event_created_at: snapshot.checkpoint_event_created_at, + summary: { + instance_count: snapshot.instance_count, + app_count: snapshot.app_count, + organization_count: snapshot.organization_count, + space_count: snapshot.space_count, + chunk_count: snapshot.chunk_count + }, + links: build_links + } + end + + private + + def snapshot + @resource + end + + def build_links + links = { + self: { href: url_builder.build_url(path: "/v3/app_usage/snapshots/#{snapshot.guid}") } + } + + links[:checkpoint_event] = { href: url_builder.build_url(path: "/v3/app_usage_events/#{snapshot.checkpoint_event_guid}") } if snapshot.checkpoint_event_guid.present? + + links[:chunks] = { href: url_builder.build_url(path: "/v3/app_usage/snapshots/#{snapshot.guid}/chunks") } if snapshot.complete? + + links + end + end + end + end +end diff --git a/app/presenters/v3/service_usage_snapshot_chunk_presenter.rb b/app/presenters/v3/service_usage_snapshot_chunk_presenter.rb new file mode 100644 index 00000000000..7455a15dc72 --- /dev/null +++ b/app/presenters/v3/service_usage_snapshot_chunk_presenter.rb @@ -0,0 +1,26 @@ +require 'presenters/v3/base_presenter' + +module VCAP::CloudController + module Presenters + module V3 + class ServiceUsageSnapshotChunkPresenter < BasePresenter + def to_hash + { + organization_guid: chunk.organization_guid, + organization_name: chunk.organization_name, + space_guid: chunk.space_guid, + space_name: chunk.space_name, + chunk_index: chunk.chunk_index, + service_instances: chunk.service_instances || [] + } + end + + private + + def chunk + @resource + end + end + end + end +end diff --git a/app/presenters/v3/service_usage_snapshot_presenter.rb b/app/presenters/v3/service_usage_snapshot_presenter.rb new file mode 100644 index 00000000000..56e071f3d9b --- /dev/null +++ b/app/presenters/v3/service_usage_snapshot_presenter.rb @@ -0,0 +1,44 @@ +require 'presenters/v3/base_presenter' + +module VCAP::CloudController + module Presenters + module V3 + class ServiceUsageSnapshotPresenter < BasePresenter + def to_hash + { + guid: snapshot.guid, + created_at: snapshot.created_at, + completed_at: snapshot.completed_at, + checkpoint_event_guid: snapshot.checkpoint_event_guid, + checkpoint_event_created_at: snapshot.checkpoint_event_created_at, + summary: { + service_instance_count: snapshot.service_instance_count, + organization_count: snapshot.organization_count, + space_count: snapshot.space_count, + chunk_count: snapshot.chunk_count + }, + links: build_links + } + end + + private + + def snapshot + @resource + end + + def build_links + links = { + self: { href: url_builder.build_url(path: "/v3/service_usage/snapshots/#{snapshot.guid}") } + } + + links[:checkpoint_event] = { href: url_builder.build_url(path: "/v3/service_usage_events/#{snapshot.checkpoint_event_guid}") } if snapshot.checkpoint_event_guid.present? + + links[:chunks] = { href: url_builder.build_url(path: "/v3/service_usage/snapshots/#{snapshot.guid}/chunks") } if snapshot.complete? + + links + end + end + end + end +end diff --git a/app/repositories/app_usage_snapshot_repository.rb b/app/repositories/app_usage_snapshot_repository.rb new file mode 100644 index 00000000000..9d9a9ef28d3 --- /dev/null +++ b/app/repositories/app_usage_snapshot_repository.rb @@ -0,0 +1,181 @@ +require 'oj' + +module VCAP::CloudController + module Repositories + class AppUsageSnapshotRepository + BATCH_SIZE = 1000 + CHUNK_LIMIT = 50 + + # Populates a snapshot with process data, creating chunks of 50 processes per space. + def populate_snapshot!(snapshot) + start_time = Time.now + + generator = ChunkGenerator.new(snapshot, CHUNK_LIMIT) + + AppUsageSnapshot.db.transaction do + checkpoint_event = AppUsageEvent.order(Sequel.desc(:id)).first + + generator.generate_from_stream(build_process_query) + + snapshot.update( + checkpoint_event_guid: checkpoint_event&.guid, + checkpoint_event_created_at: checkpoint_event&.created_at, + instance_count: generator.total_instances, + organization_count: generator.org_guids.size, + space_count: generator.space_guids.size, + app_count: generator.app_guids.size, + chunk_count: generator.chunk_count, + completed_at: Time.now.utc + ) + end + + snapshot.reload + + duration = Time.now - start_time + logger.info("Snapshot #{snapshot.guid} created: #{snapshot.instance_count} instances, " \ + "#{snapshot.app_count} apps, #{snapshot.chunk_count} chunks in #{duration.round(2)}s") + prometheus.update_histogram_metric(:cc_app_usage_snapshot_generation_duration_seconds, duration) + + snapshot + rescue StandardError => e + logger.error("Snapshot generation failed: #{e.message}") + prometheus.increment_counter_metric(:cc_app_usage_snapshot_generation_failures_total) + raise + end + + private + + def build_process_query + ProcessModel. + join(AppModel.table_name, { guid: :app_guid }, table_alias: :parent_app). + join(Space.table_name, guid: :space_guid). + join(Organization.table_name, id: :organization_id). + left_join(DropletModel.table_name, { guid: :parent_app__droplet_guid }, table_alias: :desired_droplet). + where("#{ProcessModel.table_name}__state": ProcessModel::STARTED). + exclude("#{ProcessModel.table_name}__type": %w[TASK build]). + order(Sequel.qualify(Space.table_name, :guid), Sequel.qualify(ProcessModel.table_name, :id)). + select( + Sequel.as(:"#{ProcessModel.table_name}__id", :process_id), + Sequel.as(:"#{ProcessModel.table_name}__guid", :process_guid), + Sequel.as(:"#{ProcessModel.table_name}__type", :process_type), + Sequel.as(:"#{ProcessModel.table_name}__instances", :instances), + Sequel.as(:"#{ProcessModel.table_name}__memory", :memory), + Sequel.as(:parent_app__guid, :app_guid), + Sequel.as(:parent_app__name, :app_name), + Sequel.as(:"#{Space.table_name}__guid", :space_guid), + Sequel.as(:"#{Space.table_name}__name", :space_name), + Sequel.as(:"#{Organization.table_name}__guid", :organization_guid), + Sequel.as(:"#{Organization.table_name}__name", :organization_name), + Sequel.as(:desired_droplet__buildpack_receipt_buildpack_guid, :buildpack_guid), + Sequel.as(:desired_droplet__buildpack_receipt_buildpack, :buildpack_name) + ) + end + + def prometheus + @prometheus ||= CloudController::DependencyLocator.instance.prometheus_updater + end + + def logger + @logger ||= Steno.logger('cc.app_usage_snapshot_repository') + end + + class ChunkGenerator + attr_reader :total_instances, :chunk_count, :org_guids, :space_guids, :app_guids + + def initialize(snapshot, chunk_limit) + @snapshot = snapshot + @chunk_limit = chunk_limit + + @total_instances = 0 + @chunk_count = 0 + @org_guids = Set.new + @space_guids = Set.new + @app_guids = Set.new + + @current_space_guid = nil + @current_space_name = nil + @current_org_guid = nil + @current_org_name = nil + @current_chunk_index = 0 + @current_chunk_processes = [] + @pending_chunks = [] + end + + def generate_from_stream(query) + query.paged_each(rows_per_fetch: BATCH_SIZE) do |row| + process_row(row) + end + + flush_current_chunk if @current_chunk_processes.any? + flush_pending_chunks + end + + private + + def process_row(row) + space_guid = row[:space_guid] + return if space_guid.nil? + + org_guid = row[:organization_guid] + + if space_guid != @current_space_guid + flush_current_chunk if @current_chunk_processes.any? + @current_space_guid = space_guid + @current_space_name = row[:space_name] + @current_org_guid = org_guid + @current_org_name = row[:organization_name] + @current_chunk_index = 0 + @current_chunk_processes = [] + end + + @org_guids << org_guid + @space_guids << space_guid + @app_guids << row[:app_guid] + instance_count = row[:instances] || 0 + @total_instances += instance_count + + @current_chunk_processes << { + app_guid: row[:app_guid], + app_name: row[:app_name], + process_guid: row[:process_guid], + process_type: row[:process_type], + instance_count: row[:instances], + memory_in_mb_per_instance: row[:memory], + buildpack_guid: row[:buildpack_guid], + buildpack_name: row[:buildpack_name] + } + + return unless @current_chunk_processes.size >= @chunk_limit + + flush_current_chunk + @current_chunk_index += 1 + @current_chunk_processes = [] + end + + def flush_current_chunk + return if @current_chunk_processes.empty? + + @pending_chunks << { + app_usage_snapshot_id: @snapshot.id, + organization_guid: @current_org_guid, + organization_name: @current_org_name, + space_guid: @current_space_guid, + space_name: @current_space_name, + chunk_index: @current_chunk_index, + processes: Oj.dump(@current_chunk_processes, mode: :compat) + } + @chunk_count += 1 + + flush_pending_chunks if @pending_chunks.size >= BATCH_SIZE + end + + def flush_pending_chunks + return if @pending_chunks.empty? + + AppUsageSnapshotChunk.dataset.multi_insert(@pending_chunks) + @pending_chunks = [] + end + end + end + end +end diff --git a/app/repositories/service_usage_snapshot_repository.rb b/app/repositories/service_usage_snapshot_repository.rb new file mode 100644 index 00000000000..1c7369c8363 --- /dev/null +++ b/app/repositories/service_usage_snapshot_repository.rb @@ -0,0 +1,178 @@ +require 'oj' + +module VCAP::CloudController + module Repositories + class ServiceUsageSnapshotRepository + BATCH_SIZE = 1000 + CHUNK_LIMIT = 50 + + # Populates a snapshot with service instance data, creating chunks of 50 instances per space. + def populate_snapshot!(snapshot) + start_time = Time.now + + generator = ChunkGenerator.new(snapshot, CHUNK_LIMIT) + + ServiceUsageSnapshot.db.transaction do + checkpoint_event = ServiceUsageEvent.order(Sequel.desc(:id)).first + + generator.generate_from_stream(build_service_instance_query) + + snapshot.update( + checkpoint_event_guid: checkpoint_event&.guid, + checkpoint_event_created_at: checkpoint_event&.created_at, + service_instance_count: generator.total_service_instances, + organization_count: generator.org_guids.size, + space_count: generator.space_guids.size, + chunk_count: generator.chunk_count, + completed_at: Time.now.utc + ) + end + + snapshot.reload + + duration = Time.now - start_time + logger.info("Service snapshot #{snapshot.guid} created: " \ + "#{snapshot.service_instance_count} service instances, #{snapshot.chunk_count} chunks in #{duration.round(2)}s") + prometheus.update_histogram_metric(:cc_service_usage_snapshot_generation_duration_seconds, duration) + + snapshot + rescue StandardError => e + logger.error("Service snapshot generation failed: #{e.message}") + prometheus.increment_counter_metric(:cc_service_usage_snapshot_generation_failures_total) + raise + end + + private + + def build_service_instance_query + ServiceInstance. + join(:spaces, id: :service_instances__space_id). + join(:organizations, id: :spaces__organization_id). + left_join(:service_plans, id: :service_instances__service_plan_id). + left_join(:services, id: :service_plans__service_id). + left_join(:service_brokers, id: :services__service_broker_id). + order(Sequel.qualify(:spaces, :guid), Sequel.qualify(:service_instances, :id)). + select( + Sequel.as(:service_instances__id, :service_instance_id), + Sequel.as(:service_instances__guid, :guid), + Sequel.as(:service_instances__name, :name), + Sequel.as(:service_instances__is_gateway_service, :is_managed), + Sequel.as(:spaces__guid, :space_guid), + Sequel.as(:spaces__name, :space_name), + Sequel.as(:organizations__guid, :organization_guid), + Sequel.as(:organizations__name, :organization_name), + Sequel.as(:service_plans__guid, :service_plan_guid), + Sequel.as(:service_plans__name, :service_plan_name), + Sequel.as(:services__guid, :service_guid), + Sequel.as(:services__label, :service_label), + Sequel.as(:service_brokers__guid, :service_broker_guid), + Sequel.as(:service_brokers__name, :service_broker_name) + ) + end + + def prometheus + @prometheus ||= CloudController::DependencyLocator.instance.prometheus_updater + end + + def logger + @logger ||= Steno.logger('cc.service_usage_snapshot_repository') + end + + class ChunkGenerator + attr_reader :total_service_instances, :chunk_count, :org_guids, :space_guids + + def initialize(snapshot, chunk_limit) + @snapshot = snapshot + @chunk_limit = chunk_limit + + @total_service_instances = 0 + @chunk_count = 0 + @org_guids = Set.new + @space_guids = Set.new + + @current_space_guid = nil + @current_space_name = nil + @current_org_guid = nil + @current_org_name = nil + @current_chunk_index = 0 + @current_chunk_instances = [] + @pending_chunks = [] + end + + def generate_from_stream(query) + query.paged_each(rows_per_fetch: BATCH_SIZE) do |row| + process_row(row) + end + + flush_current_chunk if @current_chunk_instances.any? + flush_pending_chunks + end + + private + + def process_row(row) + space_guid = row[:space_guid] + return if space_guid.nil? + + org_guid = row[:organization_guid] + + if space_guid != @current_space_guid + flush_current_chunk if @current_chunk_instances.any? + @current_space_guid = space_guid + @current_space_name = row[:space_name] + @current_org_guid = org_guid + @current_org_name = row[:organization_name] + @current_chunk_index = 0 + @current_chunk_instances = [] + end + + @org_guids << org_guid + @space_guids << space_guid + @total_service_instances += 1 + + @current_chunk_instances << { + service_instance_guid: row[:guid], + service_instance_name: row[:name], + service_instance_type: row[:is_managed] ? 'managed' : 'user_provided', + service_plan_guid: row[:service_plan_guid], + service_plan_name: row[:service_plan_name], + service_offering_guid: row[:service_guid], + service_offering_name: row[:service_label], + service_broker_guid: row[:service_broker_guid], + service_broker_name: row[:service_broker_name] + } + + return unless @current_chunk_instances.size >= @chunk_limit + + flush_current_chunk + @current_chunk_index += 1 + @current_chunk_instances = [] + end + + def flush_current_chunk + return if @current_chunk_instances.empty? + + @pending_chunks << { + service_usage_snapshot_id: @snapshot.id, + organization_guid: @current_org_guid, + organization_name: @current_org_name, + space_guid: @current_space_guid, + space_name: @current_space_name, + chunk_index: @current_chunk_index, + service_instances: Oj.dump(@current_chunk_instances, mode: :compat) + } + @chunk_count += 1 + + flush_pending_chunks if @pending_chunks.size >= BATCH_SIZE + end + + def flush_pending_chunks + return if @pending_chunks.empty? + + ServiceUsageSnapshotChunk.dataset.multi_insert(@pending_chunks) + @pending_chunks = [] + end + end + end + end +end diff --git a/config/cloud_controller.yml b/config/cloud_controller.yml index bdb1f9108f5..a6a9a2d9996 100644 --- a/config/cloud_controller.yml +++ b/config/cloud_controller.yml @@ -58,6 +58,12 @@ service_operations_initial_cleanup: completed_tasks: cutoff_age_in_days: 31 +app_usage_snapshot: + cutoff_age_in_days: 31 + +service_usage_snapshot: + cutoff_age_in_days: 31 + cpu_weight_min_memory: 128 #mb cpu_weight_max_memory: 8192 #mb default_app_memory: 1024 #mb diff --git a/config/routes.rb b/config/routes.rb index d1723e474e0..397e91510bc 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -334,6 +334,18 @@ get '/service_usage_events', to: 'service_usage_events#index' post '/service_usage_events/actions/destructively_purge_all_and_reseed', to: 'service_usage_events#destructively_purge_all_and_reseed' + # app usage snapshots + get '/app_usage/snapshots', to: 'app_usage_snapshots#index' + post '/app_usage/snapshots', to: 'app_usage_snapshots#create' + get '/app_usage/snapshots/:guid', to: 'app_usage_snapshots#show' + get '/app_usage/snapshots/:guid/chunks', to: 'app_usage_snapshots#chunks' + + # service usage snapshots + get '/service_usage/snapshots', to: 'service_usage_snapshots#index' + post '/service_usage/snapshots', to: 'service_usage_snapshots#create' + get '/service_usage/snapshots/:guid', to: 'service_usage_snapshots#show' + get '/service_usage/snapshots/:guid/chunks', to: 'service_usage_snapshots#chunks' + # environment variable groups get '/environment_variable_groups/:name', to: 'environment_variable_groups#show' patch '/environment_variable_groups/:name', to: 'environment_variable_groups#update' diff --git a/db/migrations/20260114200000_create_app_usage_snapshots.rb b/db/migrations/20260114200000_create_app_usage_snapshots.rb new file mode 100644 index 00000000000..73f9b2c9adb --- /dev/null +++ b/db/migrations/20260114200000_create_app_usage_snapshots.rb @@ -0,0 +1,60 @@ +# Creates tables for app usage snapshots feature. +# +# App usage snapshots capture a point-in-time baseline of all running processes +# with a checkpoint in the app usage event stream. This enables non-destructive +# baseline establishment for billing systems. +# +# Each snapshot consists of: +# - A parent record with summary counts and checkpoint reference +# - Chunk records containing up to 50 processes each for bounded memory/API sizes +# +# The chunking strategy ensures: +# - Bounded memory during generation (streaming, not all-in-memory) +# - Bounded API response sizes (each chunk ≤ 50 processes) +# - Atomic operations (snapshot is all-or-nothing via transaction) + +Sequel.migration do + up do + create_table :app_usage_snapshots do + primary_key :id, type: :Bignum, name: :id + String :guid, null: false, size: 255 + String :checkpoint_event_guid, null: true, size: 255 + Timestamp :checkpoint_event_created_at, null: true + Timestamp :created_at, null: false, default: Sequel::CURRENT_TIMESTAMP + Timestamp :completed_at, null: true + Integer :instance_count, null: false, default: 0 + Integer :organization_count, null: false, default: 0 + Integer :space_count, null: false, default: 0 + Integer :chunk_count, null: false, default: 0 + Integer :app_count, null: false, default: 0 + + index :guid, unique: true, name: :app_usage_snapshots_guid_index + index :created_at, name: :app_usage_snapshots_created_at_index + index :completed_at, name: :app_usage_snapshots_completed_at_index + index :checkpoint_event_guid, name: :app_usage_snapshots_checkpoint_event_guid_index + end + + create_table :app_usage_snapshot_chunks do + primary_key :id, type: :Bignum, name: :id + column :app_usage_snapshot_id, :Bignum, null: false + String :organization_guid, null: false, size: 255 + String :organization_name, null: true, size: 255 + String :space_guid, null: false, size: 255 + String :space_name, null: true, size: 255 + Integer :chunk_index, null: false, default: 0 + Text :processes, null: true + + index %i[app_usage_snapshot_id space_guid chunk_index], + name: :app_snapshot_chunks_space_idx, + unique: true + foreign_key [:app_usage_snapshot_id], :app_usage_snapshots, + name: :fk_app_snapshot_chunk_snapshot_id, + on_delete: :cascade + end + end + + down do + drop_table :app_usage_snapshot_chunks + drop_table :app_usage_snapshots + end +end diff --git a/db/migrations/20260114200100_create_service_usage_snapshots.rb b/db/migrations/20260114200100_create_service_usage_snapshots.rb new file mode 100644 index 00000000000..87d2b826e35 --- /dev/null +++ b/db/migrations/20260114200100_create_service_usage_snapshots.rb @@ -0,0 +1,54 @@ +# Creates tables for service usage snapshots feature. +# +# Service usage snapshots capture a point-in-time baseline of all service instances +# (both managed and user-provided) with a checkpoint in the service usage event stream. +# This mirrors the app usage snapshots feature for service billing systems. +# +# Each snapshot consists of: +# - A parent record with summary counts and checkpoint reference +# - Chunk records containing up to 50 service instances each for bounded memory/API sizes + +Sequel.migration do + up do + create_table :service_usage_snapshots do + primary_key :id, type: :Bignum, name: :id + String :guid, null: false, size: 255 + String :checkpoint_event_guid, null: true, size: 255 + Timestamp :checkpoint_event_created_at, null: true + Timestamp :created_at, null: false, default: Sequel::CURRENT_TIMESTAMP + Timestamp :completed_at, null: true + Integer :service_instance_count, null: false, default: 0 + Integer :organization_count, null: false, default: 0 + Integer :space_count, null: false, default: 0 + Integer :chunk_count, null: false, default: 0 + + index :guid, unique: true, name: :service_usage_snapshots_guid_index + index :created_at, name: :service_usage_snapshots_created_at_index + index :completed_at, name: :service_usage_snapshots_completed_at_index + index :checkpoint_event_guid, name: :service_usage_snapshots_checkpoint_event_guid_index + end + + create_table :service_usage_snapshot_chunks do + primary_key :id, type: :Bignum, name: :id + column :service_usage_snapshot_id, :Bignum, null: false + String :organization_guid, null: false, size: 255 + String :organization_name, null: true, size: 255 + String :space_guid, null: false, size: 255 + String :space_name, null: true, size: 255 + Integer :chunk_index, null: false, default: 0 + Text :service_instances, null: true + + index %i[service_usage_snapshot_id space_guid chunk_index], + name: :svc_snapshot_chunks_space_idx, + unique: true + foreign_key [:service_usage_snapshot_id], :service_usage_snapshots, + name: :fk_svc_snapshot_chunk_snapshot_id, + on_delete: :cascade + end + end + + down do + drop_table :service_usage_snapshot_chunks + drop_table :service_usage_snapshots + end +end diff --git a/errors/v2.yml b/errors/v2.yml index 1f0d12418cb..dd990f49109 100644 --- a/errors/v2.yml +++ b/errors/v2.yml @@ -1408,3 +1408,13 @@ name: CNBRestoreFailed http_code: 400 message: "cnb: restore failed" + +440001: + name: AppUsageSnapshotGenerationInProgress + http_code: 409 + message: "An app usage snapshot is already being generated. Please wait for it to complete." + +440002: + name: ServiceUsageSnapshotGenerationInProgress + http_code: 409 + message: "A service usage snapshot is already being generated. Please wait for it to complete." diff --git a/lib/cloud_controller/clock/scheduler.rb b/lib/cloud_controller/clock/scheduler.rb index 388b5db11d5..97710385504 100644 --- a/lib/cloud_controller/clock/scheduler.rb +++ b/lib/cloud_controller/clock/scheduler.rb @@ -17,7 +17,9 @@ class Scheduler { name: 'pollable_job_cleanup', class: Jobs::Runtime::PollableJobCleanup, time: '02:00', arg_from_config: %i[pollable_jobs cutoff_age_in_days] }, { name: 'prune_completed_deployments', class: Jobs::Runtime::PruneCompletedDeployments, time: '03:00', arg_from_config: [:max_retained_deployments_per_app] }, { name: 'prune_completed_builds', class: Jobs::Runtime::PruneCompletedBuilds, time: '03:30', arg_from_config: [:max_retained_builds_per_app] }, - { name: 'prune_excess_app_revisions', class: Jobs::Runtime::PruneExcessAppRevisions, time: '03:35', arg_from_config: [:max_retained_revisions_per_app] } + { name: 'prune_excess_app_revisions', class: Jobs::Runtime::PruneExcessAppRevisions, time: '03:35', arg_from_config: [:max_retained_revisions_per_app] }, + { name: 'app_usage_snapshot', class: Jobs::Runtime::AppUsageSnapshotCleanup, time: '04:00', arg_from_config: %i[app_usage_snapshot cutoff_age_in_days] }, + { name: 'service_usage_snapshot', class: Jobs::Runtime::ServiceUsageSnapshotCleanup, time: '04:30', arg_from_config: %i[service_usage_snapshot cutoff_age_in_days] } ].freeze FREQUENTS = [ diff --git a/lib/cloud_controller/config_schemas/clock_schema.rb b/lib/cloud_controller/config_schemas/clock_schema.rb index 975d832d7d9..751973dad89 100644 --- a/lib/cloud_controller/config_schemas/clock_schema.rb +++ b/lib/cloud_controller/config_schemas/clock_schema.rb @@ -211,6 +211,8 @@ class ClockSchema < VCAP::Config }, service_usage_events: { cutoff_age_in_days: Integer }, + app_usage_snapshot: { cutoff_age_in_days: Integer }, + service_usage_snapshot: { cutoff_age_in_days: Integer }, default_app_ssh_access: bool, allow_app_ssh_access: bool, jobs: { diff --git a/lib/cloud_controller/jobs.rb b/lib/cloud_controller/jobs.rb index 9f39f53d152..81e51d454b7 100644 --- a/lib/cloud_controller/jobs.rb +++ b/lib/cloud_controller/jobs.rb @@ -37,6 +37,10 @@ require 'jobs/runtime/prune_completed_deployments' require 'jobs/runtime/prune_completed_builds' require 'jobs/runtime/prune_excess_app_revisions' +require 'jobs/runtime/app_usage_snapshot_generator_job' +require 'jobs/runtime/app_usage_snapshot_cleanup' +require 'jobs/runtime/service_usage_snapshot_generator_job' +require 'jobs/runtime/service_usage_snapshot_cleanup' require 'jobs/v2/services/service_usage_events_cleanup' diff --git a/lib/cloud_controller/metrics/prometheus_updater.rb b/lib/cloud_controller/metrics/prometheus_updater.rb index fc03f27e68a..8b1b84797a3 100644 --- a/lib/cloud_controller/metrics/prometheus_updater.rb +++ b/lib/cloud_controller/metrics/prometheus_updater.rb @@ -33,7 +33,12 @@ def self.allow_pid_label { type: :gauge, name: :cc_running_tasks_total, docstring: 'Total running tasks', aggregation: :most_recent }, { type: :gauge, name: :cc_running_tasks_memory_bytes, docstring: 'Total memory consumed by running tasks', aggregation: :most_recent }, { type: :gauge, name: :cc_users_total, docstring: 'Number of users', aggregation: :most_recent }, - { type: :gauge, name: :cc_deployments_in_progress_total, docstring: 'Number of in progress deployments', aggregation: :most_recent } + { type: :gauge, name: :cc_deployments_in_progress_total, docstring: 'Number of in progress deployments', aggregation: :most_recent }, + { type: :histogram, name: :cc_app_usage_snapshot_generation_duration_seconds, docstring: 'Time taken to generate app usage snapshots', buckets: DELAYED_JOB_METRIC_BUCKETS }, + { type: :counter, name: :cc_app_usage_snapshot_generation_failures_total, docstring: 'Total number of failed app usage snapshot generations' }, + { type: :histogram, name: :cc_service_usage_snapshot_generation_duration_seconds, docstring: 'Time taken to generate service usage snapshots', + buckets: DELAYED_JOB_METRIC_BUCKETS }, + { type: :counter, name: :cc_service_usage_snapshot_generation_failures_total, docstring: 'Total number of failed service snapshot generations' } ].freeze PUMA_METRICS = [ diff --git a/spec/request/app_usage_snapshots_spec.rb b/spec/request/app_usage_snapshots_spec.rb new file mode 100644 index 00000000000..fc365c756d1 --- /dev/null +++ b/spec/request/app_usage_snapshots_spec.rb @@ -0,0 +1,407 @@ +require 'spec_helper' +require 'request_spec_shared_examples' + +RSpec.describe 'App Usage Snapshots' do + let(:user) { make_user } + let(:admin_header) { admin_headers_for(user) } + let(:org) { VCAP::CloudController::Organization.make } + let(:space) { VCAP::CloudController::Space.make(organization: org) } + + describe 'POST /v3/app_usage/snapshots' do + let(:api_call) { ->(user_headers) { post '/v3/app_usage/snapshots', nil, user_headers } } + + let(:expected_codes_and_responses) do + h = Hash.new { |hash, key| hash[key] = { code: 403 } } + h['admin'] = { code: 202 } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + + context 'when the user is an admin' do + it 'creates a usage snapshot asynchronously' do + post '/v3/app_usage/snapshots', nil, admin_header + + expect(last_response.status).to eq(202) + expect(last_response.headers['Location']).to match(%r{/v3/jobs/}) + + job_guid = last_response.headers['Location'].split('/').last + get "/v3/jobs/#{job_guid}", nil, admin_header + + expect(last_response.status).to eq(200) + job_response = Oj.load(last_response.body) + expect(job_response['operation']).to eq('app_usage_snapshot.generate') + end + + context 'when a snapshot is already in progress' do + before do + VCAP::CloudController::AppUsageSnapshot.create( + guid: 'in-progress-snapshot', + checkpoint_event_guid: nil, + created_at: Time.now.utc, + completed_at: nil, + instance_count: 0, + organization_count: 0, + space_count: 0, + app_count: 0, + chunk_count: 0 + ) + end + + it 'returns 409 Conflict' do + post '/v3/app_usage/snapshots', nil, admin_header + + expect(last_response.status).to eq(409) + expect(last_response).to have_error_message('An app usage snapshot is already being generated') + end + end + + context 'when previous snapshots exist but are all completed' do + before do + # Create several completed snapshots + 3.times do |i| + VCAP::CloudController::AppUsageSnapshot.create( + guid: "completed-snapshot-#{i}", + checkpoint_event_guid: "checkpoint-guid-#{i}", + created_at: Time.now.utc - (i + 1).hours, + completed_at: Time.now.utc - i.hours, + instance_count: 10, + organization_count: 2, + space_count: 3, + app_count: 5, + chunk_count: 1 + ) + end + end + + it 'allows creating a new snapshot' do + post '/v3/app_usage/snapshots', nil, admin_header + + expect(last_response.status).to eq(202) + expect(last_response.headers['Location']).to match(%r{/v3/jobs/}) + end + end + + context 'when a previously in-progress snapshot has been cleaned up' do + it 'allows creating a new snapshot' do + post '/v3/app_usage/snapshots', nil, admin_header + + expect(last_response.status).to eq(202) + end + end + + context 'when there are no running processes (empty foundation)' do + it 'creates a snapshot with zero counts' do + post '/v3/app_usage/snapshots', nil, admin_header + + expect(last_response.status).to eq(202) + + # Execute the job synchronously + job_guid = last_response.headers['Location'].split('/').last + execute_all_jobs(expected_successes: 1, expected_failures: 0) + + # Check job completed + get "/v3/jobs/#{job_guid}", nil, admin_header + expect(last_response.status).to eq(200) + job_response = Oj.load(last_response.body) + expect(job_response['state']).to eq('COMPLETE') + + # Get snapshot and verify zero counts + snapshot_guid = VCAP::CloudController::AppUsageSnapshot.last.guid + get "/v3/app_usage/snapshots/#{snapshot_guid}", nil, admin_header + expect(last_response.status).to eq(200) + + snapshot_response = Oj.load(last_response.body) + expect(snapshot_response['summary']['instance_count']).to eq(0) + expect(snapshot_response['summary']['app_count']).to eq(0) + expect(snapshot_response['summary']['organization_count']).to eq(0) + expect(snapshot_response['summary']['space_count']).to eq(0) + expect(snapshot_response['summary']['chunk_count']).to eq(0) + expect(snapshot_response['completed_at']).not_to be_nil + end + end + end + + context 'when the user is not an admin' do + let(:user_header) { headers_for(user) } + + it 'returns 403 Forbidden' do + post '/v3/app_usage/snapshots', nil, user_header + + expect(last_response.status).to eq(403) + end + end + + context 'when the user is not logged in' do + it 'returns 401 Unauthorized' do + post '/v3/app_usage/snapshots', nil, base_json_headers + + expect(last_response.status).to eq(401) + end + end + end + + describe 'GET /v3/app_usage/snapshots/:guid' do + let!(:snapshot) do + VCAP::CloudController::AppUsageSnapshot.create( + guid: 'test-snapshot-guid', + checkpoint_event_guid: 'checkpoint-event-guid-12345', + checkpoint_event_created_at: Time.now.utc - 1.hour, + created_at: Time.now.utc - 1.hour, + completed_at: Time.now.utc - 59.minutes, + instance_count: 10, + organization_count: 2, + space_count: 3, + app_count: 5, + chunk_count: 3 + ) + end + + let(:api_call) { ->(user_headers) { get "/v3/app_usage/snapshots/#{snapshot.guid}", nil, user_headers } } + + let(:snapshot_json) do + { + guid: snapshot.guid, + created_at: iso8601, + completed_at: iso8601, + checkpoint_event_guid: 'checkpoint-event-guid-12345', + checkpoint_event_created_at: iso8601, + summary: { + instance_count: 10, + app_count: 5, + organization_count: 2, + space_count: 3, + chunk_count: 3 + }, + links: { + self: { href: /#{Regexp.escape("/v3/app_usage/snapshots/#{snapshot.guid}")}/ }, + checkpoint_event: { href: %r{/v3/app_usage_events/checkpoint-event-guid-12345} }, + chunks: { href: /#{Regexp.escape("/v3/app_usage/snapshots/#{snapshot.guid}/chunks")}/ } + } + } + end + + let(:expected_codes_and_responses) do + h = Hash.new { |hash, key| hash[key] = { code: 404 } } + h['admin'] = { code: 200, response_object: snapshot_json } + h['admin_read_only'] = { code: 200, response_object: snapshot_json } + h['global_auditor'] = { code: 200, response_object: snapshot_json } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + + context 'when the snapshot does not exist' do + it 'returns 404' do + get '/v3/app_usage/snapshots/does-not-exist', nil, admin_header + + expect(last_response.status).to eq(404) + expect(last_response).to have_error_message('App usage snapshot not found') + end + end + end + + describe 'GET /v3/app_usage/snapshots' do + let!(:snapshot1) do + VCAP::CloudController::AppUsageSnapshot.create( + guid: 'snapshot-1', + checkpoint_event_guid: 'checkpoint-guid-100', + checkpoint_event_created_at: Time.now.utc - 2.hours, + created_at: Time.now.utc - 2.hours, + completed_at: Time.now.utc - 119.minutes, + instance_count: 5, + organization_count: 1, + space_count: 1, + app_count: 2, + chunk_count: 1 + ) + end + + let!(:snapshot2) do + VCAP::CloudController::AppUsageSnapshot.create( + guid: 'snapshot-2', + checkpoint_event_guid: 'checkpoint-guid-200', + checkpoint_event_created_at: Time.now.utc - 1.hour, + created_at: Time.now.utc - 59.minutes, + completed_at: Time.now.utc - 59.minutes, + instance_count: 10, + organization_count: 2, + space_count: 2, + app_count: 4, + chunk_count: 2 + ) + end + + let(:api_call) { ->(user_headers) { get '/v3/app_usage/snapshots', nil, user_headers } } + + let(:expected_codes_and_responses) do + h = Hash.new { |hash, key| hash[key] = { code: 200, response_objects: [] } } + h['admin'] = { code: 200, response_objects: [hash_including(guid: 'snapshot-1'), hash_including(guid: 'snapshot-2')] } + h['admin_read_only'] = { code: 200, response_objects: [hash_including(guid: 'snapshot-1'), hash_including(guid: 'snapshot-2')] } + h['global_auditor'] = { code: 200, response_objects: [hash_including(guid: 'snapshot-1'), hash_including(guid: 'snapshot-2')] } + h + end + + it_behaves_like 'permissions for list endpoint', ALL_PERMISSIONS + + context 'when the user is an admin' do + it 'returns all snapshots' do + get '/v3/app_usage/snapshots', nil, admin_header + + expect(last_response.status).to eq(200) + response = Oj.load(last_response.body) + expect(response['resources'].length).to eq(2) + expect(response['resources'].pluck('guid')).to contain_exactly('snapshot-1', 'snapshot-2') + end + + it 'supports pagination' do + get '/v3/app_usage/snapshots?per_page=1', nil, admin_header + + expect(last_response.status).to eq(200) + response = Oj.load(last_response.body) + expect(response['resources'].length).to eq(1) + expect(response['pagination']['total_results']).to eq(2) + end + end + end + + describe 'GET /v3/app_usage/snapshots/:guid/chunks' do + let!(:snapshot) do + VCAP::CloudController::AppUsageSnapshot.create( + guid: 'test-snapshot-guid', + checkpoint_event_guid: 'checkpoint-event-guid-12345', + checkpoint_event_created_at: Time.now.utc - 1.hour, + created_at: Time.now.utc - 1.hour, + completed_at: Time.now.utc - 59.minutes, + instance_count: 15, + organization_count: 2, + space_count: 2, + app_count: 4, + chunk_count: 2 + ) + end + + let!(:chunk1) do + VCAP::CloudController::AppUsageSnapshotChunk.create( + app_usage_snapshot_id: snapshot.id, + organization_guid: 'org-1-guid', + organization_name: 'org-1-name', + space_guid: 'space-1-guid', + space_name: 'space-1-name', + chunk_index: 0, + processes: [ + { 'app_guid' => 'app-1-guid', 'app_name' => 'app-1', 'process_guid' => 'process-1-guid', + 'process_type' => 'web', 'instance_count' => 5, 'memory_in_mb_per_instance' => 256, + 'buildpack_guid' => 'bp-guid', 'buildpack_name' => 'ruby_buildpack' }, + { 'app_guid' => 'app-1-guid', 'app_name' => 'app-1', 'process_guid' => 'process-2-guid', + 'process_type' => 'worker', 'instance_count' => 5, 'memory_in_mb_per_instance' => 512, + 'buildpack_guid' => 'bp-guid', 'buildpack_name' => 'ruby_buildpack' } + ] + ) + end + + let!(:chunk2) do + VCAP::CloudController::AppUsageSnapshotChunk.create( + app_usage_snapshot_id: snapshot.id, + organization_guid: 'org-2-guid', + organization_name: 'org-2-name', + space_guid: 'space-2-guid', + space_name: 'space-2-name', + chunk_index: 0, + processes: [ + { 'app_guid' => 'app-2-guid', 'app_name' => 'app-2', 'process_guid' => 'process-3-guid', + 'process_type' => 'web', 'instance_count' => 5, 'memory_in_mb_per_instance' => 1024, + 'buildpack_guid' => nil, 'buildpack_name' => nil } + ] + ) + end + + context 'when the user is an admin' do + it 'returns the chunk details for the snapshot' do + get "/v3/app_usage/snapshots/#{snapshot.guid}/chunks", nil, admin_header + + expect(last_response.status).to eq(200) + response = Oj.load(last_response.body) + expect(response['resources'].length).to eq(2) + expect(response['resources'].pluck('space_guid')).to contain_exactly('space-1-guid', 'space-2-guid') + end + + it 'includes process details with V3-aligned fields in each chunk record' do + get "/v3/app_usage/snapshots/#{snapshot.guid}/chunks", nil, admin_header + + expect(last_response.status).to eq(200) + response = Oj.load(last_response.body) + chunk1_response = response['resources'].find { |r| r['space_guid'] == 'space-1-guid' } + + expect(chunk1_response['organization_guid']).to eq('org-1-guid') + expect(chunk1_response['organization_name']).to eq('org-1-name') + expect(chunk1_response['space_name']).to eq('space-1-name') + expect(chunk1_response['chunk_index']).to eq(0) + expect(chunk1_response['processes'].length).to eq(2) + + process = chunk1_response['processes'].first + expect(process).to include( + 'app_guid' => 'app-1-guid', + 'app_name' => 'app-1', + 'process_guid' => 'process-1-guid', + 'process_type' => 'web', + 'instance_count' => 5, + 'memory_in_mb_per_instance' => 256, + 'buildpack_guid' => 'bp-guid', + 'buildpack_name' => 'ruby_buildpack' + ) + end + + it 'supports pagination' do + get "/v3/app_usage/snapshots/#{snapshot.guid}/chunks?per_page=1", nil, admin_header + + expect(last_response.status).to eq(200) + response = Oj.load(last_response.body) + expect(response['resources'].length).to eq(1) + expect(response['pagination']['total_results']).to eq(2) + end + end + + context 'when the snapshot is still processing' do + let!(:processing_snapshot) do + VCAP::CloudController::AppUsageSnapshot.create( + guid: 'processing-snapshot-guid', + checkpoint_event_guid: nil, + created_at: Time.now.utc, + completed_at: nil, + instance_count: 0, + organization_count: 0, + space_count: 0, + app_count: 0, + chunk_count: 0 + ) + end + + it 'returns 422 Unprocessable Entity' do + get "/v3/app_usage/snapshots/#{processing_snapshot.guid}/chunks", nil, admin_header + + expect(last_response.status).to eq(422) + expect(last_response).to have_error_message('Snapshot is still processing') + end + end + + context 'when the snapshot does not exist' do + it 'returns 404' do + get '/v3/app_usage/snapshots/does-not-exist/chunks', nil, admin_header + + expect(last_response.status).to eq(404) + expect(last_response).to have_error_message('App usage snapshot not found') + end + end + + context 'when the user is not an admin' do + let(:user_header) { headers_for(user) } + + it 'returns 404' do + get "/v3/app_usage/snapshots/#{snapshot.guid}/chunks", nil, user_header + + expect(last_response.status).to eq(404) + end + end + end +end diff --git a/spec/request/service_usage_snapshots_spec.rb b/spec/request/service_usage_snapshots_spec.rb new file mode 100644 index 00000000000..89c72e35713 --- /dev/null +++ b/spec/request/service_usage_snapshots_spec.rb @@ -0,0 +1,290 @@ +require 'spec_helper' + +RSpec.describe 'Service Usage Snapshots' do + let(:user) { make_user } + let(:admin_header) { admin_headers_for(user) } + + describe 'POST /v3/service_usage/snapshots' do + it 'creates a snapshot generation job and returns 202' do + post '/v3/service_usage/snapshots', nil, admin_header + + expect(last_response.status).to eq(202) + expect(last_response.headers['Location']).to match(%r{/v3/jobs/}) + end + + it 'requires admin permissions' do + post '/v3/service_usage/snapshots', nil, headers_for(user) + + expect(last_response.status).to eq(403) + end + + context 'when a snapshot is already in progress' do + before do + VCAP::CloudController::ServiceUsageSnapshot.make(completed_at: nil) + end + + it 'returns 409 conflict' do + post '/v3/service_usage/snapshots', nil, admin_header + + expect(last_response.status).to eq(409) + expect(parsed_response['errors'].first['title']).to match(/ServiceUsageSnapshotGenerationInProgress/) + end + end + + context 'when previous snapshots exist but are all completed' do + before do + # Create several completed snapshots + 3.times do + VCAP::CloudController::ServiceUsageSnapshot.make(completed_at: Time.now.utc) + end + end + + it 'allows creating a new snapshot' do + post '/v3/service_usage/snapshots', nil, admin_header + + expect(last_response.status).to eq(202) + expect(last_response.headers['Location']).to match(%r{/v3/jobs/}) + end + end + + context 'when a previously in-progress snapshot has been cleaned up' do + it 'allows creating a new snapshot' do + post '/v3/service_usage/snapshots', nil, admin_header + + expect(last_response.status).to eq(202) + end + end + + context 'when there are no service instances (empty foundation)' do + it 'creates a snapshot with zero counts' do + post '/v3/service_usage/snapshots', nil, admin_header + + expect(last_response.status).to eq(202) + + # Execute the job synchronously + job_guid = last_response.headers['Location'].split('/').last + execute_all_jobs(expected_successes: 1, expected_failures: 0) + + # Check job completed + get "/v3/jobs/#{job_guid}", nil, admin_header + expect(last_response.status).to eq(200) + job_response = Oj.load(last_response.body) + expect(job_response['state']).to eq('COMPLETE') + + # Get snapshot and verify zero counts + snapshot_guid = VCAP::CloudController::ServiceUsageSnapshot.last.guid + get "/v3/service_usage/snapshots/#{snapshot_guid}", nil, admin_header + expect(last_response.status).to eq(200) + + snapshot_response = Oj.load(last_response.body) + expect(snapshot_response['summary']['service_instance_count']).to eq(0) + expect(snapshot_response['summary']['organization_count']).to eq(0) + expect(snapshot_response['summary']['space_count']).to eq(0) + expect(snapshot_response['summary']['chunk_count']).to eq(0) + expect(snapshot_response['completed_at']).not_to be_nil + end + end + end + + describe 'GET /v3/service_usage/snapshots/:guid' do + let(:snapshot) { VCAP::CloudController::ServiceUsageSnapshot.make(service_instance_count: 10, completed_at: Time.now.utc) } + + it 'returns the snapshot' do + get "/v3/service_usage/snapshots/#{snapshot.guid}", nil, admin_header + + expect(last_response.status).to eq(200) + expect(parsed_response['guid']).to eq(snapshot.guid) + expect(parsed_response['summary']['service_instance_count']).to eq(10) + end + + it 'returns 404 for non-admin users' do + get "/v3/service_usage/snapshots/#{snapshot.guid}", nil, headers_for(user) + + expect(last_response.status).to eq(404) + end + + it 'returns 404 for non-existent snapshot' do + get '/v3/service_usage/snapshots/nonexistent-guid', nil, admin_header + + expect(last_response.status).to eq(404) + end + end + + describe 'GET /v3/service_usage/snapshots' do + let!(:snapshot1) { VCAP::CloudController::ServiceUsageSnapshot.make(service_instance_count: 5, completed_at: Time.now.utc) } + let!(:snapshot2) { VCAP::CloudController::ServiceUsageSnapshot.make(service_instance_count: 10, completed_at: Time.now.utc) } + + it 'lists all snapshots' do + get '/v3/service_usage/snapshots', nil, admin_header + + expect(last_response.status).to eq(200) + expect(parsed_response['pagination']['total_results']).to eq(2) + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(snapshot1.guid, snapshot2.guid) + end + + it 'returns empty list for non-admin users' do + get '/v3/service_usage/snapshots', nil, headers_for(user) + + expect(last_response.status).to eq(200) + expect(parsed_response['pagination']['total_results']).to eq(0) + end + + it 'supports pagination' do + get '/v3/service_usage/snapshots?per_page=1', nil, admin_header + + expect(last_response.status).to eq(200) + expect(parsed_response['pagination']['total_results']).to eq(2) + expect(parsed_response['resources'].length).to eq(1) + end + end + + describe 'GET /v3/service_usage/snapshots/:guid/chunks' do + let!(:snapshot) do + VCAP::CloudController::ServiceUsageSnapshot.create( + guid: 'test-service-snapshot-guid', + checkpoint_event_guid: 'checkpoint-event-guid-12345', + checkpoint_event_created_at: Time.now.utc - 1.hour, + created_at: Time.now.utc - 1.hour, + completed_at: Time.now.utc - 59.minutes, + service_instance_count: 5, + organization_count: 2, + space_count: 2, + chunk_count: 2 + ) + end + + let!(:chunk1) do + VCAP::CloudController::ServiceUsageSnapshotChunk.create( + service_usage_snapshot_id: snapshot.id, + organization_guid: 'org-1-guid', + organization_name: 'org-1-name', + space_guid: 'space-1-guid', + space_name: 'space-1-name', + chunk_index: 0, + service_instances: [ + { 'service_instance_guid' => 'si-1', 'service_instance_name' => 'my-db', 'service_instance_type' => 'managed', + 'service_plan_guid' => 'plan-1', 'service_plan_name' => 'standard', + 'service_offering_guid' => 'svc-1', 'service_offering_name' => 'mysql', + 'service_broker_guid' => 'broker-1', 'service_broker_name' => 'my-broker' }, + { 'service_instance_guid' => 'si-2', 'service_instance_name' => 'my-cache', 'service_instance_type' => 'managed', + 'service_plan_guid' => 'plan-2', 'service_plan_name' => 'premium', + 'service_offering_guid' => 'svc-2', 'service_offering_name' => 'redis', + 'service_broker_guid' => 'broker-1', 'service_broker_name' => 'my-broker' }, + { 'service_instance_guid' => 'si-3', 'service_instance_name' => 'my-creds', 'service_instance_type' => 'user_provided', + 'service_plan_guid' => nil, 'service_plan_name' => nil, + 'service_offering_guid' => nil, 'service_offering_name' => nil, + 'service_broker_guid' => nil, 'service_broker_name' => nil } + ] + ) + end + + let!(:chunk2) do + VCAP::CloudController::ServiceUsageSnapshotChunk.create( + service_usage_snapshot_id: snapshot.id, + organization_guid: 'org-2-guid', + organization_name: 'org-2-name', + space_guid: 'space-2-guid', + space_name: 'space-2-name', + chunk_index: 0, + service_instances: [ + { 'service_instance_guid' => 'si-4', 'service_instance_name' => 'other-db', 'service_instance_type' => 'managed', + 'service_plan_guid' => 'plan-3', 'service_plan_name' => 'enterprise', + 'service_offering_guid' => 'svc-1', 'service_offering_name' => 'mysql', + 'service_broker_guid' => 'broker-2', 'service_broker_name' => 'other-broker' }, + { 'service_instance_guid' => 'si-5', 'service_instance_name' => 'other-cache', 'service_instance_type' => 'managed', + 'service_plan_guid' => 'plan-4', 'service_plan_name' => 'basic', + 'service_offering_guid' => 'svc-2', 'service_offering_name' => 'redis', + 'service_broker_guid' => 'broker-2', 'service_broker_name' => 'other-broker' } + ] + ) + end + + context 'when the user is an admin' do + it 'returns the chunk details for the snapshot' do + get "/v3/service_usage/snapshots/#{snapshot.guid}/chunks", nil, admin_header + + expect(last_response.status).to eq(200) + expect(parsed_response['resources'].length).to eq(2) + expect(parsed_response['resources'].pluck('space_guid')).to contain_exactly('space-1-guid', 'space-2-guid') + end + + it 'includes service instance details with V3-aligned fields in each chunk record' do + get "/v3/service_usage/snapshots/#{snapshot.guid}/chunks", nil, admin_header + + expect(last_response.status).to eq(200) + chunk1_response = parsed_response['resources'].find { |r| r['space_guid'] == 'space-1-guid' } + + expect(chunk1_response['organization_guid']).to eq('org-1-guid') + expect(chunk1_response['organization_name']).to eq('org-1-name') + expect(chunk1_response['space_name']).to eq('space-1-name') + expect(chunk1_response['chunk_index']).to eq(0) + expect(chunk1_response['service_instances'].length).to eq(3) + + managed_instance = chunk1_response['service_instances'].first + expect(managed_instance).to include( + 'service_instance_guid' => 'si-1', + 'service_instance_name' => 'my-db', + 'service_instance_type' => 'managed', + 'service_plan_guid' => 'plan-1', + 'service_plan_name' => 'standard', + 'service_offering_guid' => 'svc-1', + 'service_offering_name' => 'mysql', + 'service_broker_guid' => 'broker-1', + 'service_broker_name' => 'my-broker' + ) + + user_provided = chunk1_response['service_instances'].last + expect(user_provided).to include( + 'service_instance_type' => 'user_provided', + 'service_plan_guid' => nil, + 'service_broker_guid' => nil + ) + end + + it 'supports pagination' do + get "/v3/service_usage/snapshots/#{snapshot.guid}/chunks?per_page=1", nil, admin_header + + expect(last_response.status).to eq(200) + expect(parsed_response['resources'].length).to eq(1) + expect(parsed_response['pagination']['total_results']).to eq(2) + end + end + + context 'when the snapshot is still processing' do + let!(:processing_snapshot) do + VCAP::CloudController::ServiceUsageSnapshot.create( + guid: 'processing-service-snapshot-guid', + checkpoint_event_guid: nil, + created_at: Time.now.utc, + completed_at: nil, + service_instance_count: 0, + organization_count: 0, + space_count: 0, + chunk_count: 0 + ) + end + + it 'returns 422 Unprocessable Entity' do + get "/v3/service_usage/snapshots/#{processing_snapshot.guid}/chunks", nil, admin_header + + expect(last_response.status).to eq(422) + end + end + + context 'when the snapshot does not exist' do + it 'returns 404' do + get '/v3/service_usage/snapshots/does-not-exist/chunks', nil, admin_header + + expect(last_response.status).to eq(404) + end + end + + context 'when the user is not an admin' do + it 'returns 404' do + get "/v3/service_usage/snapshots/#{snapshot.guid}/chunks", nil, headers_for(user) + + expect(last_response.status).to eq(404) + end + end + end +end diff --git a/spec/support/fakes/blueprints.rb b/spec/support/fakes/blueprints.rb index 86a528857b0..60cf73516c9 100644 --- a/spec/support/fakes/blueprints.rb +++ b/spec/support/fakes/blueprints.rb @@ -891,4 +891,45 @@ module VCAP::CloudController TestModelRedact.blueprint do end + + AppUsageSnapshot.blueprint do + guid { Sham.guid } + checkpoint_event_guid { Sham.guid } + created_at { Time.now.utc } + instance_count { 0 } + organization_count { 0 } + space_count { 0 } + app_count { 0 } + chunk_count { 0 } + end + + AppUsageSnapshotChunk.blueprint do + app_usage_snapshot { AppUsageSnapshot.make } + organization_guid { Sham.guid } + organization_name { Sham.name } + space_guid { Sham.guid } + space_name { Sham.name } + chunk_index { 0 } + processes { [] } + end + + ServiceUsageSnapshot.blueprint do + guid { Sham.guid } + checkpoint_event_guid { Sham.guid } + created_at { Time.now.utc } + service_instance_count { 0 } + organization_count { 0 } + space_count { 0 } + chunk_count { 0 } + end + + ServiceUsageSnapshotChunk.blueprint do + service_usage_snapshot { ServiceUsageSnapshot.make } + organization_guid { Sham.guid } + organization_name { Sham.name } + space_guid { Sham.guid } + space_name { Sham.name } + chunk_index { 0 } + service_instances { [] } + end end diff --git a/spec/unit/jobs/runtime/app_usage_snapshot_cleanup_spec.rb b/spec/unit/jobs/runtime/app_usage_snapshot_cleanup_spec.rb new file mode 100644 index 00000000000..5935ac2d933 --- /dev/null +++ b/spec/unit/jobs/runtime/app_usage_snapshot_cleanup_spec.rb @@ -0,0 +1,129 @@ +require 'spec_helper' + +module VCAP::CloudController + module Jobs::Runtime + RSpec.describe AppUsageSnapshotCleanup, job_context: :worker do + let(:cutoff_age_in_days) { 30 } + let(:logger) { double(Steno::Logger, info: nil) } + + subject(:job) do + AppUsageSnapshotCleanup.new(cutoff_age_in_days) + end + + before do + allow(Steno).to receive(:logger).and_return(logger) + end + + it { is_expected.to be_a_valid_job } + + it 'can be enqueued' do + expect(job).to respond_to(:perform) + end + + describe '#perform' do + context 'with old completed snapshots' do + let!(:old_completed_snapshot) do + AppUsageSnapshot.make( + created_at: (cutoff_age_in_days + 1).days.ago, + completed_at: cutoff_age_in_days.days.ago + ) + end + + let!(:recent_completed_snapshot) do + AppUsageSnapshot.make( + created_at: (cutoff_age_in_days - 1).days.ago, + completed_at: (cutoff_age_in_days - 1).days.ago + ) + end + + it 'deletes old completed snapshots past the retention period' do + expect do + job.perform + end.to change(old_completed_snapshot, :exists?).to(false) + end + + it 'keeps recent completed snapshots' do + expect do + job.perform + end.not_to change(recent_completed_snapshot, :exists?).from(true) + end + end + + context 'with stale in-progress snapshots' do + let!(:stale_in_progress_snapshot) do + AppUsageSnapshot.make( + created_at: 2.hours.ago, + completed_at: nil + ) + end + + let!(:recent_in_progress_snapshot) do + AppUsageSnapshot.make( + created_at: 30.minutes.ago, + completed_at: nil + ) + end + + it 'deletes stale in-progress snapshots (older than 1 hour)' do + expect do + job.perform + end.to change(stale_in_progress_snapshot, :exists?).to(false) + end + + it 'keeps recent in-progress snapshots (less than 1 hour old)' do + expect do + job.perform + end.not_to change(recent_in_progress_snapshot, :exists?).from(true) + end + end + + context 'with a mix of snapshot states' do + let!(:old_completed) do + AppUsageSnapshot.make( + created_at: 60.days.ago, + completed_at: 60.days.ago + ) + end + + let!(:stale_in_progress) do + AppUsageSnapshot.make( + created_at: 2.hours.ago, + completed_at: nil + ) + end + + let!(:recent_completed) do + AppUsageSnapshot.make( + created_at: 1.day.ago, + completed_at: 1.day.ago + ) + end + + let!(:recent_in_progress) do + AppUsageSnapshot.make( + created_at: 30.minutes.ago, + completed_at: nil + ) + end + + it 'deletes old completed and stale in-progress, keeps recent ones' do + expect { job.perform }.to change(AppUsageSnapshot, :count).by(-2) + + expect(old_completed.exists?).to be false + expect(stale_in_progress.exists?).to be false + expect(recent_completed.exists?).to be true + expect(recent_in_progress.exists?).to be true + end + end + + it 'knows its job name' do + expect(job.job_name_in_configuration).to equal(:app_usage_snapshot_cleanup) + end + + it 'has max_attempts of 1' do + expect(job.max_attempts).to eq(1) + end + end + end + end +end diff --git a/spec/unit/jobs/runtime/app_usage_snapshot_generator_job_spec.rb b/spec/unit/jobs/runtime/app_usage_snapshot_generator_job_spec.rb new file mode 100644 index 00000000000..5d500bb19b5 --- /dev/null +++ b/spec/unit/jobs/runtime/app_usage_snapshot_generator_job_spec.rb @@ -0,0 +1,104 @@ +require 'spec_helper' + +module VCAP::CloudController + module Jobs + module Runtime + RSpec.describe AppUsageSnapshotGeneratorJob do + let(:snapshot) { AppUsageSnapshot.make(instance_count: 100, completed_at: nil) } + subject(:job) { AppUsageSnapshotGeneratorJob.new(snapshot.guid) } + + let(:repository) { instance_double(Repositories::AppUsageSnapshotRepository) } + + before do + allow(Repositories::AppUsageSnapshotRepository).to receive(:new).and_return(repository) + end + + describe '#initialize' do + it 'sets resource_guid from the constructor argument' do + expect(job.resource_guid).to eq(snapshot.guid) + end + end + + describe '#perform' do + before do + allow(repository).to receive(:populate_snapshot!) + end + + it 'fetches the snapshot and calls the repository to populate it' do + expect(repository).to receive(:populate_snapshot!).with(snapshot) + + job.perform + end + + it 'logs the start and completion' do + allow(repository).to receive(:populate_snapshot!) do |s| + s.update(instance_count: 100, completed_at: Time.now.utc) + end + + logger = instance_double(Steno::Logger) + allow(Steno).to receive(:logger).with('cc.background').and_return(logger) + + expect(logger).to receive(:info).with("Starting usage snapshot generation for snapshot #{snapshot.guid}") + expect(logger).to receive(:info).with("Usage snapshot #{snapshot.guid} completed: 100 instances") + + job.perform + end + + context 'when snapshot is not found' do + subject(:job) { AppUsageSnapshotGeneratorJob.new('non-existent-guid') } + + it 'raises an error' do + expect { job.perform }.to raise_error(RuntimeError, /Snapshot not found: non-existent-guid/) + end + end + + context 'when population fails' do + let(:error) { StandardError.new('Database connection failed') } + + before do + allow(repository).to receive(:populate_snapshot!).and_raise(error) + end + + it 'logs the error with backtrace' do + logger = instance_double(Steno::Logger) + allow(Steno).to receive(:logger).with('cc.background').and_return(logger) + + expect(logger).to receive(:info).with("Starting usage snapshot generation for snapshot #{snapshot.guid}") + expect(logger).to receive(:error).with(/Usage snapshot generation failed: Database connection failed/) + + expect { job.perform }.to raise_error(StandardError, 'Database connection failed') + end + + it 're-raises the error' do + expect { job.perform }.to raise_error(StandardError, 'Database connection failed') + end + end + end + + describe '#job_name_in_configuration' do + it 'returns the correct job name' do + expect(job.job_name_in_configuration).to eq(:app_usage_snapshot_generator) + end + end + + describe '#max_attempts' do + it 'returns 1' do + expect(job.max_attempts).to eq(1) + end + end + + describe '#resource_type' do + it 'returns app_usage_snapshot' do + expect(job.resource_type).to eq('app_usage_snapshot') + end + end + + describe '#display_name' do + it 'returns the display name' do + expect(job.display_name).to eq('app_usage_snapshot.generate') + end + end + end + end + end +end diff --git a/spec/unit/jobs/runtime/service_usage_snapshot_cleanup_spec.rb b/spec/unit/jobs/runtime/service_usage_snapshot_cleanup_spec.rb new file mode 100644 index 00000000000..a02ea92eda0 --- /dev/null +++ b/spec/unit/jobs/runtime/service_usage_snapshot_cleanup_spec.rb @@ -0,0 +1,129 @@ +require 'spec_helper' + +module VCAP::CloudController + module Jobs::Runtime + RSpec.describe ServiceUsageSnapshotCleanup, job_context: :worker do + let(:cutoff_age_in_days) { 30 } + let(:logger) { double(Steno::Logger, info: nil) } + + subject(:job) do + ServiceUsageSnapshotCleanup.new(cutoff_age_in_days) + end + + before do + allow(Steno).to receive(:logger).and_return(logger) + end + + it { is_expected.to be_a_valid_job } + + it 'can be enqueued' do + expect(job).to respond_to(:perform) + end + + describe '#perform' do + context 'with old completed snapshots' do + let!(:old_completed_snapshot) do + ServiceUsageSnapshot.make( + created_at: (cutoff_age_in_days + 1).days.ago, + completed_at: cutoff_age_in_days.days.ago + ) + end + + let!(:recent_completed_snapshot) do + ServiceUsageSnapshot.make( + created_at: (cutoff_age_in_days - 1).days.ago, + completed_at: (cutoff_age_in_days - 1).days.ago + ) + end + + it 'deletes old completed snapshots past the retention period' do + expect do + job.perform + end.to change(old_completed_snapshot, :exists?).to(false) + end + + it 'keeps recent completed snapshots' do + expect do + job.perform + end.not_to change(recent_completed_snapshot, :exists?).from(true) + end + end + + context 'with stale in-progress snapshots' do + let!(:stale_in_progress_snapshot) do + ServiceUsageSnapshot.make( + created_at: 2.hours.ago, + completed_at: nil + ) + end + + let!(:recent_in_progress_snapshot) do + ServiceUsageSnapshot.make( + created_at: 30.minutes.ago, + completed_at: nil + ) + end + + it 'deletes stale in-progress snapshots (older than 1 hour)' do + expect do + job.perform + end.to change(stale_in_progress_snapshot, :exists?).to(false) + end + + it 'keeps recent in-progress snapshots (less than 1 hour old)' do + expect do + job.perform + end.not_to change(recent_in_progress_snapshot, :exists?).from(true) + end + end + + context 'with a mix of snapshot states' do + let!(:old_completed) do + ServiceUsageSnapshot.make( + created_at: 60.days.ago, + completed_at: 60.days.ago + ) + end + + let!(:stale_in_progress) do + ServiceUsageSnapshot.make( + created_at: 2.hours.ago, + completed_at: nil + ) + end + + let!(:recent_completed) do + ServiceUsageSnapshot.make( + created_at: 1.day.ago, + completed_at: 1.day.ago + ) + end + + let!(:recent_in_progress) do + ServiceUsageSnapshot.make( + created_at: 30.minutes.ago, + completed_at: nil + ) + end + + it 'deletes old completed and stale in-progress, keeps recent ones' do + expect { job.perform }.to change(ServiceUsageSnapshot, :count).by(-2) + + expect(old_completed.exists?).to be false + expect(stale_in_progress.exists?).to be false + expect(recent_completed.exists?).to be true + expect(recent_in_progress.exists?).to be true + end + end + + it 'knows its job name' do + expect(job.job_name_in_configuration).to equal(:service_usage_snapshot_cleanup) + end + + it 'has max_attempts of 1' do + expect(job.max_attempts).to eq(1) + end + end + end + end +end diff --git a/spec/unit/jobs/runtime/service_usage_snapshot_generator_job_spec.rb b/spec/unit/jobs/runtime/service_usage_snapshot_generator_job_spec.rb new file mode 100644 index 00000000000..d91c5eecd58 --- /dev/null +++ b/spec/unit/jobs/runtime/service_usage_snapshot_generator_job_spec.rb @@ -0,0 +1,104 @@ +require 'spec_helper' + +module VCAP::CloudController + module Jobs + module Runtime + RSpec.describe ServiceUsageSnapshotGeneratorJob do + let(:snapshot) { ServiceUsageSnapshot.make(service_instance_count: 50, completed_at: nil) } + subject(:job) { ServiceUsageSnapshotGeneratorJob.new(snapshot.guid) } + + let(:repository) { instance_double(Repositories::ServiceUsageSnapshotRepository) } + + before do + allow(Repositories::ServiceUsageSnapshotRepository).to receive(:new).and_return(repository) + end + + describe '#initialize' do + it 'sets resource_guid from the constructor argument' do + expect(job.resource_guid).to eq(snapshot.guid) + end + end + + describe '#perform' do + before do + allow(repository).to receive(:populate_snapshot!) + end + + it 'fetches the snapshot and calls the repository to populate it' do + expect(repository).to receive(:populate_snapshot!).with(snapshot) + + job.perform + end + + it 'logs the start and completion' do + allow(repository).to receive(:populate_snapshot!) do |s| + s.update(service_instance_count: 50, completed_at: Time.now.utc) + end + + logger = instance_double(Steno::Logger) + allow(Steno).to receive(:logger).with('cc.background').and_return(logger) + + expect(logger).to receive(:info).with("Starting service usage snapshot generation for snapshot #{snapshot.guid}") + expect(logger).to receive(:info).with("Service usage snapshot #{snapshot.guid} completed: 50 service instances") + + job.perform + end + + context 'when snapshot is not found' do + subject(:job) { ServiceUsageSnapshotGeneratorJob.new('non-existent-guid') } + + it 'raises an error' do + expect { job.perform }.to raise_error(RuntimeError, /Snapshot not found: non-existent-guid/) + end + end + + context 'when population fails' do + let(:error) { StandardError.new('Database connection failed') } + + before do + allow(repository).to receive(:populate_snapshot!).and_raise(error) + end + + it 'logs the error with backtrace' do + logger = instance_double(Steno::Logger) + allow(Steno).to receive(:logger).with('cc.background').and_return(logger) + + expect(logger).to receive(:info).with("Starting service usage snapshot generation for snapshot #{snapshot.guid}") + expect(logger).to receive(:error).with(/Service usage snapshot generation failed: Database connection failed/) + + expect { job.perform }.to raise_error(StandardError, 'Database connection failed') + end + + it 're-raises the error' do + expect { job.perform }.to raise_error(StandardError, 'Database connection failed') + end + end + end + + describe '#job_name_in_configuration' do + it 'returns the correct job name' do + expect(job.job_name_in_configuration).to eq(:service_usage_snapshot_generator) + end + end + + describe '#max_attempts' do + it 'returns 1' do + expect(job.max_attempts).to eq(1) + end + end + + describe '#resource_type' do + it 'returns service_usage_snapshot' do + expect(job.resource_type).to eq('service_usage_snapshot') + end + end + + describe '#display_name' do + it 'returns the display name' do + expect(job.display_name).to eq('service_usage_snapshot.generate') + end + end + end + end + end +end diff --git a/spec/unit/lib/cloud_controller/clock/scheduler_spec.rb b/spec/unit/lib/cloud_controller/clock/scheduler_spec.rb index 20d0e49b5bd..4efbc95f1ad 100644 --- a/spec/unit/lib/cloud_controller/clock/scheduler_spec.rb +++ b/spec/unit/lib/cloud_controller/clock/scheduler_spec.rb @@ -28,7 +28,9 @@ module VCAP::CloudController diego_sync: { frequency_in_seconds: 30 }, max_retained_deployments_per_app: 15, max_retained_builds_per_app: 15, - max_retained_revisions_per_app: 15 + max_retained_revisions_per_app: 15, + app_usage_snapshot: { cutoff_age_in_days: 30 }, + service_usage_snapshot: { cutoff_age_in_days: 30 } ) end @@ -55,6 +57,7 @@ module VCAP::CloudController expect(Clockwork).to have_received(:run) end + # rubocop:disable RSpec/MultipleExpectations it 'schedules cleanup for all daily jobs' do allow(clock).to receive(:schedule_frequent_worker_job) allow(clock).to receive(:schedule_frequent_inline_job) @@ -131,8 +134,21 @@ module VCAP::CloudController expect(block.call).to be_instance_of(Jobs::Runtime::PruneExcessAppRevisions) end + expect(clock).to receive(:schedule_daily_job) do |args, &block| + expect(args).to eql(name: 'app_usage_snapshot', at: '04:00', priority: 0) + expect(Jobs::Runtime::AppUsageSnapshotCleanup).to receive(:new).with(30).and_call_original + expect(block.call).to be_instance_of(Jobs::Runtime::AppUsageSnapshotCleanup) + end + + expect(clock).to receive(:schedule_daily_job) do |args, &block| + expect(args).to eql(name: 'service_usage_snapshot', at: '04:30', priority: 0) + expect(Jobs::Runtime::ServiceUsageSnapshotCleanup).to receive(:new).with(30).and_call_original + expect(block.call).to be_instance_of(Jobs::Runtime::ServiceUsageSnapshotCleanup) + end + schedule.start end + # rubocop:enable RSpec/MultipleExpectations it 'schedules the frequent worker jobs' do allow(clock).to receive(:schedule_daily_job) diff --git a/spec/unit/models/runtime/app_usage_snapshot_chunk_spec.rb b/spec/unit/models/runtime/app_usage_snapshot_chunk_spec.rb new file mode 100644 index 00000000000..6d5060f811c --- /dev/null +++ b/spec/unit/models/runtime/app_usage_snapshot_chunk_spec.rb @@ -0,0 +1,143 @@ +require 'spec_helper' + +module VCAP::CloudController + RSpec.describe AppUsageSnapshotChunk do + describe 'associations' do + it 'belongs to app_usage_snapshot' do + snapshot = AppUsageSnapshot.make + chunk = AppUsageSnapshotChunk.make(app_usage_snapshot: snapshot) + + expect(chunk.app_usage_snapshot).to eq(snapshot) + end + end + + describe 'validations' do + it 'validates presence of app_usage_snapshot_id' do + chunk = AppUsageSnapshotChunk.new( + organization_guid: 'org-guid', + space_guid: 'space-guid', + chunk_index: 0 + ) + chunk.validate + expect(chunk.errors.on(:app_usage_snapshot_id)).to eq([:presence]) + end + + it 'validates presence of organization_guid' do + snapshot = AppUsageSnapshot.make + chunk = AppUsageSnapshotChunk.new( + app_usage_snapshot_id: snapshot.id, + space_guid: 'space-guid', + chunk_index: 0 + ) + chunk.validate + expect(chunk.errors.on(:organization_guid)).to eq([:presence]) + end + + it 'validates presence of space_guid' do + snapshot = AppUsageSnapshot.make + chunk = AppUsageSnapshotChunk.new( + app_usage_snapshot_id: snapshot.id, + organization_guid: 'org-guid', + chunk_index: 0 + ) + chunk.validate + expect(chunk.errors.on(:space_guid)).to eq([:presence]) + end + + it 'validates presence of chunk_index' do + snapshot = AppUsageSnapshot.make + chunk = AppUsageSnapshotChunk.new( + app_usage_snapshot_id: snapshot.id, + organization_guid: 'org-guid', + space_guid: 'space-guid' + ) + chunk.validate + expect(chunk.errors.on(:chunk_index)).to eq([:presence]) + end + end + + describe 'processes serialization' do + it 'serializes and deserializes processes as JSON' do + snapshot = AppUsageSnapshot.make + processes = [ + { 'app_guid' => 'app-1', 'process_type' => 'web', 'instances' => 3 }, + { 'app_guid' => 'app-2', 'process_type' => 'worker', 'instances' => 2 } + ] + + chunk = AppUsageSnapshotChunk.create( + app_usage_snapshot: snapshot, + organization_guid: 'org-guid', + space_guid: 'space-guid', + chunk_index: 0, + processes: processes + ) + + chunk.reload + expect(chunk.processes).to eq(processes) + end + + it 'handles nil processes (column is nullable)' do + snapshot = AppUsageSnapshot.make + chunk = AppUsageSnapshotChunk.create( + app_usage_snapshot: snapshot, + organization_guid: 'org-guid', + space_guid: 'space-guid', + chunk_index: 0, + processes: nil + ) + + chunk.reload + expect(chunk.processes).to be_nil + end + + it 'handles empty array' do + snapshot = AppUsageSnapshot.make + chunk = AppUsageSnapshotChunk.create( + app_usage_snapshot: snapshot, + organization_guid: 'org-guid', + space_guid: 'space-guid', + chunk_index: 0, + processes: [] + ) + + chunk.reload + expect(chunk.processes).to eq([]) + end + end + + describe 'cascade delete' do + it 'deletes chunk records when snapshot is deleted' do + snapshot = AppUsageSnapshot.make + AppUsageSnapshotChunk.make(app_usage_snapshot: snapshot, space_guid: 'space-1', chunk_index: 0) + AppUsageSnapshotChunk.make(app_usage_snapshot: snapshot, space_guid: 'space-2', chunk_index: 0) + + expect(AppUsageSnapshotChunk.count).to eq(2) + + snapshot.destroy + + expect(AppUsageSnapshotChunk.count).to eq(0) + end + end + + describe 'multiple chunks per space' do + it 'allows multiple chunks for the same space with different chunk_index' do + snapshot = AppUsageSnapshot.make + + chunk1 = AppUsageSnapshotChunk.make( + app_usage_snapshot: snapshot, + space_guid: 'space-1', + chunk_index: 0 + ) + + chunk2 = AppUsageSnapshotChunk.make( + app_usage_snapshot: snapshot, + space_guid: 'space-1', + chunk_index: 1 + ) + + expect(snapshot.app_usage_snapshot_chunks.count).to eq(2) + expect(snapshot.app_usage_snapshot_chunks).to contain_exactly(chunk1, chunk2) + end + end + end +end diff --git a/spec/unit/models/runtime/app_usage_snapshot_spec.rb b/spec/unit/models/runtime/app_usage_snapshot_spec.rb new file mode 100644 index 00000000000..da3a69c6e42 --- /dev/null +++ b/spec/unit/models/runtime/app_usage_snapshot_spec.rb @@ -0,0 +1,93 @@ +require 'spec_helper' + +module VCAP::CloudController + RSpec.describe AppUsageSnapshot do + describe 'associations' do + it 'has many app_usage_snapshot_chunks' do + snapshot = AppUsageSnapshot.make + chunk1 = AppUsageSnapshotChunk.make(app_usage_snapshot: snapshot, space_guid: 'space-1', chunk_index: 0) + chunk2 = AppUsageSnapshotChunk.make(app_usage_snapshot: snapshot, space_guid: 'space-2', chunk_index: 0) + + expect(snapshot.app_usage_snapshot_chunks).to contain_exactly(chunk1, chunk2) + end + end + + describe 'validations' do + it 'allows nil checkpoint_event_guid (for placeholder snapshots)' do + snapshot = AppUsageSnapshot.make + snapshot.checkpoint_event_guid = nil + expect(snapshot).to be_valid + end + + it 'validates presence of created_at' do + snapshot = AppUsageSnapshot.new( + guid: SecureRandom.uuid, + instance_count: 0, + organization_count: 0, + space_count: 0, + app_count: 0, + chunk_count: 0 + ) + snapshot.created_at = nil + snapshot.validate + expect(snapshot.errors.on(:created_at)).to eq([:presence]) + end + + it 'validates presence of app_count' do + snapshot = AppUsageSnapshot.new( + guid: SecureRandom.uuid, + created_at: Time.now.utc, + instance_count: 0, + organization_count: 0, + space_count: 0, + chunk_count: 0 + ) + snapshot.app_count = nil + snapshot.validate + expect(snapshot.errors.on(:app_count)).to eq([:presence]) + end + + it 'validates presence of chunk_count' do + snapshot = AppUsageSnapshot.new( + guid: SecureRandom.uuid, + created_at: Time.now.utc, + instance_count: 0, + organization_count: 0, + space_count: 0, + app_count: 0 + ) + snapshot.chunk_count = nil + snapshot.validate + expect(snapshot.errors.on(:chunk_count)).to eq([:presence]) + end + end + + describe '#processing?' do + it 'returns true when completed_at is nil' do + snapshot = AppUsageSnapshot.make + snapshot.completed_at = nil + expect(snapshot.processing?).to be true + end + + it 'returns false when completed_at is set' do + snapshot = AppUsageSnapshot.make + snapshot.completed_at = Time.now.utc + expect(snapshot.processing?).to be false + end + end + + describe '#complete?' do + it 'returns false when completed_at is nil' do + snapshot = AppUsageSnapshot.make + snapshot.completed_at = nil + expect(snapshot.complete?).to be false + end + + it 'returns true when completed_at is set' do + snapshot = AppUsageSnapshot.make + snapshot.completed_at = Time.now.utc + expect(snapshot.complete?).to be true + end + end + end +end diff --git a/spec/unit/models/runtime/service_usage_snapshot_chunk_spec.rb b/spec/unit/models/runtime/service_usage_snapshot_chunk_spec.rb new file mode 100644 index 00000000000..d375dad26dc --- /dev/null +++ b/spec/unit/models/runtime/service_usage_snapshot_chunk_spec.rb @@ -0,0 +1,143 @@ +require 'spec_helper' + +module VCAP::CloudController + RSpec.describe ServiceUsageSnapshotChunk do + describe 'associations' do + it 'belongs to service_usage_snapshot' do + snapshot = ServiceUsageSnapshot.make + chunk = ServiceUsageSnapshotChunk.make(service_usage_snapshot: snapshot) + + expect(chunk.service_usage_snapshot).to eq(snapshot) + end + end + + describe 'validations' do + it 'validates presence of service_usage_snapshot_id' do + chunk = ServiceUsageSnapshotChunk.new( + organization_guid: 'org-guid', + space_guid: 'space-guid', + chunk_index: 0 + ) + chunk.validate + expect(chunk.errors.on(:service_usage_snapshot_id)).to eq([:presence]) + end + + it 'validates presence of organization_guid' do + snapshot = ServiceUsageSnapshot.make + chunk = ServiceUsageSnapshotChunk.new( + service_usage_snapshot_id: snapshot.id, + space_guid: 'space-guid', + chunk_index: 0 + ) + chunk.validate + expect(chunk.errors.on(:organization_guid)).to eq([:presence]) + end + + it 'validates presence of space_guid' do + snapshot = ServiceUsageSnapshot.make + chunk = ServiceUsageSnapshotChunk.new( + service_usage_snapshot_id: snapshot.id, + organization_guid: 'org-guid', + chunk_index: 0 + ) + chunk.validate + expect(chunk.errors.on(:space_guid)).to eq([:presence]) + end + + it 'validates presence of chunk_index' do + snapshot = ServiceUsageSnapshot.make + chunk = ServiceUsageSnapshotChunk.new( + service_usage_snapshot_id: snapshot.id, + organization_guid: 'org-guid', + space_guid: 'space-guid' + ) + chunk.validate + expect(chunk.errors.on(:chunk_index)).to eq([:presence]) + end + end + + describe 'service_instances serialization' do + it 'serializes and deserializes service_instances as JSON' do + snapshot = ServiceUsageSnapshot.make + service_instances = [ + { 'guid' => 'si-1', 'name' => 'my-db', 'type' => 'managed' }, + { 'guid' => 'si-2', 'name' => 'my-cache', 'type' => 'user_provided' } + ] + + chunk = ServiceUsageSnapshotChunk.create( + service_usage_snapshot: snapshot, + organization_guid: 'org-guid', + space_guid: 'space-guid', + chunk_index: 0, + service_instances: service_instances + ) + + chunk.reload + expect(chunk.service_instances).to eq(service_instances) + end + + it 'handles nil service_instances (column is nullable)' do + snapshot = ServiceUsageSnapshot.make + chunk = ServiceUsageSnapshotChunk.create( + service_usage_snapshot: snapshot, + organization_guid: 'org-guid', + space_guid: 'space-guid', + chunk_index: 0, + service_instances: nil + ) + + chunk.reload + expect(chunk.service_instances).to be_nil + end + + it 'handles empty array' do + snapshot = ServiceUsageSnapshot.make + chunk = ServiceUsageSnapshotChunk.create( + service_usage_snapshot: snapshot, + organization_guid: 'org-guid', + space_guid: 'space-guid', + chunk_index: 0, + service_instances: [] + ) + + chunk.reload + expect(chunk.service_instances).to eq([]) + end + end + + describe 'cascade delete' do + it 'deletes chunk records when snapshot is deleted' do + snapshot = ServiceUsageSnapshot.make + ServiceUsageSnapshotChunk.make(service_usage_snapshot: snapshot, space_guid: 'space-1', chunk_index: 0) + ServiceUsageSnapshotChunk.make(service_usage_snapshot: snapshot, space_guid: 'space-2', chunk_index: 0) + + expect(ServiceUsageSnapshotChunk.count).to eq(2) + + snapshot.destroy + + expect(ServiceUsageSnapshotChunk.count).to eq(0) + end + end + + describe 'multiple chunks per space' do + it 'allows multiple chunks for the same space with different chunk_index' do + snapshot = ServiceUsageSnapshot.make + + chunk1 = ServiceUsageSnapshotChunk.make( + service_usage_snapshot: snapshot, + space_guid: 'space-1', + chunk_index: 0 + ) + + chunk2 = ServiceUsageSnapshotChunk.make( + service_usage_snapshot: snapshot, + space_guid: 'space-1', + chunk_index: 1 + ) + + expect(snapshot.service_usage_snapshot_chunks.count).to eq(2) + expect(snapshot.service_usage_snapshot_chunks).to contain_exactly(chunk1, chunk2) + end + end + end +end diff --git a/spec/unit/models/runtime/service_usage_snapshot_spec.rb b/spec/unit/models/runtime/service_usage_snapshot_spec.rb new file mode 100644 index 00000000000..700027bdd38 --- /dev/null +++ b/spec/unit/models/runtime/service_usage_snapshot_spec.rb @@ -0,0 +1,77 @@ +require 'spec_helper' + +module VCAP::CloudController + RSpec.describe ServiceUsageSnapshot do + describe 'associations' do + it 'has many service_usage_snapshot_chunks' do + snapshot = ServiceUsageSnapshot.make + chunk1 = ServiceUsageSnapshotChunk.make(service_usage_snapshot: snapshot, space_guid: 'space-1', chunk_index: 0) + chunk2 = ServiceUsageSnapshotChunk.make(service_usage_snapshot: snapshot, space_guid: 'space-2', chunk_index: 0) + + expect(snapshot.service_usage_snapshot_chunks).to contain_exactly(chunk1, chunk2) + end + end + + describe 'validations' do + it 'allows nil checkpoint_event_guid (for placeholder snapshots)' do + snapshot = ServiceUsageSnapshot.make + snapshot.checkpoint_event_guid = nil + expect(snapshot).to be_valid + end + + it 'validates presence of created_at' do + snapshot = ServiceUsageSnapshot.new( + guid: SecureRandom.uuid, + service_instance_count: 0, + organization_count: 0, + space_count: 0, + chunk_count: 0 + ) + snapshot.created_at = nil + snapshot.validate + expect(snapshot.errors.on(:created_at)).to eq([:presence]) + end + + it 'validates presence of chunk_count' do + snapshot = ServiceUsageSnapshot.new( + guid: SecureRandom.uuid, + created_at: Time.now.utc, + service_instance_count: 0, + organization_count: 0, + space_count: 0 + ) + snapshot.chunk_count = nil + snapshot.validate + expect(snapshot.errors.on(:chunk_count)).to eq([:presence]) + end + end + + describe '#processing?' do + it 'returns true when completed_at is nil' do + snapshot = ServiceUsageSnapshot.make + snapshot.completed_at = nil + expect(snapshot.processing?).to be true + end + + it 'returns false when completed_at is set' do + snapshot = ServiceUsageSnapshot.make + snapshot.completed_at = Time.now.utc + expect(snapshot.processing?).to be false + end + end + + describe '#complete?' do + it 'returns false when completed_at is nil' do + snapshot = ServiceUsageSnapshot.make + snapshot.completed_at = nil + expect(snapshot.complete?).to be false + end + + it 'returns true when completed_at is set' do + snapshot = ServiceUsageSnapshot.make + snapshot.completed_at = Time.now.utc + expect(snapshot.complete?).to be true + end + end + end +end diff --git a/spec/unit/repositories/app_usage_snapshot_repository_spec.rb b/spec/unit/repositories/app_usage_snapshot_repository_spec.rb new file mode 100644 index 00000000000..eedfde58df6 --- /dev/null +++ b/spec/unit/repositories/app_usage_snapshot_repository_spec.rb @@ -0,0 +1,349 @@ +require 'spec_helper' +require 'repositories/app_usage_snapshot_repository' + +module VCAP::CloudController + module Repositories + RSpec.describe AppUsageSnapshotRepository do + subject(:repository) { AppUsageSnapshotRepository.new } + + let(:org) { Organization.make(name: 'test-org') } + let(:space) { Space.make(organization: org, name: 'test-space') } + let(:app_model) { AppModel.make(space: space, name: 'test-app') } + + def create_placeholder_snapshot + AppUsageSnapshot.create( + guid: SecureRandom.uuid, + checkpoint_event_guid: nil, + created_at: Time.now.utc, + completed_at: nil, + instance_count: 0, + organization_count: 0, + space_count: 0, + app_count: 0, + chunk_count: 0 + ) + end + + describe '#populate_snapshot!' do + context 'when there are running processes' do + let!(:process1) { ProcessModel.make(app: app_model, state: ProcessModel::STARTED, instances: 3, memory: 256, type: 'web') } + let!(:process2) { ProcessModel.make(app: app_model, state: ProcessModel::STARTED, instances: 2, memory: 512, type: 'worker') } + let!(:stopped_process) { ProcessModel.make(app: app_model, state: ProcessModel::STOPPED, instances: 1) } + + it 'populates the snapshot with correct counts' do + snapshot = create_placeholder_snapshot + repository.populate_snapshot!(snapshot) + + snapshot.reload + # 3 instances (web) + 2 instances (worker) = 5 total instances + expect(snapshot.instance_count).to eq(5) + expect(snapshot.app_count).to eq(1) # both processes belong to same app + expect(snapshot.organization_count).to eq(1) + expect(snapshot.space_count).to eq(1) + expect(snapshot.chunk_count).to eq(1) + expect(snapshot.completed_at).not_to be_nil + end + + it 'creates chunk records with process details including V3-aligned fields' do + snapshot = create_placeholder_snapshot + repository.populate_snapshot!(snapshot) + + expect(snapshot.app_usage_snapshot_chunks.count).to eq(1) + chunk = snapshot.app_usage_snapshot_chunks.first + + expect(chunk.space_guid).to eq(space.guid) + expect(chunk.space_name).to eq(space.name) + expect(chunk.organization_guid).to eq(org.guid) + expect(chunk.organization_name).to eq(org.name) + expect(chunk.chunk_index).to eq(0) + expect(chunk.processes).to contain_exactly( + hash_including( + 'app_guid' => app_model.guid, + 'app_name' => app_model.name, + 'process_guid' => process1.guid, + 'process_type' => 'web', + 'instance_count' => 3, + 'memory_in_mb_per_instance' => 256 + ), + hash_including( + 'app_guid' => app_model.guid, + 'app_name' => app_model.name, + 'process_guid' => process2.guid, + 'process_type' => 'worker', + 'instance_count' => 2, + 'memory_in_mb_per_instance' => 512 + ) + ) + end + + it 'records checkpoint event GUID' do + AppUsageEvent.make + AppUsageEvent.make + last_event = AppUsageEvent.make + + snapshot = create_placeholder_snapshot + repository.populate_snapshot!(snapshot) + + snapshot.reload + expect(snapshot.checkpoint_event_guid).to eq(last_event.guid) + expect(snapshot.checkpoint_event_created_at).to be_within(1.second).of(last_event.created_at) + end + + it 'excludes task and build processes from counts' do + ProcessModel.make(app: app_model, state: ProcessModel::STARTED, instances: 10, type: 'TASK') + ProcessModel.make(app: app_model, state: ProcessModel::STARTED, instances: 5, type: 'build') + + snapshot = create_placeholder_snapshot + repository.populate_snapshot!(snapshot) + + snapshot.reload + # Only web (3) + worker (2) = 5 instances, 1 app + expect(snapshot.instance_count).to eq(5) + expect(snapshot.app_count).to eq(1) + end + end + + context 'when there are multiple spaces' do + let(:space2) { Space.make(organization: org) } + let(:org2) { Organization.make } + let(:space3) { Space.make(organization: org2) } + let(:app_model2) { AppModel.make(space: space2) } + let(:app_model3) { AppModel.make(space: space3) } + + before do + ProcessModel.make(app: app_model, state: ProcessModel::STARTED, instances: 2, type: 'web') + ProcessModel.make(app: app_model2, state: ProcessModel::STARTED, instances: 3, type: 'web') + ProcessModel.make(app: app_model3, state: ProcessModel::STARTED, instances: 5, type: 'web') + end + + it 'creates one chunk per space' do + snapshot = create_placeholder_snapshot + repository.populate_snapshot!(snapshot) + + expect(snapshot.app_usage_snapshot_chunks.count).to eq(3) + expect(snapshot.instance_count).to eq(10) # 2 + 3 + 5 + expect(snapshot.app_count).to eq(3) + expect(snapshot.organization_count).to eq(2) + expect(snapshot.space_count).to eq(3) + expect(snapshot.chunk_count).to eq(3) + end + + it 'groups processes by space correctly' do + snapshot = create_placeholder_snapshot + repository.populate_snapshot!(snapshot) + + chunks = snapshot.app_usage_snapshot_chunks.to_a + space1_chunk = chunks.find { |c| c.space_guid == space.guid } + space2_chunk = chunks.find { |c| c.space_guid == space2.guid } + space3_chunk = chunks.find { |c| c.space_guid == space3.guid } + + expect(space1_chunk.processes.size).to eq(1) + expect(space2_chunk.processes.size).to eq(1) + expect(space3_chunk.processes.size).to eq(1) + end + end + + context 'when a space has many processes (chunking test)' do + # Create more than CHUNK_LIMIT (50) processes in one space + before do + 75.times do |_i| + process_app = AppModel.make(space:) + ProcessModel.make(app: process_app, state: ProcessModel::STARTED, instances: 1, type: 'web') + end + end + + it 'creates multiple chunks for the same space' do + snapshot = create_placeholder_snapshot + repository.populate_snapshot!(snapshot) + + # 75 processes should create 2 chunks (50 + 25) + expect(snapshot.app_usage_snapshot_chunks.count).to eq(2) + expect(snapshot.app_count).to eq(75) + expect(snapshot.instance_count).to eq(75) + expect(snapshot.chunk_count).to eq(2) + + chunks = snapshot.app_usage_snapshot_chunks_dataset.order(:chunk_index).to_a + expect(chunks[0].chunk_index).to eq(0) + expect(chunks[0].processes.size).to eq(50) + expect(chunks[1].chunk_index).to eq(1) + expect(chunks[1].processes.size).to eq(25) + end + end + + context 'when there are no running processes' do + it 'populates snapshot with zero counts and no chunks' do + snapshot = create_placeholder_snapshot + repository.populate_snapshot!(snapshot) + + snapshot.reload + expect(snapshot.instance_count).to eq(0) + expect(snapshot.app_count).to eq(0) + expect(snapshot.organization_count).to eq(0) + expect(snapshot.space_count).to eq(0) + expect(snapshot.chunk_count).to eq(0) + expect(snapshot.app_usage_snapshot_chunks.count).to eq(0) + expect(snapshot.completed_at).not_to be_nil + end + end + + context 'when there are no usage events (empty system)' do + it 'sets checkpoint_event_guid to nil and checkpoint_event_created_at to nil' do + snapshot = create_placeholder_snapshot + repository.populate_snapshot!(snapshot) + + snapshot.reload + expect(snapshot.checkpoint_event_guid).to be_nil + expect(snapshot.checkpoint_event_created_at).to be_nil + expect(snapshot.completed_at).not_to be_nil + end + end + + context 'when app has a droplet with buildpack information' do + let(:droplet) do + DropletModel.make( + app: app_model, + state: DropletModel::STAGED_STATE, + buildpack_receipt_buildpack_guid: 'buildpack-guid-123', + buildpack_receipt_buildpack: 'ruby_buildpack' + ) + end + + before do + app_model.update(droplet:) + ProcessModel.make(app: app_model, state: ProcessModel::STARTED, instances: 2, memory: 1024, type: 'web') + end + + it 'includes buildpack information in the process JSON' do + snapshot = create_placeholder_snapshot + repository.populate_snapshot!(snapshot) + + chunk = snapshot.app_usage_snapshot_chunks.first + process_data = chunk.processes.first + + expect(process_data['buildpack_guid']).to eq('buildpack-guid-123') + expect(process_data['buildpack_name']).to eq('ruby_buildpack') + end + end + + context 'when app does not have a droplet' do + let!(:process) { ProcessModel.make(app: app_model, state: ProcessModel::STARTED, instances: 2, type: 'web') } + + it 'includes nil for buildpack fields' do + snapshot = create_placeholder_snapshot + repository.populate_snapshot!(snapshot) + + chunk = snapshot.app_usage_snapshot_chunks.first + process_data = chunk.processes.first + + expect(process_data['buildpack_guid']).to be_nil + expect(process_data['buildpack_name']).to be_nil + end + end + + context 'when snapshot population fails' do + it 'raises the error and rolls back transaction' do + snapshot = create_placeholder_snapshot + allow(snapshot).to receive(:update).and_raise(Sequel::DatabaseError.new('DB error')) + + prometheus = instance_double(VCAP::CloudController::Metrics::PrometheusUpdater) + allow(CloudController::DependencyLocator.instance).to receive(:prometheus_updater).and_return(prometheus) + expect(prometheus).to receive(:increment_counter_metric).with(:cc_app_usage_snapshot_generation_failures_total) + + expect { repository.populate_snapshot!(snapshot) }.to raise_error(Sequel::DatabaseError) + end + end + + context 'metrics' do + it 'records generation duration' do + prometheus = instance_double(VCAP::CloudController::Metrics::PrometheusUpdater) + allow(CloudController::DependencyLocator.instance).to receive(:prometheus_updater).and_return(prometheus) + + expect(prometheus).to receive(:update_histogram_metric).with(:cc_app_usage_snapshot_generation_duration_seconds, anything) + + snapshot = create_placeholder_snapshot + repository.populate_snapshot!(snapshot) + end + + it 'increments failure counter on error' do + prometheus = instance_double(VCAP::CloudController::Metrics::PrometheusUpdater) + allow(CloudController::DependencyLocator.instance).to receive(:prometheus_updater).and_return(prometheus) + + snapshot = create_placeholder_snapshot + allow(snapshot).to receive(:update).and_raise(StandardError.new('test error')) + + expect(prometheus).to receive(:increment_counter_metric).with(:cc_app_usage_snapshot_generation_failures_total) + + expect { repository.populate_snapshot!(snapshot) }.to raise_error(StandardError) + end + end + + context 'edge cases' do + context 'when exactly CHUNK_LIMIT (50) processes in a space' do + before do + 50.times do + process_app = AppModel.make(space:) + ProcessModel.make(app: process_app, state: ProcessModel::STARTED, instances: 1, type: 'web') + end + end + + it 'creates exactly 1 chunk (not 2)' do + snapshot = create_placeholder_snapshot + repository.populate_snapshot!(snapshot) + + expect(snapshot.app_usage_snapshot_chunks.count).to eq(1) + expect(snapshot.app_count).to eq(50) + expect(snapshot.chunk_count).to eq(1) + + chunk = snapshot.app_usage_snapshot_chunks.first + expect(chunk.chunk_index).to eq(0) + expect(chunk.processes.size).to eq(50) + end + end + + context 'when exactly CHUNK_LIMIT + 1 (51) processes in a space' do + before do + 51.times do + process_app = AppModel.make(space:) + ProcessModel.make(app: process_app, state: ProcessModel::STARTED, instances: 1, type: 'web') + end + end + + it 'creates exactly 2 chunks (50 + 1)' do + snapshot = create_placeholder_snapshot + repository.populate_snapshot!(snapshot) + + expect(snapshot.app_usage_snapshot_chunks.count).to eq(2) + expect(snapshot.app_count).to eq(51) + expect(snapshot.chunk_count).to eq(2) + + chunks = snapshot.app_usage_snapshot_chunks_dataset.order(:chunk_index).to_a + expect(chunks[0].processes.size).to eq(50) + expect(chunks[1].processes.size).to eq(1) + end + end + + context 'when transaction fails mid-way' do + it 'rolls back all chunks (atomic operation)' do + ProcessModel.make(app: app_model, state: ProcessModel::STARTED, instances: 3, type: 'web') + + snapshot = create_placeholder_snapshot + initial_chunk_count = AppUsageSnapshotChunk.count + + # Simulate failure during final update within the transaction + allow(snapshot).to receive(:update).and_raise(Sequel::DatabaseError.new('DB error')) + + prometheus = instance_double(VCAP::CloudController::Metrics::PrometheusUpdater) + allow(CloudController::DependencyLocator.instance).to receive(:prometheus_updater).and_return(prometheus) + allow(prometheus).to receive(:increment_counter_metric) + + expect { repository.populate_snapshot!(snapshot) }.to raise_error(Sequel::DatabaseError) + + # Verify no orphan chunks were created (transaction rolled back) + expect(AppUsageSnapshotChunk.count).to eq(initial_chunk_count) + end + end + end + end + end + end +end diff --git a/spec/unit/repositories/service_usage_snapshot_repository_spec.rb b/spec/unit/repositories/service_usage_snapshot_repository_spec.rb new file mode 100644 index 00000000000..4b1c4acaa33 --- /dev/null +++ b/spec/unit/repositories/service_usage_snapshot_repository_spec.rb @@ -0,0 +1,329 @@ +require 'spec_helper' +require 'repositories/service_usage_snapshot_repository' + +module VCAP::CloudController + module Repositories + RSpec.describe ServiceUsageSnapshotRepository do + subject(:repository) { ServiceUsageSnapshotRepository.new } + + let(:quota) { QuotaDefinition.make(total_services: 500) } + let(:org) { Organization.make(quota_definition: quota, name: 'test-org') } + let(:space) { Space.make(organization: org, name: 'test-space') } + let(:service_broker) { ServiceBroker.make(name: 'test-broker') } + let(:service) { Service.make(service_broker: service_broker, label: 'test-service') } + let(:service_plan) { ServicePlan.make(service: service, name: 'test-plan') } + + # Helper to create a placeholder snapshot (as the controller would) + def create_placeholder_snapshot + ServiceUsageSnapshot.create( + guid: SecureRandom.uuid, + checkpoint_event_guid: nil, + created_at: Time.now.utc, + completed_at: nil, + service_instance_count: 0, + organization_count: 0, + space_count: 0, + chunk_count: 0 + ) + end + + describe '#populate_snapshot!' do + context 'when there are managed service instances' do + let!(:instance1) { ManagedServiceInstance.make(space: space, service_plan: service_plan, name: 'instance-1') } + let!(:instance2) { ManagedServiceInstance.make(space: space, service_plan: service_plan, name: 'instance-2') } + + it 'populates the snapshot with correct counts' do + snapshot = create_placeholder_snapshot + repository.populate_snapshot!(snapshot) + + snapshot.reload + expect(snapshot.service_instance_count).to eq(2) + expect(snapshot.organization_count).to eq(1) + expect(snapshot.space_count).to eq(1) + expect(snapshot.chunk_count).to eq(1) + expect(snapshot.completed_at).not_to be_nil + end + + it 'creates chunk records with service instance details including V3-aligned fields' do + snapshot = create_placeholder_snapshot + repository.populate_snapshot!(snapshot) + + expect(snapshot.service_usage_snapshot_chunks.count).to eq(1) + chunk = snapshot.service_usage_snapshot_chunks.first + + expect(chunk.space_guid).to eq(space.guid) + expect(chunk.space_name).to eq(space.name) + expect(chunk.organization_guid).to eq(org.guid) + expect(chunk.organization_name).to eq(org.name) + expect(chunk.chunk_index).to eq(0) + expect(chunk.service_instances.size).to eq(2) + expect(chunk.service_instances).to include( + hash_including( + 'service_instance_guid' => instance1.guid, + 'service_instance_name' => 'instance-1', + 'service_instance_type' => 'managed', + 'service_plan_guid' => service_plan.guid, + 'service_plan_name' => 'test-plan', + 'service_offering_guid' => service.guid, + 'service_offering_name' => 'test-service', + 'service_broker_guid' => service_broker.guid, + 'service_broker_name' => 'test-broker' + ), + hash_including( + 'service_instance_guid' => instance2.guid, + 'service_instance_name' => 'instance-2', + 'service_instance_type' => 'managed' + ) + ) + end + + it 'records checkpoint event GUID' do + ServiceUsageEvent.make + ServiceUsageEvent.make + last_event = ServiceUsageEvent.make + + snapshot = create_placeholder_snapshot + repository.populate_snapshot!(snapshot) + + snapshot.reload + expect(snapshot.checkpoint_event_guid).to eq(last_event.guid) + expect(snapshot.checkpoint_event_created_at).to be_within(1.second).of(last_event.created_at) + end + end + + context 'when there are user-provided service instances' do + let!(:user_provided_instance) { UserProvidedServiceInstance.make(space: space, name: 'user-provided-1') } + + it 'includes user-provided service instance in the count' do + snapshot = create_placeholder_snapshot + repository.populate_snapshot!(snapshot) + + snapshot.reload + expect(snapshot.service_instance_count).to eq(1) + end + + it 'marks user-provided instances correctly with nil plan/offering/broker fields' do + snapshot = create_placeholder_snapshot + repository.populate_snapshot!(snapshot) + + chunk = snapshot.service_usage_snapshot_chunks.first + instance_data = chunk.service_instances.first + + expect(instance_data['service_instance_type']).to eq('user_provided') + expect(instance_data['service_instance_guid']).to eq(user_provided_instance.guid) + expect(instance_data['service_instance_name']).to eq('user-provided-1') + expect(instance_data['service_plan_guid']).to be_nil + expect(instance_data['service_plan_name']).to be_nil + expect(instance_data['service_offering_guid']).to be_nil + expect(instance_data['service_offering_name']).to be_nil + expect(instance_data['service_broker_guid']).to be_nil + expect(instance_data['service_broker_name']).to be_nil + end + end + + context 'when there are both managed and user-provided instances' do + let!(:managed_instance) { ManagedServiceInstance.make(space:, service_plan:) } + let!(:user_provided_instance) { UserProvidedServiceInstance.make(space:) } + + it 'includes both types in the snapshot count' do + snapshot = create_placeholder_snapshot + repository.populate_snapshot!(snapshot) + + snapshot.reload + expect(snapshot.service_instance_count).to eq(2) + end + + it 'includes both types in chunk record' do + snapshot = create_placeholder_snapshot + repository.populate_snapshot!(snapshot) + + chunk = snapshot.service_usage_snapshot_chunks.first + types = chunk.service_instances.pluck('service_instance_type') + expect(types).to contain_exactly('managed', 'user_provided') + end + end + + context 'when there are multiple spaces' do + let(:space2) { Space.make(organization: org) } + let(:org2) { Organization.make } + let(:space3) { Space.make(organization: org2) } + + before do + ManagedServiceInstance.make(space:, service_plan:) + ManagedServiceInstance.make(space: space2, service_plan: service_plan) + ManagedServiceInstance.make(space: space2, service_plan: service_plan) + ManagedServiceInstance.make(space: space3, service_plan: service_plan) + end + + it 'creates one chunk per space' do + snapshot = create_placeholder_snapshot + repository.populate_snapshot!(snapshot) + + expect(snapshot.service_usage_snapshot_chunks.count).to eq(3) + expect(snapshot.service_instance_count).to eq(4) + expect(snapshot.organization_count).to eq(2) + expect(snapshot.space_count).to eq(3) + expect(snapshot.chunk_count).to eq(3) + end + + it 'groups service instances by space correctly' do + snapshot = create_placeholder_snapshot + repository.populate_snapshot!(snapshot) + + chunks = snapshot.service_usage_snapshot_chunks.to_a + space1_chunk = chunks.find { |c| c.space_guid == space.guid } + space2_chunk = chunks.find { |c| c.space_guid == space2.guid } + space3_chunk = chunks.find { |c| c.space_guid == space3.guid } + + expect(space1_chunk.service_instances.size).to eq(1) + expect(space2_chunk.service_instances.size).to eq(2) + expect(space3_chunk.service_instances.size).to eq(1) + end + end + + context 'when there are no service instances' do + it 'populates snapshot with zero counts and no chunks' do + snapshot = create_placeholder_snapshot + repository.populate_snapshot!(snapshot) + + snapshot.reload + expect(snapshot.service_instance_count).to eq(0) + expect(snapshot.organization_count).to eq(0) + expect(snapshot.space_count).to eq(0) + expect(snapshot.chunk_count).to eq(0) + expect(snapshot.service_usage_snapshot_chunks.count).to eq(0) + expect(snapshot.completed_at).not_to be_nil + end + end + + context 'when there are no usage events (empty system)' do + it 'sets checkpoint_event_guid to nil and checkpoint_event_created_at to nil' do + snapshot = create_placeholder_snapshot + repository.populate_snapshot!(snapshot) + + snapshot.reload + expect(snapshot.checkpoint_event_guid).to be_nil + expect(snapshot.checkpoint_event_created_at).to be_nil + expect(snapshot.completed_at).not_to be_nil + end + end + + context 'when snapshot population fails' do + it 'raises the error and rolls back transaction' do + snapshot = create_placeholder_snapshot + allow(snapshot).to receive(:update).and_raise(Sequel::DatabaseError.new('DB error')) + + prometheus = instance_double(VCAP::CloudController::Metrics::PrometheusUpdater) + allow(CloudController::DependencyLocator.instance).to receive(:prometheus_updater).and_return(prometheus) + expect(prometheus).to receive(:increment_counter_metric).with(:cc_service_usage_snapshot_generation_failures_total) + + expect { repository.populate_snapshot!(snapshot) }.to raise_error(Sequel::DatabaseError) + end + end + + context 'metrics' do + let!(:instance) { ManagedServiceInstance.make(space:, service_plan:) } + + it 'records generation duration' do + prometheus = instance_double(VCAP::CloudController::Metrics::PrometheusUpdater) + allow(CloudController::DependencyLocator.instance).to receive(:prometheus_updater).and_return(prometheus) + + expect(prometheus).to receive(:update_histogram_metric).with(:cc_service_usage_snapshot_generation_duration_seconds, kind_of(Numeric)) + + snapshot = create_placeholder_snapshot + repository.populate_snapshot!(snapshot) + end + end + + context 'edge cases' do + context 'when exactly CHUNK_LIMIT (50) service instances in a space' do + before do + 50.times do + ManagedServiceInstance.make(space:, service_plan:) + end + end + + it 'creates exactly 1 chunk (not 2)' do + snapshot = create_placeholder_snapshot + repository.populate_snapshot!(snapshot) + + expect(snapshot.service_usage_snapshot_chunks.count).to eq(1) + expect(snapshot.service_instance_count).to eq(50) + expect(snapshot.chunk_count).to eq(1) + + chunk = snapshot.service_usage_snapshot_chunks.first + expect(chunk.chunk_index).to eq(0) + expect(chunk.service_instances.size).to eq(50) + end + end + + context 'when exactly CHUNK_LIMIT + 1 (51) service instances in a space' do + before do + 51.times do + ManagedServiceInstance.make(space:, service_plan:) + end + end + + it 'creates exactly 2 chunks (50 + 1)' do + snapshot = create_placeholder_snapshot + repository.populate_snapshot!(snapshot) + + expect(snapshot.service_usage_snapshot_chunks.count).to eq(2) + expect(snapshot.service_instance_count).to eq(51) + expect(snapshot.chunk_count).to eq(2) + + chunks = snapshot.service_usage_snapshot_chunks_dataset.order(:chunk_index).to_a + expect(chunks[0].service_instances.size).to eq(50) + expect(chunks[1].service_instances.size).to eq(1) + end + end + + context 'when a space has many service instances (chunking test)' do + before do + 75.times do + ManagedServiceInstance.make(space:, service_plan:) + end + end + + it 'creates multiple chunks for the same space' do + snapshot = create_placeholder_snapshot + repository.populate_snapshot!(snapshot) + + # 75 instances should create 2 chunks (50 + 25) + expect(snapshot.service_usage_snapshot_chunks.count).to eq(2) + expect(snapshot.service_instance_count).to eq(75) + expect(snapshot.chunk_count).to eq(2) + + chunks = snapshot.service_usage_snapshot_chunks_dataset.order(:chunk_index).to_a + expect(chunks[0].chunk_index).to eq(0) + expect(chunks[0].service_instances.size).to eq(50) + expect(chunks[1].chunk_index).to eq(1) + expect(chunks[1].service_instances.size).to eq(25) + end + end + + context 'when transaction fails mid-way' do + it 'rolls back all chunks (atomic operation)' do + ManagedServiceInstance.make(space:, service_plan:) + + snapshot = create_placeholder_snapshot + initial_chunk_count = ServiceUsageSnapshotChunk.count + + # Simulate failure during final update within the transaction + allow(snapshot).to receive(:update).and_raise(Sequel::DatabaseError.new('DB error')) + + prometheus = instance_double(VCAP::CloudController::Metrics::PrometheusUpdater) + allow(CloudController::DependencyLocator.instance).to receive(:prometheus_updater).and_return(prometheus) + allow(prometheus).to receive(:increment_counter_metric) + + expect { repository.populate_snapshot!(snapshot) }.to raise_error(Sequel::DatabaseError) + + # Verify no orphan chunks were created (transaction rolled back) + expect(ServiceUsageSnapshotChunk.count).to eq(initial_chunk_count) + end + end + end + end + end + end +end