diff --git a/app/controllers/v3/space_manifests_controller.rb b/app/controllers/v3/space_manifests_controller.rb index 9e8f8cd91ae..4fd6599802d 100644 --- a/app/controllers/v3/space_manifests_controller.rb +++ b/app/controllers/v3/space_manifests_controller.rb @@ -127,6 +127,9 @@ def incompatible_with_buildpacks(lifecycle_type, manifest) end def incompatible_with_docker(lifecycle_type, manifest) + # Allow docker + buildpack when lifecycle is explicitly set to buildpack (custom stack usage) + return false if lifecycle_type == 'buildpack' && manifest.requested?(:lifecycle) && manifest.lifecycle == 'buildpack' + lifecycle_type == 'buildpack' && manifest.docker end end diff --git a/app/fetchers/buildpack_lifecycle_fetcher.rb b/app/fetchers/buildpack_lifecycle_fetcher.rb index f5d399a64e2..37783a251e1 100644 --- a/app/fetchers/buildpack_lifecycle_fetcher.rb +++ b/app/fetchers/buildpack_lifecycle_fetcher.rb @@ -4,8 +4,16 @@ module VCAP::CloudController class BuildpackLifecycleFetcher class << self def fetch(buildpack_names, stack_name, lifecycle=Config.config.get(:default_app_lifecycle)) + # Try to find the stack in the database first + stack = Stack.find(name: stack_name) if stack_name.is_a?(String) + + # If not found and it looks like a custom stack URL, use it as-is (normalized) + if stack.nil? && stack_name.is_a?(String) && is_custom_stack?(stack_name) + stack = normalize_stack_url(stack_name) + end + { - stack: Stack.find(name: stack_name), + stack: stack, buildpack_infos: ordered_buildpacks(buildpack_names, stack_name, lifecycle) } end @@ -20,6 +28,21 @@ def ordered_buildpacks(buildpack_names, stack_name, lifecycle) BuildpackInfo.new(buildpack_name, buildpack_record) end end + + def is_custom_stack?(stack_name) + # Check for various container registry URL formats + return true if stack_name.include?('docker://') + return true if stack_name.match?(%r{^https?://}) # Any https/http URL + return true if stack_name.include?('.') # Any string with a dot (likely a registry) + false + end + + def normalize_stack_url(stack_url) + return stack_url if stack_url.start_with?('docker://') + return stack_url.sub(/^https?:\/\//, 'docker://') if stack_url.match?(%r{^https?://}) + return "docker://#{stack_url}" if stack_url.match?(%r{^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/.+}) + stack_url + end end end end diff --git a/app/messages/app_manifest_message.rb b/app/messages/app_manifest_message.rb index 97802f9e361..2312669a238 100644 --- a/app/messages/app_manifest_message.rb +++ b/app/messages/app_manifest_message.rb @@ -138,9 +138,17 @@ def app_lifecycle_hash Lifecycles::DOCKER end + # Use docker image as custom stack when lifecycle is explicitly buildpack + stack_value = if requested?(:lifecycle) && @lifecycle == 'buildpack' && requested?(:docker) && docker + docker_image = docker[:image] || docker['image'] + docker_image ? "docker://#{docker_image}" : @stack + else + @stack + end + data = { buildpacks: requested_buildpacks, - stack: @stack, + stack: stack_value, credentials: @cnb_credentials }.compact @@ -475,6 +483,9 @@ def validate_cnb_enabled! def validate_docker_buildpacks_combination! return unless requested?(:docker) && (requested?(:buildpack) || requested?(:buildpacks)) + # Allow docker + buildpacks when lifecycle is explicitly set to buildpack (custom stack usage) + return if requested?(:lifecycle) && @lifecycle == 'buildpack' + errors.add(:base, 'Cannot specify both buildpack(s) and docker keys') end diff --git a/app/messages/buildpack_lifecycle_data_message.rb b/app/messages/buildpack_lifecycle_data_message.rb index 66bf5ed234a..fef7d5482d9 100644 --- a/app/messages/buildpack_lifecycle_data_message.rb +++ b/app/messages/buildpack_lifecycle_data_message.rb @@ -1,3 +1,5 @@ +require 'uri' + module VCAP::CloudController class BuildpackLifecycleDataMessage < BaseMessage register_allowed_keys %i[buildpacks stack credentials] @@ -19,6 +21,7 @@ class BuildpackLifecycleDataMessage < BaseMessage validate :buildpacks_content validate :credentials_content + validate :custom_stack_requires_custom_buildpack def buildpacks_content return unless buildpacks.is_a?(Array) @@ -40,7 +43,44 @@ def buildpacks_content def credentials_content return unless credentials.is_a?(Hash) - errors.add(:credentials, 'credentials value must be a hash') if credentials.any? { |_, v| !v.is_a?(Hash) } + credentials.each do |registry, creds| + unless creds.is_a?(Hash) + errors.add(:credentials, "for registry '#{registry}' must be a hash") + next + end + + has_username = creds.key?('username') || creds.key?(:username) + has_password = creds.key?('password') || creds.key?(:password) + errors.add(:base, "credentials for #{registry} must include 'username' and 'password'") unless has_username && has_password + end + end + + def custom_stack_requires_custom_buildpack + return unless stack.is_a?(String) && is_custom_stack?(stack) + return unless FeatureFlag.enabled?(:diego_custom_stacks) + return unless buildpacks.is_a?(Array) + + buildpacks.each do |buildpack_name| + # If buildpack is a URL, it's custom + next if buildpack_name&.match?(URI::DEFAULT_PARSER.make_regexp) + + # Check if it's a system buildpack + system_buildpack = Buildpack.find(name: buildpack_name) + if system_buildpack + errors.add(:base, 'Buildpack must be a custom buildpack when using a custom stack') + break + end + end + end + + private + + def is_custom_stack?(stack_name) + # Check for various container registry URL formats + return true if stack_name.include?('docker://') + return true if stack_name.match?(%r{^https?://}) # Any https/http URL + return true if stack_name.include?('.') # Any string with a dot (likely a registry) + false end end end diff --git a/app/messages/validators.rb b/app/messages/validators.rb index 48d4d3bdf61..e97035fc8f7 100644 --- a/app/messages/validators.rb +++ b/app/messages/validators.rb @@ -198,7 +198,7 @@ def validate(record) lifecycle_data_message = lifecycle_data_message_class.new(record.lifecycle_data) return if lifecycle_data_message.valid? - lifecycle_data_message.errors.full_messages.each do |message| + lifecycle_data_message.errors.each do |attribute, message| record.errors.add(:lifecycle, message:) end end diff --git a/app/models/runtime/cnb_lifecycle_data_model.rb b/app/models/runtime/cnb_lifecycle_data_model.rb index 63bd6a32142..968fe5b3f4f 100644 --- a/app/models/runtime/cnb_lifecycle_data_model.rb +++ b/app/models/runtime/cnb_lifecycle_data_model.rb @@ -68,13 +68,10 @@ def using_custom_buildpack? end def to_hash - hash = { + { buildpacks: buildpacks.map { |buildpack| CloudController::UrlSecretObfuscator.obfuscate(buildpack) }, stack: stack } - hash[:credentials] = Presenters::Censorship::REDACTED_CREDENTIAL unless credentials.nil? - - hash end def validate diff --git a/app/models/runtime/feature_flag.rb b/app/models/runtime/feature_flag.rb index 88a5c24a353..422f1379b54 100644 --- a/app/models/runtime/feature_flag.rb +++ b/app/models/runtime/feature_flag.rb @@ -14,6 +14,7 @@ class UndefinedFeatureFlagError < StandardError service_instance_creation: true, diego_docker: false, diego_cnb: false, + diego_custom_stacks: false, set_roles_by_username: true, unset_roles_by_username: true, task_creation: true, diff --git a/lib/cloud_controller/diego/buildpack/desired_lrp_builder.rb b/lib/cloud_controller/diego/buildpack/desired_lrp_builder.rb index 9cd5e72fa74..4cdbb03afb7 100644 --- a/lib/cloud_controller/diego/buildpack/desired_lrp_builder.rb +++ b/lib/cloud_controller/diego/buildpack/desired_lrp_builder.rb @@ -26,6 +26,14 @@ def cached_dependencies lifecycle_bundle_key = :"buildpack/#{@stack}" lifecycle_bundle = @config.get(:diego, :lifecycle_bundles)[lifecycle_bundle_key] + + # If custom stack doesn't have a bundle, use the default stack's bundle + if !lifecycle_bundle && @stack.is_a?(String) && is_custom_stack?(@stack) + default_stack = Stack.default.name + lifecycle_bundle_key = :"buildpack/#{default_stack}" + lifecycle_bundle = @config.get(:diego, :lifecycle_bundles)[lifecycle_bundle_key] + end + raise InvalidStack.new("no compiler defined for requested stack '#{@stack}'") unless lifecycle_bundle [ @@ -38,6 +46,11 @@ def cached_dependencies end def root_fs + # Handle custom stacks (docker:// URLs) + if @stack.is_a?(String) && is_custom_stack?(@stack) + return normalize_stack_url(@stack) + end + @stack_obj ||= Stack.find(name: @stack) raise CloudController::Errors::ApiError.new_from_details('StackNotFound', @stack) unless @stack_obj @@ -65,9 +78,22 @@ def image_layers lifecycle_bundle_key = :"buildpack/#{@stack}" lifecycle_bundle = @config.get(:diego, :lifecycle_bundles)[lifecycle_bundle_key] + + # If custom stack doesn't have a bundle, use the default stack's bundle + if !lifecycle_bundle && @stack.is_a?(String) && is_custom_stack?(@stack) + default_stack = Stack.default.name + lifecycle_bundle_key = :"buildpack/#{default_stack}" + lifecycle_bundle = @config.get(:diego, :lifecycle_bundles)[lifecycle_bundle_key] + end + raise InvalidStack.new("no compiler defined for requested stack '#{@stack}'") unless lifecycle_bundle destination = @config.get(:diego, :droplet_destinations)[@stack.to_sym] + # For custom stacks, use a default destination if not configured + if !destination && @stack.is_a?(String) && is_custom_stack?(@stack) + default_stack = Stack.default.name + destination = @config.get(:diego, :droplet_destinations)[default_stack.to_sym] + end raise InvalidStack.new("no droplet destination defined for requested stack '#{@stack}'") unless destination layers = [ @@ -116,6 +142,23 @@ def port_environment_variables def privileged? @config.get(:diego, :use_privileged_containers_for_running) end + + private + + def is_custom_stack?(stack_name) + # Check for various container registry URL formats + return true if stack_name.include?('docker://') + return true if stack_name.match?(%r{^https?://}) # Any https/http URL + return true if stack_name.include?('.') # Any string with a dot (likely a registry) + false + end + + def normalize_stack_url(stack_url) + return stack_url if stack_url.start_with?('docker://') + return stack_url.sub(/^https?:\/\//, 'docker://') if stack_url.match?(%r{^https?://}) + return "docker://#{stack_url}" if stack_url.include?('.') + stack_url + end end end end diff --git a/lib/cloud_controller/diego/buildpack/task_action_builder.rb b/lib/cloud_controller/diego/buildpack/task_action_builder.rb index 99f7731b088..4df0afb5c45 100644 --- a/lib/cloud_controller/diego/buildpack/task_action_builder.rb +++ b/lib/cloud_controller/diego/buildpack/task_action_builder.rb @@ -90,6 +90,11 @@ def task_environment_variables end def stack + # Handle custom stacks (docker:// URLs) + if lifecycle_stack.is_a?(String) && is_custom_stack?(lifecycle_stack) + return normalize_stack_url(lifecycle_stack) + end + @stack ||= Stack.find(name: lifecycle_stack) raise CloudController::Errors::ApiError.new_from_details('StackNotFound', lifecycle_stack) unless @stack @@ -117,6 +122,21 @@ def lifecycle_bundle_key def lifecycle_stack lifecycle_data[:stack] end + + def is_custom_stack?(stack_name) + # Check for various container registry URL formats + return true if stack_name.include?('docker://') + return true if stack_name.match?(%r{^https?://}) # Any https/http URL + return true if stack_name.include?('.') # Any string with a dot (likely a registry) + false + end + + def normalize_stack_url(stack_url) + return stack_url if stack_url.start_with?('docker://') + return stack_url.sub(%r{^https?://}, 'docker://') if stack_url.match?(%r{^https?://}) + return "docker://#{stack_url}" if stack_url.include?('.') + stack_url + end end end end diff --git a/lib/cloud_controller/diego/cnb/desired_lrp_builder.rb b/lib/cloud_controller/diego/cnb/desired_lrp_builder.rb index 6646db49193..d25f64414c4 100644 --- a/lib/cloud_controller/diego/cnb/desired_lrp_builder.rb +++ b/lib/cloud_controller/diego/cnb/desired_lrp_builder.rb @@ -26,6 +26,14 @@ def cached_dependencies lifecycle_bundle_key = :"cnb/#{@stack}" lifecycle_bundle = @config.get(:diego, :lifecycle_bundles)[lifecycle_bundle_key] + + # If custom stack doesn't have a bundle, use the default stack's bundle + if !lifecycle_bundle && @stack.is_a?(String) && is_custom_stack?(@stack) + default_stack = Stack.default.name + lifecycle_bundle_key = :"cnb/#{default_stack}" + lifecycle_bundle = @config.get(:diego, :lifecycle_bundles)[lifecycle_bundle_key] + end + raise InvalidStack.new("no compiler defined for requested stack '#{@stack}'") unless lifecycle_bundle [ @@ -38,6 +46,11 @@ def cached_dependencies end def root_fs + # Handle custom stacks (docker:// URLs) + if @stack.is_a?(String) && is_custom_stack?(@stack) + return normalize_stack_url(@stack) + end + @stack_obj ||= Stack.find(name: @stack) raise CloudController::Errors::ApiError.new_from_details('StackNotFound', @stack) unless @stack_obj @@ -65,9 +78,22 @@ def image_layers lifecycle_bundle_key = :"cnb/#{@stack}" lifecycle_bundle = @config.get(:diego, :lifecycle_bundles)[lifecycle_bundle_key] + + # If custom stack doesn't have a bundle, use the default stack's bundle + if !lifecycle_bundle && @stack.is_a?(String) && is_custom_stack?(@stack) + default_stack = Stack.default.name + lifecycle_bundle_key = :"cnb/#{default_stack}" + lifecycle_bundle = @config.get(:diego, :lifecycle_bundles)[lifecycle_bundle_key] + end + raise InvalidStack.new("no compiler defined for requested stack '#{@stack}'") unless lifecycle_bundle destination = @config.get(:diego, :droplet_destinations)[@stack.to_sym] + # For custom stacks, use a default destination if not configured + if !destination && @stack.is_a?(String) && is_custom_stack?(@stack) + default_stack = Stack.default.name + destination = @config.get(:diego, :droplet_destinations)[default_stack.to_sym] + end raise InvalidStack.new("no droplet destination defined for requested stack '#{@stack}'") unless destination layers = [ @@ -124,6 +150,23 @@ def default_container_env ::Diego::Bbs::Models::EnvironmentVariable.new(name: 'CNB_APP_DIR', value: '/home/vcap/workspace') ] end + + private + + def is_custom_stack?(stack_name) + # Check for various container registry URL formats + return true if stack_name.include?('docker://') + return true if stack_name.match?(%r{^https?://}) # Any https/http URL + return true if stack_name.include?('.') # Any string with a dot (likely a registry) + false + end + + def normalize_stack_url(stack_url) + return stack_url if stack_url.start_with?('docker://') + return stack_url.sub(/^https?:\/\//, 'docker://') if stack_url.match?(%r{^https?://}) + return "docker://#{stack_url}" if stack_url.include?('.') + stack_url + end end end end diff --git a/lib/cloud_controller/diego/lifecycle_protocol.rb b/lib/cloud_controller/diego/lifecycle_protocol.rb index 967a8290e12..bd7e63fd73c 100644 --- a/lib/cloud_controller/diego/lifecycle_protocol.rb +++ b/lib/cloud_controller/diego/lifecycle_protocol.rb @@ -31,8 +31,8 @@ def lifecycle_data(staging_details) lifecycle_data.app_bits_download_uri = @blobstore_url_generator.package_download_url(staging_details.package) lifecycle_data.app_bits_checksum = staging_details.package.checksum_info lifecycle_data.buildpack_cache_checksum = staging_details.package.app.buildpack_cache_sha256_checksum - lifecycle_data.build_artifacts_cache_download_uri = @blobstore_url_generator.buildpack_cache_download_url(staging_details.package.app_guid, stack) - lifecycle_data.build_artifacts_cache_upload_uri = @blobstore_url_generator.buildpack_cache_upload_url(staging_details.package.app_guid, stack) + lifecycle_data.build_artifacts_cache_download_uri = @blobstore_url_generator.buildpack_cache_download_url(staging_details.package.app_guid, normalize_stack_for_cache_key(stack)) + lifecycle_data.build_artifacts_cache_upload_uri = @blobstore_url_generator.buildpack_cache_upload_url(staging_details.package.app_guid, normalize_stack_for_cache_key(stack)) lifecycle_data.droplet_upload_uri = @blobstore_url_generator.droplet_upload_url(staging_details.staging_guid) lifecycle_data.buildpacks = @buildpack_entry_generator.buildpack_entries(staging_details.lifecycle.buildpack_infos, stack) lifecycle_data.stack = stack @@ -90,6 +90,33 @@ def container_env_vars_for_process(process) additional_env + WindowsEnvironmentSage.ponder(process.app) end + def normalize_stack_for_cache_key(stack_name) + return stack_name unless stack_name.is_a?(String) && is_custom_stack?(stack_name) + + # Extract the image name from the Docker URL for cache key compatibility + # Examples: + # https://docker.io/cloudfoundry/cflinuxfs4 -> cflinuxfs4 + # docker://cloudfoundry/cflinuxfs3 -> cflinuxfs3 + # docker.io/cloudfoundry/cflinuxfs4 -> cflinuxfs4 + normalized_url = stack_name.gsub(%r{^(https?://|docker://)}, '') + if normalized_url.include?('/') + # Extract the last part of the path + parts = normalized_url.split('/') + parts.last + else + # If no path, use as-is + normalized_url + end + end + + def is_custom_stack?(stack_name) + # Check for various container registry URL formats + return true if stack_name.include?('docker://') + return true if stack_name.match?(%r{^https?://}) # Any https/http URL + return true if stack_name.include?('.') # Any string with a dot (likely a registry) + false + end + def logger @logger ||= Steno.logger('cc.diego.tr') end diff --git a/lib/cloud_controller/diego/lifecycles/buildpack_info.rb b/lib/cloud_controller/diego/lifecycles/buildpack_info.rb index 0cae5333d22..e6c1439f723 100644 --- a/lib/cloud_controller/diego/lifecycles/buildpack_info.rb +++ b/lib/cloud_controller/diego/lifecycles/buildpack_info.rb @@ -18,6 +18,10 @@ def buildpack_enabled? buildpack_record.enabled? end + def custom? + !@buildpack_url.nil? + end + def to_s if @buildpack_url @buildpack_url diff --git a/lib/cloud_controller/diego/lifecycles/buildpack_lifecycle.rb b/lib/cloud_controller/diego/lifecycles/buildpack_lifecycle.rb index 25efa5cda17..3950a630de5 100644 --- a/lib/cloud_controller/diego/lifecycles/buildpack_lifecycle.rb +++ b/lib/cloud_controller/diego/lifecycles/buildpack_lifecycle.rb @@ -19,7 +19,7 @@ def create_lifecycle_data_model(build) def staging_environment_variables { - 'CF_STACK' => staging_stack + 'CF_STACK' => normalize_stack_name_for_buildpack(staging_stack) } end @@ -28,5 +28,32 @@ def staging_environment_variables def app_stack @package.app.buildpack_lifecycle_data.try(:stack) end + + def normalize_stack_name_for_buildpack(stack_name) + return stack_name unless stack_name.is_a?(String) && is_custom_stack?(stack_name) + + # Extract the image name from the Docker URL for buildpack compatibility + # Examples: + # https://docker.io/cloudfoundry/cflinuxfs4 -> cflinuxfs4 + # docker://cloudfoundry/cflinuxfs3 -> cflinuxfs3 + # docker.io/cloudfoundry/cflinuxfs4 -> cflinuxfs4 + normalized_url = stack_name.gsub(%r{^(https?://|docker://)}, '') + if normalized_url.include?('/') + # Extract the last part of the path + parts = normalized_url.split('/') + parts.last + else + # If no path, use as-is + normalized_url + end + end + + def is_custom_stack?(stack_name) + # Check for various container registry URL formats + return true if stack_name.include?('docker://') + return true if stack_name.match?(%r{^https?://}) # Any https/http URL + return true if stack_name.include?('.') # Any string with a dot (likely a registry) + false + end end end diff --git a/lib/cloud_controller/diego/lifecycles/buildpack_lifecycle_data_validator.rb b/lib/cloud_controller/diego/lifecycles/buildpack_lifecycle_data_validator.rb index 08bed855085..9886431435b 100644 --- a/lib/cloud_controller/diego/lifecycles/buildpack_lifecycle_data_validator.rb +++ b/lib/cloud_controller/diego/lifecycles/buildpack_lifecycle_data_validator.rb @@ -8,6 +8,14 @@ class BuildpackLifecycleDataValidator validate :buildpacks_are_uri_or_nil validate :stack_exists_in_db + validate :custom_stack_requires_custom_buildpack + + def custom_stack_requires_custom_buildpack + return unless stack.is_a?(String) && is_custom_stack?(stack) + return if buildpack_infos.all?(&:custom?) + + errors.add(:buildpack, 'must be a custom buildpack when using a custom stack') + end def buildpacks_are_uri_or_nil buildpack_infos.each do |buildpack_info| @@ -16,7 +24,8 @@ def buildpacks_are_uri_or_nil next if buildpack_info.buildpack_url if stack - errors.add(:buildpack, %("#{buildpack_info.buildpack}" for stack "#{stack.name}" must be an existing admin buildpack or a valid git URI)) + stack_name = stack.is_a?(String) ? stack : stack.name + errors.add(:buildpack, %("#{buildpack_info.buildpack}" for stack "#{stack_name}" must be an existing admin buildpack or a valid git URI)) else errors.add(:buildpack, %("#{buildpack_info.buildpack}" must be an existing admin buildpack or a valid git URI)) end @@ -24,7 +33,35 @@ def buildpacks_are_uri_or_nil end def stack_exists_in_db - errors.add(:stack, 'must be an existing stack') if stack.nil? + # Explicitly check for nil first + if stack.nil? + errors.add(:stack, 'must be an existing stack') + return + end + + # Handle custom stacks (container registry URLs) + if stack.is_a?(String) && is_custom_stack?(stack) && FeatureFlag.enabled?(:diego_custom_stacks) + return + end + + # Handle existing stack objects or string names + if stack.is_a?(String) + # For string stack names, verify they exist in the database + unless VCAP::CloudController::Stack.where(name: stack).any? + errors.add(:stack, 'must be an existing stack') + end + end + # If stack is an object (not nil, not string), assume it's valid + end + + private + + def is_custom_stack?(stack_name) + # Check for various container registry URL formats + return true if stack_name.include?('docker://') + return true if stack_name.match?(%r{^https?://}) # Any https/http URL + return true if stack_name.include?('.') # Any string with a dot (likely a registry) + false end end end diff --git a/lib/cloud_controller/diego/lifecycles/lifecycle_base.rb b/lib/cloud_controller/diego/lifecycles/lifecycle_base.rb index 671d0bd3d92..dd0a23094b5 100644 --- a/lib/cloud_controller/diego/lifecycles/lifecycle_base.rb +++ b/lib/cloud_controller/diego/lifecycles/lifecycle_base.rb @@ -17,7 +17,13 @@ def initialize(package, staging_message) delegate :valid?, :errors, to: :validator def staging_stack - requested_stack || app_stack || VCAP::CloudController::Stack.default.name + stack = requested_stack || app_stack || VCAP::CloudController::Stack.default.name + FeatureFlag.raise_unless_enabled!(:diego_custom_stacks) if stack.is_a?(String) && is_custom_stack?(stack) + stack + end + + def credentials + staging_message.lifecycle_data&.dig(:credentials) end private @@ -31,5 +37,15 @@ def requested_stack end attr_reader :validator + + private + + def is_custom_stack?(stack_name) + # Check for various container registry URL formats + return true if stack_name.include?('docker://') + return true if stack_name.match?(%r{^https?://}) # Any https/http URL + return true if stack_name.include?('.') # Any string with a dot (likely a registry) + false + end end end diff --git a/lib/cloud_controller/diego/staging_action_builder.rb b/lib/cloud_controller/diego/staging_action_builder.rb index 3cca3aca34b..3be8dc6b983 100644 --- a/lib/cloud_controller/diego/staging_action_builder.rb +++ b/lib/cloud_controller/diego/staging_action_builder.rb @@ -39,11 +39,22 @@ def action def cached_dependencies return nil if @config.get(:diego, :enable_declarative_asset_downloads) + # For custom stacks, use a default lifecycle bundle since they won't be in config + bundle_key = lifecycle_bundle_key + lifecycle_bundle = config.get(:diego, :lifecycle_bundles)[bundle_key] + + # If custom stack doesn't have a bundle, use the default stack's bundle + if !lifecycle_bundle && lifecycle_stack.is_a?(String) && is_custom_stack?(lifecycle_stack) + default_stack = Stack.default.name + bundle_key = :"#{@prefix}/#{default_stack}" + lifecycle_bundle = config.get(:diego, :lifecycle_bundles)[bundle_key] + end + dependencies = [ ::Diego::Bbs::Models::CachedDependency.new( - from: LifecycleBundleUriGenerator.uri(config.get(:diego, :lifecycle_bundles)[lifecycle_bundle_key]), + from: LifecycleBundleUriGenerator.uri(lifecycle_bundle), to: '/tmp/lifecycle', - cache_key: "#{@prefix}-#{lifecycle_stack}-lifecycle" + cache_key: "#{@prefix}-#{normalize_stack_for_cache_key(lifecycle_stack)}-lifecycle" ) ] @@ -90,10 +101,21 @@ def additional_image_layers def image_layers return [] unless @config.get(:diego, :enable_declarative_asset_downloads) + # For custom stacks, use a default lifecycle bundle since they won't be in config + bundle_key = lifecycle_bundle_key + lifecycle_bundle = config.get(:diego, :lifecycle_bundles)[bundle_key] + + # If custom stack doesn't have a bundle, use the default stack's bundle + if !lifecycle_bundle && lifecycle_stack.is_a?(String) && is_custom_stack?(lifecycle_stack) + default_stack = Stack.default.name + bundle_key = :"#{@prefix}/#{default_stack}" + lifecycle_bundle = config.get(:diego, :lifecycle_bundles)[bundle_key] + end + layers = [ ::Diego::Bbs::Models::ImageLayer.new( name: "#{@prefix}-#{lifecycle_stack}-lifecycle", - url: LifecycleBundleUriGenerator.uri(config.get(:diego, :lifecycle_bundles)[lifecycle_bundle_key]), + url: LifecycleBundleUriGenerator.uri(lifecycle_bundle), destination_path: '/tmp/lifecycle', layer_type: ::Diego::Bbs::Models::ImageLayer::Type::SHARED, media_type: ::Diego::Bbs::Models::ImageLayer::MediaType::TGZ @@ -130,6 +152,11 @@ def image_layers end def stack + # Handle custom stacks (docker:// URLs) + if lifecycle_stack.is_a?(String) && is_custom_stack?(lifecycle_stack) + return normalize_stack_url(lifecycle_stack) + end + @stack ||= Stack.find(name: lifecycle_stack) raise CloudController::Errors::ApiError.new_from_details('StackNotFound', lifecycle_stack) unless @stack @@ -231,6 +258,40 @@ def buildpack_path(buildpack_key) "/tmp/buildpacks/#{Digest::XXH64.hexdigest(buildpack_key)}" end end + + def normalize_stack_for_cache_key(stack_name) + return stack_name unless stack_name.is_a?(String) && is_custom_stack?(stack_name) + + # Extract the image name from the Docker URL for cache key compatibility + # Examples: + # https://docker.io/cloudfoundry/cflinuxfs4 -> cflinuxfs4 + # docker://cloudfoundry/cflinuxfs3 -> cflinuxfs3 + # docker.io/cloudfoundry/cflinuxfs4 -> cflinuxfs4 + normalized_url = stack_name.gsub(%r{^(https?://|docker://)}, '') + if normalized_url.include?('/') + # Extract the last part of the path + parts = normalized_url.split('/') + parts.last + else + # If no path, use as-is + normalized_url + end + end + + def is_custom_stack?(stack_name) + # Check for various container registry URL formats + return true if stack_name.include?('docker://') + return true if stack_name.match?(%r{^https?://}) # Any https/http URL + return true if stack_name.include?('.') # Any string with a dot (likely a registry) + false + end + + def normalize_stack_url(stack_url) + return stack_url if stack_url.start_with?('docker://') + return stack_url.sub(%r{^https?://}, 'docker://') if stack_url.match?(%r{^https?://}) + return "docker://#{stack_url}" if stack_url.include?('.') + stack_url + end end end end diff --git a/lib/cloud_controller/diego/task_recipe_builder.rb b/lib/cloud_controller/diego/task_recipe_builder.rb index 4395d6d0b96..52899555786 100644 --- a/lib/cloud_controller/diego/task_recipe_builder.rb +++ b/lib/cloud_controller/diego/task_recipe_builder.rb @@ -91,12 +91,55 @@ def build_staging_task(config, staging_details) "app:#{staging_details.package.app_guid}" ] ), - image_username: staging_details.package.docker_username, - image_password: staging_details.package.docker_password, + image_username: image_username(staging_details), + image_password: image_password(staging_details), volume_mounted_files: ServiceBindingFilesBuilder.build(staging_details.package.app) }.compact) end + def image_username(staging_details) + return staging_details.package.docker_username if staging_details.package.docker_username.present? + return unless staging_details.lifecycle.respond_to?(:credentials) && staging_details.lifecycle.credentials.present? + + cred = get_credentials_for_stack(staging_details) + cred ? cred['username'] : nil + end + + def image_password(staging_details) + return staging_details.package.docker_password if staging_details.package.docker_password.present? + return unless staging_details.lifecycle.respond_to?(:credentials) && staging_details.lifecycle.credentials.present? + + cred = get_credentials_for_stack(staging_details) + cred ? cred['password'] : nil + end + + def get_credentials_for_stack(staging_details) + stack = staging_details.lifecycle.staging_stack + return nil unless is_custom_stack?(stack) + + # Convert different URL formats to a standard format for parsing + normalized_stack = normalize_stack_url(stack) + stack_uri = URI.parse(normalized_stack) + host = stack_uri.host + staging_details.lifecycle.credentials[host] + end + + def normalize_stack_url(stack_url) + return stack_url if stack_url.start_with?('docker://') + return stack_url.sub(/^https?:\/\//, 'docker://') if stack_url.match?(%r{^https?://}) + return "docker://#{stack_url}" if stack_url.match?(%r{^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/.+}) + stack_url + end + + def is_custom_stack?(stack_name) + return false unless stack_name.is_a?(String) + # Check for various container registry URL formats + return true if stack_name.include?('docker://') + return true if stack_name.match?(%r{^https?://}) # Any https/http URL + return true if stack_name.include?('.') # Any string with a dot (likely a registry) + false + end + private def metric_tags(source) diff --git a/spec/api/documentation/feature_flags_api_spec.rb b/spec/api/documentation/feature_flags_api_spec.rb index ce55fbd2c6b..5fe6740eb30 100644 --- a/spec/api/documentation/feature_flags_api_spec.rb +++ b/spec/api/documentation/feature_flags_api_spec.rb @@ -18,7 +18,7 @@ client.get '/v2/config/feature_flags', {}, headers expect(status).to eq(200) - expect(parsed_response.length).to eq(18) + expect(parsed_response.length).to eq(19) expect(parsed_response).to include( { 'name' => 'user_org_creation', diff --git a/spec/unit/actions/app_update_spec.rb b/spec/unit/actions/app_update_spec.rb index 2b2f9cc6a63..63244bc1494 100644 --- a/spec/unit/actions/app_update_spec.rb +++ b/spec/unit/actions/app_update_spec.rb @@ -259,6 +259,23 @@ module VCAP::CloudController end end + context 'when the lifecycle is valid' do + let(:message) do + AppUpdateMessage.new({ + lifecycle: { + type: 'buildpack', + data: { buildpacks: ['http://new-buildpack.url', 'ruby'], stack: stack.name } + } + }) + end + + it 'does not raise an error' do + expect do + app_update.update(app_model, message, lifecycle) + end.not_to raise_error + end + end + context 'when changing the lifecycle type' do let(:message) do AppUpdateMessage.new({ diff --git a/spec/unit/controllers/v3/apps_controller_spec.rb b/spec/unit/controllers/v3/apps_controller_spec.rb index 26c30bc185c..1e4f578a527 100644 --- a/spec/unit/controllers/v3/apps_controller_spec.rb +++ b/spec/unit/controllers/v3/apps_controller_spec.rb @@ -468,7 +468,7 @@ { name: 'some-name', relationships: { space: { data: { guid: space.guid } } }, - lifecycle: { type: 'cnb', data: { buildpacks: ['http://example.com'], credentials: { registry: { user: 'password' } } } } + lifecycle: { type: 'cnb', data: { buildpacks: ['http://example.com'], stack: 'cflinuxfs4', credentials: { 'docker.io' => { 'username' => 'user', 'password' => 'password' } } } } } end @@ -479,7 +479,7 @@ lifecycle_data = response_body['lifecycle']['data'] expect(response).to have_http_status :created - expect(lifecycle_data).to eq({ 'buildpacks' => ['http://example.com'], 'stack' => 'default-stack-name', 'credentials' => '***' }) + expect(lifecycle_data).to eq({ 'buildpacks' => ['http://example.com'], 'stack' => 'cflinuxfs4' }) end end end diff --git a/spec/unit/messages/app_manifest_message_spec.rb b/spec/unit/messages/app_manifest_message_spec.rb index dc1143172e9..d5e7142e5a9 100644 --- a/spec/unit/messages/app_manifest_message_spec.rb +++ b/spec/unit/messages/app_manifest_message_spec.rb @@ -2095,7 +2095,7 @@ module VCAP::CloudController end context 'when cnb_credentials key is specified' do - let(:parsed_yaml) { { name: 'cnb', lifecycle: 'cnb', buildpacks: %w[nodejs java], stack: stack.name, cnb_credentials: { registry: { username: 'password' } } } } + let(:parsed_yaml) { { name: 'cnb', lifecycle: 'cnb', buildpacks: %w[nodejs java], stack: stack.name, cnb_credentials: { registry: { username: 'user', password: 'password' } } } } it 'adds credentials to the lifecycle_data' do message = AppManifestMessage.create_from_yml(parsed_yaml) @@ -2104,7 +2104,7 @@ module VCAP::CloudController expect(message.app_update_message.lifecycle_type).to eq(Lifecycles::CNB) expect(message.app_update_message.buildpack_data.buildpacks).to eq(%w[nodejs java]) expect(message.app_update_message.buildpack_data.stack).to eq(stack.name) - expect(message.app_update_message.buildpack_data.credentials).to eq({ registry: { username: 'password' } }) + expect(message.app_update_message.buildpack_data.credentials).to eq({ registry: { username: 'user', password: 'password' } }) end end end diff --git a/spec/unit/messages/build_create_message_spec.rb b/spec/unit/messages/build_create_message_spec.rb index 93ed5de3a41..f36f30b2f39 100644 --- a/spec/unit/messages/build_create_message_spec.rb +++ b/spec/unit/messages/build_create_message_spec.rb @@ -126,6 +126,62 @@ module VCAP::CloudController expect(message).not_to be_valid expect(message.errors[:lifecycle]).to include('Buildpacks can only contain strings') end + + context 'credentials' do + it 'is valid with valid credentials' do + params[:lifecycle] = { + type: 'buildpack', + data: { + stack: 'cflinuxfs4', + credentials: { + 'docker.io' => { + 'username' => 'user', + 'password' => 'password' + } + } + } + } + message = BuildCreateMessage.new(params) + expect(message).to be_valid + end + + it 'is invalid with invalid credentials' do + params[:lifecycle] = { + type: 'buildpack', + data: { + stack: 'cflinuxfs4', + credentials: { + 'docker.io' => { + 'username' => 'user' + } + } + } + } + message = BuildCreateMessage.new(params) + expect(message).not_to be_valid + expect(message.errors[:lifecycle]).to include("credentials for docker.io must include 'username' and 'password'") + end + end + + context 'when a custom stack is used with a system buildpack' do + before do + VCAP::CloudController::FeatureFlag.make(name: 'diego_custom_stacks', enabled: true) + VCAP::CloudController::Buildpack.make(name: 'ruby_buildpack') + end + + it 'is not valid' do + params[:lifecycle] = { + type: 'buildpack', + data: { + stack: 'docker://cloudfoundry/cflinuxfs4', + buildpacks: ['ruby_buildpack'] + } + } + message = BuildCreateMessage.new(params) + expect(message).not_to be_valid + expect(message.errors[:lifecycle]).to include('Buildpack must be a custom buildpack when using a custom stack') + end + end end describe 'docker lifecycle' do diff --git a/spec/unit/models/runtime/feature_flag_spec.rb b/spec/unit/models/runtime/feature_flag_spec.rb index 458f93c4cdd..a7bbb6d5a7a 100644 --- a/spec/unit/models/runtime/feature_flag_spec.rb +++ b/spec/unit/models/runtime/feature_flag_spec.rb @@ -168,6 +168,13 @@ module VCAP::CloudController end end end + + context 'when the diego_custom_stacks feature flag is not overridden' do + it 'returns the default value' do + expect(FeatureFlag.enabled?(:diego_custom_stacks)).to be(false) + expect(FeatureFlag.disabled?(:diego_custom_stacks)).to be(true) + end + end end describe '.raise_unless_enabled!' do