diff --git a/app/actions/build_create.rb b/app/actions/build_create.rb index f43ab50b572..8e9f12095bf 100644 --- a/app/actions/build_create.rb +++ b/app/actions/build_create.rb @@ -184,6 +184,7 @@ def staging_in_progress! def validate_stack_state!(lifecycle, app) return [] if lifecycle.type == Lifecycles::DOCKER + return [] if UriUtils.is_custom_stack_uri?(lifecycle.staging_stack) stack = Stack.find(name: lifecycle.staging_stack) return [] unless stack diff --git a/app/controllers/v3/apps_controller.rb b/app/controllers/v3/apps_controller.rb index 7704f7f2f3f..0b6f385a858 100644 --- a/app/controllers/v3/apps_controller.rb +++ b/app/controllers/v3/apps_controller.rb @@ -98,6 +98,7 @@ def create FeatureFlag.raise_unless_enabled!(:diego_docker) if message.lifecycle_type == VCAP::CloudController::PackageModel::DOCKER_TYPE lifecycle = AppLifecycleProvider.provide_for_create(message) FeatureFlag.raise_unless_enabled!(:diego_cnb) if lifecycle.type == VCAP::CloudController::Lifecycles::CNB + raise_unless_custom_stack_enabled!(custom_stack_from_message(message)) unprocessable!(lifecycle.errors.full_messages) unless lifecycle.valid? app_creator = AppCreate.new(user_audit_info) @@ -128,6 +129,7 @@ def update require_writable_space!(space) lifecycle = AppLifecycleProvider.provide_for_update(message, app) + raise_unless_custom_stack_enabled!(custom_stack_from_message(message)) unprocessable!(lifecycle.errors.full_messages) unless lifecycle.valid? app_updater = AppUpdate.new(user_audit_info) @@ -188,6 +190,7 @@ def start FeatureFlag.raise_unless_enabled!(:diego_docker) if app.lifecycle_type == DockerLifecycleDataModel::LIFECYCLE_TYPE FeatureFlag.raise_unless_enabled!(:diego_cnb) if app.lifecycle_type == CNBLifecycleDataModel::LIFECYCLE_TYPE + raise_unless_custom_stack_enabled!(app.lifecycle_data.stack) if app.lifecycle_data.respond_to?(:stack) AppStart.start(app:, user_audit_info:) TelemetryLogger.v3_emit( @@ -231,6 +234,7 @@ def restart FeatureFlag.raise_unless_enabled!(:diego_docker) if app.lifecycle_type == DockerLifecycleDataModel::LIFECYCLE_TYPE FeatureFlag.raise_unless_enabled!(:diego_cnb) if app.lifecycle_type == CNBLifecycleDataModel::LIFECYCLE_TYPE + raise_unless_custom_stack_enabled!(app.lifecycle_data.stack) if app.lifecycle_data.respond_to?(:stack) AppRestart.restart(app: app, config: Config.config, user_audit_info: user_audit_info) TelemetryLogger.v3_emit( @@ -397,6 +401,14 @@ def translate_error(e) private + def raise_unless_custom_stack_enabled!(stack) + FeatureFlag.raise_unless_enabled!(:diego_custom_stacks) if stack && UriUtils.is_custom_stack_uri?(stack) + end + + def custom_stack_from_message(message) + message.lifecycle_data&.dig(:stack) + end + def async_recursive_delete_enabled? !!VCAP::CloudController::Config.config.get(:temporary_enable_async_recursive_delete, :apps) end diff --git a/app/fetchers/buildpack_lifecycle_fetcher.rb b/app/fetchers/buildpack_lifecycle_fetcher.rb index f5d399a64e2..0edb210280e 100644 --- a/app/fetchers/buildpack_lifecycle_fetcher.rb +++ b/app/fetchers/buildpack_lifecycle_fetcher.rb @@ -4,6 +4,8 @@ module VCAP::CloudController class BuildpackLifecycleFetcher class << self def fetch(buildpack_names, stack_name, lifecycle=Config.config.get(:default_app_lifecycle)) + return { stack: nil, buildpack_infos: custom_stack_buildpacks(buildpack_names, lifecycle) } if UriUtils.is_custom_stack_uri?(stack_name) + { stack: Stack.find(name: stack_name), buildpack_infos: ordered_buildpacks(buildpack_names, stack_name, lifecycle) @@ -12,6 +14,15 @@ def fetch(buildpack_names, stack_name, lifecycle=Config.config.get(:default_app_ private + def custom_stack_buildpacks(buildpack_names, lifecycle) + buildpacks_without_stacks = Buildpack.list_admin_buildpacks(nil, lifecycle).select { |buildpack| buildpack.stack.nil? } + + Array(buildpack_names).map do |buildpack_name| + buildpack_record = buildpacks_without_stacks.find { |buildpack| buildpack.name == buildpack_name } + BuildpackInfo.new(buildpack_name, buildpack_record) + end + end + def ordered_buildpacks(buildpack_names, stack_name, lifecycle) buildpacks_with_stacks, buildpacks_without_stacks = Buildpack.list_admin_buildpacks(stack_name, lifecycle).partition(&:stack) diff --git a/app/messages/app_manifest_message.rb b/app/messages/app_manifest_message.rb index 0f44fc387a8..57998e02864 100644 --- a/app/messages/app_manifest_message.rb +++ b/app/messages/app_manifest_message.rb @@ -75,6 +75,7 @@ def self.underscore_keys(hash) validate :validate_buildpack_and_buildpacks_combination! validate :validate_docker_enabled! validate :validate_cnb_enabled! + validate :validate_custom_stacks_enabled! validate :validate_docker_buildpacks_combination! validate :validate_credentials_combination! validate :validate_service_bindings_message!, if: ->(record) { record.requested?(:services) } @@ -488,6 +489,12 @@ def validate_cnb_enabled! errors.add(:base, e.message) end + def validate_custom_stacks_enabled! + FeatureFlag.raise_unless_enabled!(:diego_custom_stacks) if requested?(:stack) && UriUtils.is_custom_stack_uri?(@stack) + rescue CloudController::Errors::ApiError => e + errors.add(:base, e.message) + end + def validate_docker_buildpacks_combination! return unless requested?(:docker) && (requested?(:buildpack) || requested?(:buildpacks)) diff --git a/app/models/runtime/feature_flag.rb b/app/models/runtime/feature_flag.rb index e64b7d60e7b..a7039c16552 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/docs/v3/source/includes/concepts/_lifecycles.md.erb b/docs/v3/source/includes/concepts/_lifecycles.md.erb index 707a859957a..a5216b29112 100644 --- a/docs/v3/source/includes/concepts/_lifecycles.md.erb +++ b/docs/v3/source/includes/concepts/_lifecycles.md.erb @@ -40,8 +40,8 @@ Name | Type | Description ---- | ---- | ----------- **type** | _string_ | `buildpack` **data.buildpacks** | _list of strings_ | A list of the names of buildpacks, URLs from which they may be downloaded, or `null` to auto-detect a suitable buildpack during staging -**data.stack** | _string_ | The root filesystem to use with the buildpack, for example `cflinuxfs4` -**data.credentials** | _object_ | Credentials used to download custom buildpacks from private git repositories, keyed by host. Each entry is a username/password pair. If any buildpack URL embeds credentials, **data.credentials** must be empty; provide credentials in one place only. +**data.stack** | _string_ | The root filesystem to use with the buildpack, for example `cflinuxfs4`. Can also be a container image URI with `docker://` scheme (e.g. `docker://docker.io/cloudfoundry/cflinuxfs4:1.268.0`) when the `diego_custom_stacks` feature flag is enabled. When using a custom stack, buildpacks must be custom (URL-based) or enabled admin buildpacks without a pinned stack. If no buildpacks are specified, at least one enabled unpinned admin buildpack must exist for auto-detection. +**data.credentials** | _object_ | Credentials used to download custom buildpacks from private git repositories or pull a custom stack from a private registry, keyed by host. Each entry is a username/password pair. If any buildpack URL embeds credentials, **data.credentials** must be empty; provide credentials in one place only. For records whose historical lifecycle data is no longer available, **data.buildpacks** may be an empty list and **data.stack** may be `null`. @@ -84,8 +84,8 @@ Name | Type | Description ---- | ---- | ----------- **type** | _string_ | `cnb` **data.buildpacks** | _list of strings_ | A list of URLs with either `docker://` or `http(s)://` scheme, pointing to the Cloud Native Buildpack OCI image.
When the scheme is `http(s)://`, an OCI tarball is expected to be present at the specified location. -**data.credentials** | _object_ | Credentials used to download the configured buildpacks, keyed by registry host. Each entry contains either a username/password pair or a token. CNB buildpack URLs must not embed credentials; use **data.credentials** instead. -**data.stack** | _string_ | The root filesystem to use with the buildpack, for example `cflinuxfs4` +**data.credentials** | _object_ | Credentials used to download the configured buildpacks or pull a custom stack, keyed by registry host. Each entry contains either a username/password pair or a token. Only a username/password pair matching a custom stack's registry is sent to Diego to pull the stack; token entries are used for CNB buildpacks. CNB buildpack URLs must not embed credentials; use **data.credentials** instead. +**data.stack** | _string_ | The root filesystem to use with the buildpack, for example `cflinuxfs4`. Can also be a container image URI with `docker://` scheme when the `diego_custom_stacks` feature flag is enabled. When using a custom stack, buildpacks must be custom (URL-based) or enabled admin buildpacks without a pinned stack. If no buildpacks are specified, at least one enabled unpinned CNB admin buildpack must exist for auto-detection. For records whose historical lifecycle data is no longer available, **data.buildpacks** may be an empty list and **data.stack** may be `null`. diff --git a/docs/v3/source/includes/resources/feature_flags/_flags.md.erb b/docs/v3/source/includes/resources/feature_flags/_flags.md.erb index 37faf69cb63..524740be7c2 100644 --- a/docs/v3/source/includes/resources/feature_flags/_flags.md.erb +++ b/docs/v3/source/includes/resources/feature_flags/_flags.md.erb @@ -4,6 +4,7 @@ ["app_scaling", "true", "When enabled, space developers can perform scaling operations (i.e. change memory, disk, log rate, or instances). When disabled, only admins can perform scaling operations."], ["diego_docker", "false", "When enabled, Docker applications are supported by Diego. When disabled, Docker applications will stop running. It will still be possible to stop and delete them and update their configurations."], ["diego_cnb (experimental)", "false", "When enabled, the staging process using Cloud Native Buildpacks is supported."], +["diego_custom_stacks", "false", "When enabled, users can specify a container image URI (docker://...) as the stack for buildpack and CNB lifecycle apps. This allows users to bring their own stacks independent of the platform-provided stacks. When disabled, only platform-provided stacks registered in the stacks table can be used."], ["env_var_visibility", "true", "When enabled, all users can see their environment variables. When disabled, no users can see environment variables."], ["hide_marketplace_from_unauthenticated_users", "false", "When enabled, service offerings available in the marketplace will be hidden from unauthenticated users. When disabled, unauthenticated users will be able to see the service offerings available in the marketplace."], ["private_domain_creation", "true", "When enabled, an organization manager can create private domains for that organization. When disabled, only admin users can create private domains."], diff --git a/docs/v3/source/includes/resources/manifests/_object.md.erb b/docs/v3/source/includes/resources/manifests/_object.md.erb index 8727c331be8..67e712d0c70 100644 --- a/docs/v3/source/includes/resources/manifests/_object.md.erb +++ b/docs/v3/source/includes/resources/manifests/_object.md.erb @@ -105,7 +105,7 @@ Name | Type | Description **name** | _string_ | Name of the app **lifecycle** | _string_ | Type of the lifecycle; valid values are `buildpack`, `cnb`, `docker`. Defaults to `buildpack`, unless the **docker** property is present. **buildpacks** | _list of strings_ | a) An empty array, which will automatically select the appropriate default buildpack according to the coding language (incompatible with **lifecycle: cnb**)
b) An array of one or more URLs pointing to buildpacks
c) An array of one or more installed buildpack names
Replaces the legacy `buildpack` field -**credentials** | _object_ | Credentials used to download custom buildpacks (buildpack lifecycle) or buildpack OCI images (cnb lifecycle) from private git repositories or registries, keyed by host. For the buildpack lifecycle each entry is a username/password pair; for the cnb lifecycle each entry is a username/password pair or a token. Set to `null` to clear previously stored credentials. If any buildpack URL embeds credentials, **credentials** must be empty; provide credentials in one place only. +**credentials** | _object_ | Credentials used to download custom buildpacks (buildpack lifecycle), buildpack OCI images (cnb lifecycle), or pull a custom stack from a private registry, keyed by host. For the buildpack lifecycle each entry is a username/password pair; for the cnb lifecycle each entry is a username/password pair or a token. Only a username/password pair matching a custom stack's registry is sent to Diego to pull the stack. Set to `null` to clear previously stored credentials. If any buildpack URL embeds credentials, **credentials** must be empty; provide credentials in one place only. **cnb-credentials** | _object_ | Backward-compatible alias for **credentials**. Specifying both **credentials** and **cnb-credentials** is an error. **docker** | _object_ | If present, the created app will have Docker lifecycle type; the value of this key is ignored by the API but may be used by clients to source the registry address of the image and credentials, if needed; the [generate manifest endpoint](#generate-a-manifest-for-an-app) will return the registry address of the image and username provided with this key **env** | _object_ | A key-value mapping of environment variables to be used for the app when running @@ -117,7 +117,7 @@ Name | Type | Description **routes** | _array of [route configurations](#space-manifest-route-level-configuration)_ | List declaring HTTP and TCP routes to be mapped to the app. **services** | _array of [service configurations](#space-manifest-service-level-configuration)_ | A list of service-instances to bind to the app **sidecars** | _array of [sidecar configurations](#space-manifest-sidecar-level-configuration)_ | A list of configurations for individual sidecars -**stack** | _string_ | The root filesystem to use with the buildpack, for example `cflinuxfs4` +**stack** | _string_ | The root filesystem to use with the buildpack, for example `cflinuxfs4`. When the `diego_custom_stacks` feature flag is enabled, this can also be a `docker://` container image URI. Custom stack URIs must not include credentials; use **credentials** instead. When using a custom stack with the buildpack lifecycle, buildpacks must be custom (URL-based) or admin buildpacks without a pinned stack. **metadata.labels** | _[label object](#labels)_ | Labels applied to the app **metadata.annotations** | _[annotation object](#annotations)_ | Annotations applied to the app **buildpack** | _string_ | **DEPRECATED in favor of the `buildpacks` field above** diff --git a/lib/cloud_controller/diego/app_recipe_builder.rb b/lib/cloud_controller/diego/app_recipe_builder.rb index 25a4c10866f..039d8fae872 100644 --- a/lib/cloud_controller/diego/app_recipe_builder.rb +++ b/lib/cloud_controller/diego/app_recipe_builder.rb @@ -4,6 +4,7 @@ require 'cloud_controller/diego/buildpack/desired_lrp_builder' require 'cloud_controller/diego/docker/desired_lrp_builder' require 'cloud_controller/diego/cnb/desired_lrp_builder' +require 'cloud_controller/diego/image_credential_resolver' require 'cloud_controller/diego/process_guid' require 'cloud_controller/diego/ssh_key' require 'cloud_controller/diego/service_binding_files_builder' @@ -101,14 +102,25 @@ def app_lrp_arguments certificate_properties: ::Diego::Bbs::Models::CertificateProperties.new( organizational_unit: ["organization:#{process.organization.guid}", "space:#{process.space.guid}", "app:#{process.app_guid}"] ), - image_username: process.desired_droplet.docker_receipt_username, - image_password: process.desired_droplet.docker_receipt_password, + image_username: lrp_image_credentials[0], + image_password: lrp_image_credentials[1], volume_mounted_files: ServiceBindingFilesBuilder.build(process) }.compact rescue ServiceBindingFilesBuilder::IncompatibleBindings => e raise CloudController::Errors::ApiError.new_from_details('UnprocessableEntity', "Cannot build service binding files for app - #{e.message}") end + def lrp_image_credentials + @lrp_image_credentials ||= begin + droplet = process.desired_droplet + ImageCredentialResolver.resolve( + primary_username: droplet.docker_receipt_username, + primary_password: droplet.docker_receipt_password, + lifecycle_data: process.app.lifecycle_data + ) + end + end + def metric_tags(process) tags = { 'source_id' => METRIC_TAG_VALUE.new(static: process.app_guid), diff --git a/lib/cloud_controller/diego/buildpack/desired_lrp_builder.rb b/lib/cloud_controller/diego/buildpack/desired_lrp_builder.rb index b1337fc5f32..7e81e558d0a 100644 --- a/lib/cloud_controller/diego/buildpack/desired_lrp_builder.rb +++ b/lib/cloud_controller/diego/buildpack/desired_lrp_builder.rb @@ -1,3 +1,7 @@ +require 'cloud_controller/diego/docker/docker_uri_converter' +require 'cloud_controller/diego/lifecycle_bundle_stack_resolver' +require 'utils/uri_utils' + module VCAP::CloudController module Diego module Buildpack @@ -25,7 +29,6 @@ def initialize(config, opts) def cached_dependencies return nil if @config.get(:diego, :enable_declarative_asset_downloads) - lifecycle_bundle_key = :"buildpack/#{@stack}" lifecycle_bundle = @config.get(:diego, :lifecycle_bundles)[lifecycle_bundle_key] raise InvalidStack.new("no compiler defined for requested stack '#{@stack}'") unless lifecycle_bundle @@ -33,12 +36,14 @@ def cached_dependencies ::Diego::Bbs::Models::CachedDependency.new( from: LifecycleBundleUriGenerator.uri(lifecycle_bundle), to: '/tmp/lifecycle', - cache_key: "buildpack-#{@stack}-lifecycle" + cache_key: "buildpack-#{lifecycle_bundle_stack}-lifecycle" ) ] end def root_fs + return DockerURIConverter.new.convert(@stack.delete_prefix('docker://')) if UriUtils.is_custom_stack_uri?(@stack) + @stack_obj ||= Stack.find(name: @stack) raise CloudController::Errors::ApiError.new_from_details('StackNotFound', @stack) unless @stack_obj @@ -64,16 +69,15 @@ def setup def image_layers return [] unless @config.get(:diego, :enable_declarative_asset_downloads) - lifecycle_bundle_key = :"buildpack/#{@stack}" lifecycle_bundle = @config.get(:diego, :lifecycle_bundles)[lifecycle_bundle_key] raise InvalidStack.new("no compiler defined for requested stack '#{@stack}'") unless lifecycle_bundle - destination = @config.get(:diego, :droplet_destinations)[@stack.to_sym] + destination = @config.get(:diego, :droplet_destinations)[lifecycle_bundle_stack.to_sym] raise InvalidStack.new("no droplet destination defined for requested stack '#{@stack}'") unless destination layers = [ ::Diego::Bbs::Models::ImageLayer.new( - name: "buildpack-#{@stack}-lifecycle", + name: "buildpack-#{lifecycle_bundle_stack}-lifecycle", url: LifecycleBundleUriGenerator.uri(lifecycle_bundle), destination_path: '/tmp/lifecycle', layer_type: ::Diego::Bbs::Models::ImageLayer::Type::SHARED, @@ -117,6 +121,16 @@ def port_environment_variables def privileged? @config.get(:diego, :use_privileged_containers_for_running) end + + private + + def lifecycle_bundle_key + :"buildpack/#{lifecycle_bundle_stack}" + end + + def lifecycle_bundle_stack + LifecycleBundleStackResolver.resolve(@stack) + 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 1331b838543..d1ca7a82ef6 100644 --- a/lib/cloud_controller/diego/buildpack/task_action_builder.rb +++ b/lib/cloud_controller/diego/buildpack/task_action_builder.rb @@ -1,5 +1,7 @@ require 'diego/action_builder' require 'cloud_controller/diego/task_environment_variable_collector' +require 'cloud_controller/diego/docker/docker_uri_converter' +require 'cloud_controller/diego/lifecycle_bundle_stack_resolver' require 'credhub/config_helpers' module VCAP::CloudController @@ -58,12 +60,12 @@ def action def image_layers return [] unless @config.get(:diego, :enable_declarative_asset_downloads) - destination = @config.get(:diego, :droplet_destinations)[lifecycle_stack.to_sym] + destination = @config.get(:diego, :droplet_destinations)[lifecycle_bundle_stack.to_sym] raise InvalidStack.new("no droplet destination defined for requested stack '#{lifecycle_stack}'") unless destination layers = [ ::Diego::Bbs::Models::ImageLayer.new( - name: "buildpack-#{lifecycle_stack}-lifecycle", + name: "buildpack-#{lifecycle_bundle_stack}-lifecycle", url: LifecycleBundleUriGenerator.uri(config.get(:diego, :lifecycle_bundles)[lifecycle_bundle_key]), destination_path: '/tmp/lifecycle', layer_type: ::Diego::Bbs::Models::ImageLayer::Type::SHARED, @@ -91,6 +93,8 @@ def task_environment_variables end def stack + return DockerURIConverter.new.convert(lifecycle_stack.delete_prefix('docker://')) if UriUtils.is_custom_stack_uri?(lifecycle_stack) + @stack ||= Stack.find(name: lifecycle_stack) raise CloudController::Errors::ApiError.new_from_details('StackNotFound', lifecycle_stack) unless @stack @@ -103,12 +107,12 @@ def cached_dependencies [::Diego::Bbs::Models::CachedDependency.new( from: LifecycleBundleUriGenerator.uri(config.get(:diego, :lifecycle_bundles)[lifecycle_bundle_key]), to: '/tmp/lifecycle', - cache_key: "#{@prefix}-#{lifecycle_stack}-lifecycle" + cache_key: "#{@prefix}-#{lifecycle_bundle_stack}-lifecycle" )] end def lifecycle_bundle_key - :"#{@prefix}/#{lifecycle_stack}" + :"#{@prefix}/#{lifecycle_bundle_stack}" end private @@ -118,6 +122,10 @@ def lifecycle_bundle_key def lifecycle_stack lifecycle_data[:stack] end + + def lifecycle_bundle_stack + LifecycleBundleStackResolver.resolve(lifecycle_stack) + 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 1c81ce430ce..08643e458c4 100644 --- a/lib/cloud_controller/diego/cnb/desired_lrp_builder.rb +++ b/lib/cloud_controller/diego/cnb/desired_lrp_builder.rb @@ -1,3 +1,7 @@ +require 'cloud_controller/diego/docker/docker_uri_converter' +require 'cloud_controller/diego/lifecycle_bundle_stack_resolver' +require 'utils/uri_utils' + module VCAP::CloudController module Diego module CNB @@ -25,7 +29,6 @@ def initialize(config, opts) def cached_dependencies return nil if @config.get(:diego, :enable_declarative_asset_downloads) - lifecycle_bundle_key = :"cnb/#{@stack}" lifecycle_bundle = @config.get(:diego, :lifecycle_bundles)[lifecycle_bundle_key] raise InvalidStack.new("no compiler defined for requested stack '#{@stack}'") unless lifecycle_bundle @@ -33,12 +36,14 @@ def cached_dependencies ::Diego::Bbs::Models::CachedDependency.new( from: LifecycleBundleUriGenerator.uri(lifecycle_bundle), to: '/tmp/lifecycle', - cache_key: "cnb-#{@stack}-lifecycle" + cache_key: "cnb-#{lifecycle_bundle_stack}-lifecycle" ) ] end def root_fs + return DockerURIConverter.new.convert(@stack.delete_prefix('docker://')) if UriUtils.is_custom_stack_uri?(@stack) + @stack_obj ||= Stack.find(name: @stack) raise CloudController::Errors::ApiError.new_from_details('StackNotFound', @stack) unless @stack_obj @@ -64,16 +69,15 @@ def setup def image_layers return [] unless @config.get(:diego, :enable_declarative_asset_downloads) - lifecycle_bundle_key = :"cnb/#{@stack}" lifecycle_bundle = @config.get(:diego, :lifecycle_bundles)[lifecycle_bundle_key] raise InvalidStack.new("no compiler defined for requested stack '#{@stack}'") unless lifecycle_bundle - destination = @config.get(:diego, :droplet_destinations)[@stack.to_sym] + destination = @config.get(:diego, :droplet_destinations)[lifecycle_bundle_stack.to_sym] raise InvalidStack.new("no droplet destination defined for requested stack '#{@stack}'") unless destination layers = [ ::Diego::Bbs::Models::ImageLayer.new( - name: "cnb-#{@stack}-lifecycle", + name: "cnb-#{lifecycle_bundle_stack}-lifecycle", url: LifecycleBundleUriGenerator.uri(lifecycle_bundle), destination_path: '/tmp/lifecycle', layer_type: ::Diego::Bbs::Models::ImageLayer::Type::SHARED, @@ -118,6 +122,14 @@ def privileged? private + def lifecycle_bundle_key + :"cnb/#{lifecycle_bundle_stack}" + end + + def lifecycle_bundle_stack + LifecycleBundleStackResolver.resolve(@stack) + end + def default_container_env [ ::Diego::Bbs::Models::EnvironmentVariable.new(name: 'LANG', value: DEFAULT_LANG), diff --git a/lib/cloud_controller/diego/cnb/staging_action_builder.rb b/lib/cloud_controller/diego/cnb/staging_action_builder.rb index 34d750d04ea..a73fef929fa 100644 --- a/lib/cloud_controller/diego/cnb/staging_action_builder.rb +++ b/lib/cloud_controller/diego/cnb/staging_action_builder.rb @@ -15,9 +15,15 @@ def task_environment_variables env = [ ::Diego::Bbs::Models::EnvironmentVariable.new(name: 'CNB_USER_ID', value: '2000'), ::Diego::Bbs::Models::EnvironmentVariable.new(name: 'CNB_GROUP_ID', value: '2000'), - ::Diego::Bbs::Models::EnvironmentVariable.new(name: 'CNB_STACK_ID', value: lifecycle_stack), ::Diego::Bbs::Models::EnvironmentVariable.new(name: 'LANG', value: STAGING_DEFAULT_LANG) ] + # TODO: Verify this compatibility fallback end-to-end with archived stacks and + # buildpacks. Legacy CNB buildpacks require CNB_STACK_ID, while newer buildpacks + # use target metadata. For custom stacks, the full image URI is the only stable + # value currently persisted by Cloud Controller. Strict legacy buildpacks may + # require an explicit logical stack ID instead, which would need a different API. + cnb_stack_id = lifecycle_stack + env.push(::Diego::Bbs::Models::EnvironmentVariable.new(name: 'CNB_STACK_ID', value: cnb_stack_id)) if cnb_stack_id env.push(::Diego::Bbs::Models::EnvironmentVariable.new(name: 'CNB_REGISTRY_CREDS', value: lifecycle_data[:credentials])) if lifecycle_data[:credentials] env end diff --git a/lib/cloud_controller/diego/image_credential_resolver.rb b/lib/cloud_controller/diego/image_credential_resolver.rb new file mode 100644 index 00000000000..8df9dfc1ad3 --- /dev/null +++ b/lib/cloud_controller/diego/image_credential_resolver.rb @@ -0,0 +1,59 @@ +require 'utils/uri_utils' + +module VCAP::CloudController + module Diego + module ImageCredentialResolver + module_function + + # Returns [username, password], falling back to custom stack registry credentials. + def resolve(primary_username:, primary_password:, lifecycle_data:) + return [primary_username, primary_password] if primary_username.present? && primary_password.present? + return [primary_username, primary_password] unless lifecycle_data.respond_to?(:stack) && custom_stack?(lifecycle_data.stack) + + resolve_from_custom_stack(lifecycle_data) + end + + # Like resolve, but for staging where the lifecycle object provides :staging_stack. + def resolve_for_staging(primary_username:, primary_password:, lifecycle:) + return [primary_username, primary_password] if primary_username.present? && primary_password.present? + return [nil, nil] unless lifecycle.respond_to?(:credentials_for_staging) && lifecycle.respond_to?(:staging_stack) + return [primary_username, primary_password] unless custom_stack?(lifecycle.staging_stack) + + stack_host = UriUtils.custom_stack_registry_host(lifecycle.staging_stack) + return [nil, nil] unless stack_host + + extract_credentials(lifecycle.credentials_for_staging, stack_host) + end + + def resolve_from_custom_stack(lifecycle_data) + return [nil, nil] unless lifecycle_data.respond_to?(:credentials) && lifecycle_data.respond_to?(:stack) + + stack_host = UriUtils.custom_stack_registry_host(lifecycle_data.stack) + return [nil, nil] unless stack_host + + extract_credentials(lifecycle_data.credentials, stack_host) + end + private_class_method :resolve_from_custom_stack + + def custom_stack?(stack) + UriUtils.is_custom_stack_uri?(stack) + end + private_class_method :custom_stack? + + def extract_credentials(credentials, host) + return [nil, nil] unless credentials.is_a?(Hash) + + credentials = credentials.deep_stringify_keys + creds = credentials[host] + return [nil, nil] unless creds.is_a?(Hash) + + username = creds['username'] + password = creds['password'] + return [nil, nil] unless username.present? && password.present? + + [username, password] + end + private_class_method :extract_credentials + end + end +end diff --git a/lib/cloud_controller/diego/lifecycle_bundle_stack_resolver.rb b/lib/cloud_controller/diego/lifecycle_bundle_stack_resolver.rb new file mode 100644 index 00000000000..1fc020db52c --- /dev/null +++ b/lib/cloud_controller/diego/lifecycle_bundle_stack_resolver.rb @@ -0,0 +1,13 @@ +require 'utils/uri_utils' + +module VCAP::CloudController + module Diego + # Selects the installed stack used to obtain lifecycle bundles and droplet destinations. + # A custom stack remains the Diego rootfs; it uses the default stack only for these assets. + class LifecycleBundleStackResolver + def self.resolve(stack) + UriUtils.is_custom_stack_uri?(stack) ? VCAP::CloudController::Stack.default.name : stack + end + end + end +end diff --git a/lib/cloud_controller/diego/lifecycle_protocol.rb b/lib/cloud_controller/diego/lifecycle_protocol.rb index de76fbe3db8..7c97c143345 100644 --- a/lib/cloud_controller/diego/lifecycle_protocol.rb +++ b/lib/cloud_controller/diego/lifecycle_protocol.rb @@ -30,9 +30,6 @@ def lifecycle_data(staging_details) lifecycle_data = new_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.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, @@ -41,6 +38,11 @@ def lifecycle_data(staging_details) ) lifecycle_data.stack = stack + cache_key = UriUtils.buildpack_cache_key(stack) + 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, cache_key) + lifecycle_data.build_artifacts_cache_upload_uri = @blobstore_url_generator.buildpack_cache_upload_url(staging_details.package.app_guid, cache_key) + lifecycle_data.message rescue Membrane::SchemaValidationError => e raise e unless e.message.match?(/app_bits_download_uri/) diff --git a/lib/cloud_controller/diego/lifecycles/app_base_lifecycle.rb b/lib/cloud_controller/diego/lifecycles/app_base_lifecycle.rb index e94fd6308b4..02654d89777 100644 --- a/lib/cloud_controller/diego/lifecycles/app_base_lifecycle.rb +++ b/lib/cloud_controller/diego/lifecycles/app_base_lifecycle.rb @@ -6,6 +6,7 @@ module VCAP::CloudController class AppBaseLifecycle def initialize(message, app=nil) @message = message + @app_stack = app&.lifecycle_data&.stack validation_data = lifecycle_data_for_validation(app) db_result = BuildpackLifecycleFetcher.fetch(validation_data[:buildpacks], validation_data[:stack], type) @@ -13,6 +14,8 @@ def initialize(message, app=nil) @validator = validator_class.new( buildpack_infos: db_result[:buildpack_infos], stack: db_result[:stack], + stack_name: stack, + lifecycle_type: type, credentials: effective_credentials(app), credentials_from_message: message.buildpack_data.requested?(:credentials) ) @@ -68,6 +71,8 @@ def buildpacks def stack if message.buildpack_data.requested?(:stack) && !message.buildpack_data.stack.nil? message.buildpack_data.stack + elsif message.buildpack_data.requested?(:buildpacks) || validating_stored_buildpacks? + @app_stack || Stack.default.name else Stack.default.name 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 7196895458c..71aa45ce8c1 100644 --- a/lib/cloud_controller/diego/lifecycles/buildpack_lifecycle_data_validator.rb +++ b/lib/cloud_controller/diego/lifecycles/buildpack_lifecycle_data_validator.rb @@ -4,10 +4,13 @@ module VCAP::CloudController class BuildpackLifecycleDataValidator include ActiveModel::Model - attr_accessor :buildpack_infos, :stack, :credentials, :credentials_from_message + attr_accessor :buildpack_infos, :stack, :stack_name, :lifecycle_type, :credentials, :credentials_from_message validate :buildpacks_are_uri_or_nil + validate :custom_stack_uri_is_valid validate :stack_exists_in_db + validate :custom_stack_has_auto_detect_buildpack + validate :no_custom_stack_userinfo validate :no_dual_credential_sources def buildpacks_are_uri_or_nil @@ -25,9 +28,32 @@ def buildpacks_are_uri_or_nil end def stack_exists_in_db + return if UriUtils.is_custom_stack_uri?(stack_name) && FeatureFlag.enabled?(:diego_custom_stacks) + return if custom_stack_uri?(stack_name) && !UriUtils.is_custom_stack_uri?(stack_name) + errors.add(:stack, 'must be an existing stack') if stack.nil? end + def custom_stack_uri_is_valid + return unless custom_stack_uri?(stack_name) + return if UriUtils.is_custom_stack_uri?(stack_name) + + errors.add(:stack, 'must be a valid container image URI') + end + + def no_custom_stack_userinfo + return unless UriUtils.is_custom_stack_uri?(stack_name) && UriUtils.url_has_userinfo?(stack_name) + + errors.add(:stack, 'Custom stack URIs must not contain credentials; use data.credentials instead') + end + + def custom_stack_has_auto_detect_buildpack + return unless UriUtils.is_custom_stack_uri?(stack_name) && buildpack_infos.empty? + return if unpinned_admin_buildpack_exists? + + errors.add(:buildpack, 'must specify a custom buildpack URL or an enabled unpinned admin buildpack when using a custom stack') + end + def no_dual_credential_sources return unless url_has_userinfo? && credentials.present? @@ -41,8 +67,17 @@ def no_dual_credential_sources private + def unpinned_admin_buildpack_exists? + lifecycle = lifecycle_type || Config.config.get(:default_app_lifecycle) + Buildpack.list_admin_buildpacks(nil, lifecycle).any? { |buildpack| buildpack.stack.nil? && buildpack.enabled? } + end + def url_has_userinfo? buildpack_infos.any? { |bi| bi.buildpack_url && UriUtils.url_has_userinfo?(bi.buildpack_url) } end + + def custom_stack_uri?(stack_name) + stack_name.is_a?(String) && stack_name.start_with?('docker://') + end end end diff --git a/lib/cloud_controller/diego/lifecycles/lifecycle_base.rb b/lib/cloud_controller/diego/lifecycles/lifecycle_base.rb index 1e78212858a..85178b49c11 100644 --- a/lib/cloud_controller/diego/lifecycles/lifecycle_base.rb +++ b/lib/cloud_controller/diego/lifecycles/lifecycle_base.rb @@ -9,6 +9,8 @@ def initialize(package, staging_message) @staging_message = staging_message @package = package + assert_custom_stacks_enabled! + db_result = BuildpackLifecycleFetcher.fetch(buildpacks_to_use, staging_stack, type) @buildpack_infos = db_result[:buildpack_infos] @@ -18,6 +20,8 @@ def initialize(package, staging_message) @validator = validator_class.new( buildpack_infos: @buildpack_infos, stack: db_result[:stack], + stack_name: staging_stack, + lifecycle_type: type, credentials: @credentials_for_staging, credentials_from_message: staging_message.buildpack_data.requested?(:credentials) ) @@ -26,11 +30,15 @@ def initialize(package, staging_message) delegate :valid?, :errors, to: :validator def staging_stack - requested_stack || app_stack || VCAP::CloudController::Stack.default.name + @staging_stack ||= requested_stack || app_stack || VCAP::CloudController::Stack.default.name end private + def assert_custom_stacks_enabled! + FeatureFlag.raise_unless_enabled!(:diego_custom_stacks) if UriUtils.is_custom_stack_uri?(staging_stack) + end + def validator_class BuildpackLifecycleDataValidator end diff --git a/lib/cloud_controller/diego/staging_action_builder.rb b/lib/cloud_controller/diego/staging_action_builder.rb index 3cca3aca34b..e5ffb84a94c 100644 --- a/lib/cloud_controller/diego/staging_action_builder.rb +++ b/lib/cloud_controller/diego/staging_action_builder.rb @@ -1,6 +1,8 @@ require 'credhub/config_helpers' require 'diego/action_builder' require 'digest/xxhash' +require 'cloud_controller/diego/docker/docker_uri_converter' +require 'cloud_controller/diego/lifecycle_bundle_stack_resolver' module VCAP::CloudController module Diego @@ -130,10 +132,14 @@ def image_layers end def stack - @stack ||= Stack.find(name: lifecycle_stack) - raise CloudController::Errors::ApiError.new_from_details('StackNotFound', lifecycle_stack) unless @stack - - "preloaded:#{@stack.build_rootfs_image}" + @stack ||= if UriUtils.is_custom_stack_uri?(lifecycle_stack) + DockerURIConverter.new.convert(lifecycle_stack.delete_prefix('docker://')) + else + stack_obj = Stack.find(name: lifecycle_stack) + raise CloudController::Errors::ApiError.new_from_details('StackNotFound', lifecycle_stack) unless stack_obj + + "preloaded:#{stack_obj.build_rootfs_image}" + end end def lifecycle_stack @@ -172,21 +178,23 @@ def download_actions end def upload_actions - [ + actions = [ ::Diego::Bbs::Models::UploadAction.new( user: 'vcap', artifact: 'droplet', from: '/tmp/droplet', to: upload_droplet_uri.to_s - ), - - ::Diego::Bbs::Models::UploadAction.new( - user: 'vcap', - artifact: 'build artifacts cache', - from: @cache_source, - to: upload_buildpack_artifacts_cache_uri.to_s ) ] + + actions << ::Diego::Bbs::Models::UploadAction.new( + user: 'vcap', + artifact: 'build artifacts cache', + from: @cache_source, + to: upload_buildpack_artifacts_cache_uri.to_s + ) + + actions end def skip_detect? @@ -194,7 +202,7 @@ def skip_detect? end def lifecycle_bundle_key - :"#{@prefix}/#{lifecycle_data[:stack]}" + :"#{@prefix}/#{LifecycleBundleStackResolver.resolve(lifecycle_stack)}" end def upload_buildpack_artifacts_cache_uri diff --git a/lib/cloud_controller/diego/task_recipe_builder.rb b/lib/cloud_controller/diego/task_recipe_builder.rb index f1a4962095d..a1bbc46fc6c 100644 --- a/lib/cloud_controller/diego/task_recipe_builder.rb +++ b/lib/cloud_controller/diego/task_recipe_builder.rb @@ -3,6 +3,7 @@ require 'cloud_controller/diego/buildpack/task_action_builder' require 'cloud_controller/diego/docker/task_action_builder' require 'cloud_controller/diego/bbs_environment_builder' +require 'cloud_controller/diego/image_credential_resolver' require 'cloud_controller/diego/task_completion_callback_generator' require 'cloud_controller/diego/task_cpu_weight_calculator' require 'cloud_controller/diego/service_binding_files_builder' @@ -52,8 +53,8 @@ def build_app_task(config, task) "app:#{task.app_guid}" ] ), - image_username: task.droplet.docker_receipt_username, - image_password: task.droplet.docker_receipt_password, + image_username: task_image_credentials(task)[0], + image_password: task_image_credentials(task)[1], volume_mounted_files: ServiceBindingFilesBuilder.build(task.app) }.compact) rescue ServiceBindingFilesBuilder::IncompatibleBindings => e @@ -93,8 +94,8 @@ 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: staging_image_credentials(staging_details)[0], + image_password: staging_image_credentials(staging_details)[1], volume_mounted_files: ServiceBindingFilesBuilder.build(staging_details.package.app) }.compact) rescue ServiceBindingFilesBuilder::IncompatibleBindings => e @@ -103,6 +104,23 @@ def build_staging_task(config, staging_details) private + def task_image_credentials(task) + droplet = task.droplet + ImageCredentialResolver.resolve( + primary_username: droplet.docker_receipt_username, + primary_password: droplet.docker_receipt_password, + lifecycle_data: task.app.lifecycle_data + ) + end + + def staging_image_credentials(staging_details) + ImageCredentialResolver.resolve_for_staging( + primary_username: staging_details.package.docker_username, + primary_password: staging_details.package.docker_password, + lifecycle: staging_details.lifecycle + ) + end + def metric_tags(source) { 'source_id' => METRIC_TAG_VALUE.new(static: source.app_guid), diff --git a/lib/utils/uri_utils.rb b/lib/utils/uri_utils.rb index eb43d3a80e7..11fd1941525 100644 --- a/lib/utils/uri_utils.rb +++ b/lib/utils/uri_utils.rb @@ -1,4 +1,5 @@ require 'uri' +require 'digest' module UriUtils SSH_REGEX = %r{ \A (?:ssh://)? git@ .+? : .+? \.git \z }x @@ -44,6 +45,28 @@ def url_has_userinfo?(url) false end + def is_custom_stack_uri?(candidate) + !custom_stack_registry_host(candidate).nil? + end + + def custom_stack_registry_host(stack_uri) + return nil unless stack_uri.is_a?(String) && stack_uri.start_with?('docker://') + + body = stack_uri.delete_prefix('docker://') + return nil if body.empty? + + host, = parse_docker_uri(body) + host.presence || DOCKER_INDEX_SERVER + rescue InvalidDockerURI + nil + end + + def buildpack_cache_key(stack) + return stack unless is_custom_stack_uri?(stack) + + Digest::SHA256.hexdigest(stack) + end + def is_uri_path?(candidate) !!(candidate.is_a?(String) && candidate =~ %r{^(?:/|/([^\s/]\S*)?)$}) end @@ -75,6 +98,8 @@ def parse_docker_uri(docker_uri) path = docker_uri end + raise InvalidDockerURI.new "Invalid image name [#{docker_uri}]" if path.blank? + path = 'library/' + path if (official_docker_registry(name_parts[0]) || missing_registry(name_parts)) && path.exclude?('/') path, tag_digest = parse_docker_tag_digest_from_path(path) diff --git a/spec/api/documentation/feature_flags_api_spec.rb b/spec/api/documentation/feature_flags_api_spec.rb index 5fe6740eb30..77e54165f99 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(19) + expect(parsed_response.length).to eq(20) expect(parsed_response).to include( { 'name' => 'user_org_creation', diff --git a/spec/unit/controllers/v3/apps_controller_spec.rb b/spec/unit/controllers/v3/apps_controller_spec.rb index 7d7d7f281f4..e6e0cec2310 100644 --- a/spec/unit/controllers/v3/apps_controller_spec.rb +++ b/spec/unit/controllers/v3/apps_controller_spec.rb @@ -468,7 +468,8 @@ { 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: 'default-stack-name', + credentials: { 'registry.example.com' => { 'username' => 'user', 'password' => 'pass' } } } } } end @@ -571,6 +572,34 @@ end end end + + context 'when requesting a custom stack and diego_custom_stacks is disabled' do + let(:request_body) do + { + name: 'some-name', + relationships: { space: { data: { guid: space.guid } } }, + lifecycle: { + type: 'buildpack', + data: { + buildpacks: ['https://example.com/buildpack.git'], + stack: 'docker://registry.example.com/org/stack:tag' + } + } + } + end + + before do + create(:feature_flag, name: 'diego_custom_stacks', enabled: false, error_message: nil) + end + + it 'raises 403' do + post :create, params: request_body, as: :json + + expect(response).to have_http_status(:forbidden) + expect(response.body).to include('FeatureDisabled') + expect(response.body).to include('diego_custom_stacks') + end + end end describe '#update' do @@ -609,6 +638,32 @@ end end + context 'when updating to a custom stack and diego_custom_stacks is disabled' do + let(:request_body) do + { + lifecycle: { + type: 'buildpack', + data: { + buildpacks: ['https://example.com/buildpack.git'], + stack: 'docker://registry.example.com/org/stack:tag' + } + } + } + end + + before do + create(:feature_flag, name: 'diego_custom_stacks', enabled: false, error_message: nil) + end + + it 'raises 403' do + patch :update, params: { guid: app_model.guid }.merge(request_body), as: :json + + expect(response).to have_http_status(:forbidden) + expect(response.body).to include('FeatureDisabled') + expect(response.body).to include('diego_custom_stacks') + end + end + context 'lifecycle data' do let(:new_name) { 'potato' } @@ -1264,6 +1319,21 @@ end end end + + context 'when starting an app with a custom stack and diego_custom_stacks is disabled' do + before do + app_model.lifecycle_data.update(stack: 'docker://registry.example.com/org/stack:tag') + create(:feature_flag, name: 'diego_custom_stacks', enabled: false, error_message: nil) + end + + it 'raises 403' do + put :start, params: { guid: app_model.guid }, as: :json + + expect(response).to have_http_status(:forbidden) + expect(response.body).to include('FeatureDisabled') + expect(response.body).to include('diego_custom_stacks') + end + end end describe '#stop' do @@ -1367,6 +1437,24 @@ create(:buildpack_lifecycle_data_model, app: app_model, buildpacks: nil, stack: VCAP::CloudController::Stack.default.name) end + context 'when restarting an app with a custom stack and diego_custom_stacks is disabled' do + before do + set_current_user(user) + allow_user_read_access_for(user, spaces: [space]) + allow_user_write_access(user, space:) + app_model.lifecycle_data.update(stack: 'docker://registry.example.com/org/stack:tag') + create(:feature_flag, name: 'diego_custom_stacks', enabled: false, error_message: nil) + end + + it 'raises 403' do + post :restart, params: { guid: app_model.guid }, as: :json + + expect(response).to have_http_status(:forbidden) + expect(response.body).to include('FeatureDisabled') + expect(response.body).to include('diego_custom_stacks') + end + end + context 'permissions' do describe 'authorization' do role_to_expected_http_response = { diff --git a/spec/unit/fetchers/buildpack_lifecycle_fetcher_spec.rb b/spec/unit/fetchers/buildpack_lifecycle_fetcher_spec.rb index 999e97385b1..470af857d7c 100644 --- a/spec/unit/fetchers/buildpack_lifecycle_fetcher_spec.rb +++ b/spec/unit/fetchers/buildpack_lifecycle_fetcher_spec.rb @@ -51,6 +51,24 @@ module VCAP::CloudController end end + context 'with a custom stack' do + let(:custom_stack) { 'docker://registry.example.com/org/stack:tag' } + let!(:unpinned_buildpack) { create(:buildpack, :nil_stack, name: 'buildpack-3') } + + it 'returns unpinned admin buildpacks and custom buildpacks' do + returned_hash = BuildpackLifecycleFetcher.fetch([unpinned_buildpack.name, 'https://example.com/buildpack.git'], custom_stack) + + expect(returned_hash[:stack]).to be_nil + expect(returned_hash[:buildpack_infos].map(&:buildpack_record)).to eq([unpinned_buildpack, nil]) + end + + it 'does not return admin buildpacks pinned to another stack' do + returned_hash = BuildpackLifecycleFetcher.fetch([buildpack.name], custom_stack) + + expect(returned_hash[:buildpack_infos].first.buildpack_record).to be_nil + end + end + context 'when the stack and buildpack are not found' do it 'returns nil for both' do returned_hash = BuildpackLifecycleFetcher.fetch(['bogus-buildpack'], 'bogus-stack') diff --git a/spec/unit/lib/cloud_controller/diego/app_recipe_builder_spec.rb b/spec/unit/lib/cloud_controller/diego/app_recipe_builder_spec.rb index e75ebf58b10..66cc1b79180 100644 --- a/spec/unit/lib/cloud_controller/diego/app_recipe_builder_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/app_recipe_builder_spec.rb @@ -364,6 +364,21 @@ module Diego expect(lrp.image_layers).to eq(expected_image_layers) end + context 'when the app uses a custom stack with registry credentials' do + before do + app_model.lifecycle_data.update( + stack: 'docker://registry.example.com/my-org/custom-stack:latest', + credentials: { 'registry.example.com' => { 'username' => 'stack-user', 'password' => 'stack-password' } } + ) + end + + it 'passes the credentials to Diego' do + lrp = builder.build_app_lrp + expect(lrp.image_username).to eq('stack-user') + expect(lrp.image_password).to eq('stack-password') + end + end + context 'when the space is not entitled to any isolation segments' do before do allow(VCAP::CloudController::IsolationSegmentSelector).to receive(:for_space).and_return(nil) diff --git a/spec/unit/lib/cloud_controller/diego/buildpack/desired_lrp_builder_custom_stacks_spec.rb b/spec/unit/lib/cloud_controller/diego/buildpack/desired_lrp_builder_custom_stacks_spec.rb new file mode 100644 index 00000000000..aeeb07e6f2c --- /dev/null +++ b/spec/unit/lib/cloud_controller/diego/buildpack/desired_lrp_builder_custom_stacks_spec.rb @@ -0,0 +1,108 @@ +require 'spec_helper' +require 'cloud_controller/diego/buildpack/desired_lrp_builder' + +module VCAP::CloudController + module Diego + module Buildpack + RSpec.describe DesiredLrpBuilder do + let(:stack) { 'cflinuxfs4' } + let(:config) do + Config.new({ + diego: { + use_privileged_containers_for_running: false, + lifecycle_bundles: { "buildpack/#{stack}": 'http://lifecycle.example.com/bundle.tgz' }, + droplet_destinations: { stack.to_sym => '/home/vcap' }, + enable_declarative_asset_downloads: false + } + }) + end + let(:opts) do + { + stack: stack, + droplet_uri: 'http://droplet.example.com/droplet.tgz', + process_guid: 'process-guid-1', + droplet_hash: 'droplet-hash', + ports: [8080], + checksum_algorithm: 'sha256', + checksum_value: 'checksum-value', + start_command: './start', + action_user: 'vcap', + additional_container_env_vars: [] + } + end + + subject(:builder) { DesiredLrpBuilder.new(config, opts) } + + before do + VCAP::CloudController::Stack.find(name: 'cflinuxfs4') || VCAP::CloudController::Stack.create(name: 'cflinuxfs4') + end + + describe '#root_fs' do + context 'with a custom stack (docker:// URI)' do + let(:stack) { 'docker://docker.io/cloudfoundry/cflinuxfs4:1.268.0' } + let(:config) do + Config.new({ + diego: { + use_privileged_containers_for_running: false, + lifecycle_bundles: { "buildpack/#{VCAP::CloudController::Stack.default.name}": 'http://lifecycle.example.com/bundle.tgz' }, + droplet_destinations: { 'default-stack-name': '/home/vcap' }, + enable_declarative_asset_downloads: false + } + }) + end + + it 'returns a docker:// rootfs URI in Diego format' do + expect(builder.root_fs).to eq('docker://docker.io/cloudfoundry/cflinuxfs4#1.268.0') + end + end + end + + describe '#cached_dependencies' do + context 'with a custom stack' do + let(:stack) { 'docker://docker.io/cloudfoundry/cflinuxfs4:1.268.0' } + let(:config) do + Config.new({ + diego: { + use_privileged_containers_for_running: false, + lifecycle_bundles: { "buildpack/#{VCAP::CloudController::Stack.default.name}": 'http://lifecycle.example.com/bundle.tgz' }, + droplet_destinations: { 'default-stack-name': '/home/vcap' }, + enable_declarative_asset_downloads: false + } + }) + end + + it 'uses the default stack lifecycle bundle' do + allow(LifecycleBundleUriGenerator).to receive(:uri).and_return('http://lifecycle.example.com/bundle.tgz') + deps = builder.cached_dependencies + expect(deps.first.cache_key).to eq("buildpack-#{VCAP::CloudController::Stack.default.name}-lifecycle") + end + end + end + + describe '#image_layers' do + let(:config) do + Config.new({ + diego: { + use_privileged_containers_for_running: false, + lifecycle_bundles: { "buildpack/#{VCAP::CloudController::Stack.default.name}": 'http://lifecycle.example.com/bundle.tgz' }, + droplet_destinations: { 'default-stack-name': '/home/vcap' }, + enable_declarative_asset_downloads: true + } + }) + end + + context 'with a custom stack' do + let(:stack) { 'docker://docker.io/cloudfoundry/cflinuxfs4:1.268.0' } + + it 'uses the default stack for lifecycle bundle and droplet destination' do + allow(LifecycleBundleUriGenerator).to receive(:uri).and_return('http://lifecycle.example.com/bundle.tgz') + layers = builder.image_layers + lifecycle_layer = layers.find { |l| l.name.include?('lifecycle') } + expect(lifecycle_layer.name).to eq('buildpack-default-stack-name-lifecycle') + end + end + end + end + end + end +end diff --git a/spec/unit/lib/cloud_controller/diego/buildpack/lifecycle_protocol_spec.rb b/spec/unit/lib/cloud_controller/diego/buildpack/lifecycle_protocol_spec.rb index 8d951296c97..c9459548178 100644 --- a/spec/unit/lib/cloud_controller/diego/buildpack/lifecycle_protocol_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/buildpack/lifecycle_protocol_spec.rb @@ -51,6 +51,7 @@ module Buildpack let(:package) { create(:package_model, app:) } let(:droplet) { create(:droplet_model, package:, app:) } let(:buildpack) { nil } + let(:stack) { 'potato-stack' } let(:buildpack_infos) { [BuildpackInfo.new(buildpack, VCAP::CloudController::Buildpack.find(name: buildpack))] } let(:staging_details) do @@ -60,7 +61,7 @@ module Buildpack details.environment_variables = { 'nightshade_fruit' => 'potato' } details.staging_memory_in_mb = 42 details.staging_disk_in_mb = 51 - details.lifecycle = instance_double(BuildpackLifecycle, staging_stack: 'potato-stack', buildpack_infos: buildpack_infos, + details.lifecycle = instance_double(BuildpackLifecycle, staging_stack: stack, buildpack_infos: buildpack_infos, credentials_for_staging: nil) end end @@ -125,6 +126,19 @@ module Buildpack end end + context 'when using a custom stack' do + let(:stack) { 'docker://registry.example.com/my-org/custom-stack:latest' } + let(:buildpack) { 'http://custom-buildpack.example.com' } + + it 'uses a safe cache key while retaining buildpack-artifact caching' do + lifecycle_protocol.lifecycle_data(staging_details) + + cache_key = Digest::SHA256.hexdigest(stack) + expect(blobstore_url_generator).to have_received(:buildpack_cache_download_url).with(app.guid, cache_key) + expect(blobstore_url_generator).to have_received(:buildpack_cache_upload_url).with(app.guid, cache_key) + end + end + context 'when the generated message has invalid data' do let(:buildpack_infos) { [] } diff --git a/spec/unit/lib/cloud_controller/diego/buildpack/staging_action_builder_spec.rb b/spec/unit/lib/cloud_controller/diego/buildpack/staging_action_builder_spec.rb index a30b06cdb0a..c0d20ddc279 100644 --- a/spec/unit/lib/cloud_controller/diego/buildpack/staging_action_builder_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/buildpack/staging_action_builder_spec.rb @@ -152,6 +152,15 @@ module Buildpack expect(upload_actions[1].upload_action).to eq(upload_build_artifacts_cache_action) end + context 'when using a custom stack' do + let(:stack) { 'docker://registry.example.com/my-org/custom-stack:latest' } + + it 'uploads the build artifacts cache' do + upload_actions = builder.action.serial_action.actions[2].emit_progress_action.action.parallel_action.actions + expect(upload_actions.map(&:upload_action)).to include(upload_build_artifacts_cache_action) + end + end + describe 'credhub' do let(:credhub_url) { TestConfig.config_instance.get(:credhub_api, :internal_url) } let(:expected_platform_options) do diff --git a/spec/unit/lib/cloud_controller/diego/buildpack/task_action_builder_spec.rb b/spec/unit/lib/cloud_controller/diego/buildpack/task_action_builder_spec.rb index 23496ab7097..7b4dfbb5ad1 100644 --- a/spec/unit/lib/cloud_controller/diego/buildpack/task_action_builder_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/buildpack/task_action_builder_spec.rb @@ -7,13 +7,16 @@ module Buildpack subject(:builder) { TaskActionBuilder.new(config, task, lifecycle_data, 'vcap', ['app', task.command, ''], 'buildpack') } let(:enable_declarative_asset_downloads) { false } + let(:lifecycle_bundles) do + { + 'buildpack/potato-stack': 'http://file-server.service.cf.internal:8080/v1/static/potato_lifecycle_bundle_url' + } + end let(:config) do Config.new({ diego: { enable_declarative_asset_downloads: enable_declarative_asset_downloads, - lifecycle_bundles: { - 'buildpack/potato-stack': 'http://file-server.service.cf.internal:8080/v1/static/potato_lifecycle_bundle_url' - }, + lifecycle_bundles: lifecycle_bundles, droplet_destinations: droplet_destinations } }) @@ -261,6 +264,14 @@ module Buildpack expect(builder.stack).to eq('preloaded:run-image') end end + + context 'when the stack is a custom image' do + let(:stack) { 'docker://registry.example.com/my-org/custom-stack:latest' } + + it 'returns a Docker rootfs URI' do + expect(builder.stack).to eq('docker://registry.example.com/my-org/custom-stack#latest') + end + end end describe '#cached_dependencies' do @@ -291,6 +302,17 @@ module Buildpack end.to raise_error VCAP::CloudController::Diego::LifecycleBundleUriGenerator::InvalidStack end end + + context 'when the stack is a custom image' do + let(:stack) { 'docker://registry.example.com/my-org/custom-stack:latest' } + let(:lifecycle_bundles) do + { "buildpack/#{Stack.default.name}": 'http://file-server.service.cf.internal:8080/v1/static/default_lifecycle_bundle_url' } + end + + it 'uses the default stack lifecycle bundle' do + expect(builder.cached_dependencies.first.cache_key).to eq("buildpack-#{Stack.default.name}-lifecycle") + end + end end end end diff --git a/spec/unit/lib/cloud_controller/diego/cnb/lifecycle_protocol_spec.rb b/spec/unit/lib/cloud_controller/diego/cnb/lifecycle_protocol_spec.rb index 49f1e9267db..e039ab4454a 100644 --- a/spec/unit/lib/cloud_controller/diego/cnb/lifecycle_protocol_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/cnb/lifecycle_protocol_spec.rb @@ -29,8 +29,11 @@ module CNB Diego::StagingDetails.new.tap do |details| details.staging_guid = droplet.guid details.package = package - details.lifecycle = instance_double(CNBLifecycle, staging_stack: 'potato-stack', buildpack_infos: buildpack_infos, - credentials: '{"registry":{"username":"password"}}') + details.lifecycle = instance_double(CNBLifecycle, + staging_stack: 'potato-stack', + buildpack_infos: buildpack_infos, + staging_message: double('msg', buildpack_data: double('bd', requested?: false)), + credentials: '{"registry":{"username":"password"}}') end end let(:buildpack_infos) { [BuildpackInfo.new('http://some-buildpack.url', nil)] } @@ -60,8 +63,11 @@ module CNB details.environment_variables = { 'nightshade_fruit' => 'potato' } details.staging_memory_in_mb = 42 details.staging_disk_in_mb = 51 - details.lifecycle = instance_double(CNBLifecycle, staging_stack: 'potato-stack', buildpack_infos: buildpack_infos, - credentials: '{"registry":{"username":"password"}}') + details.lifecycle = instance_double(CNBLifecycle, + staging_stack: 'potato-stack', + buildpack_infos: buildpack_infos, + staging_message: double('msg', buildpack_data: double('bd', requested?: false)), + credentials: '{"registry":{"username":"password"}}') end end @@ -87,6 +93,7 @@ module CNB details.staging_memory_in_mb = 42 details.staging_disk_in_mb = 51 details.lifecycle = instance_double(CNBLifecycle, staging_stack: 'potato-stack', buildpack_infos: buildpack_infos, + staging_message: double('msg', buildpack_data: double('bd', requested?: false)), credentials: '{"registry.example.com":{"username":"user","password":"sekret"}}', credentials_for_staging: { 'registry.example.com' => { 'username' => 'user', 'password' => 'sekret' } }) @@ -148,8 +155,11 @@ module CNB let(:droplet) { create(:droplet_model, :cnb) } let(:staging_details) do StagingDetails.new.tap do |details| - details.lifecycle = instance_double(CNBLifecycle, staging_stack: 'potato-stack', buildpack_infos: 'some buildpack info', - credentials: '{"registry":{"username":"password"}}') + details.lifecycle = instance_double(CNBLifecycle, + staging_stack: 'potato-stack', + buildpack_infos: 'some buildpack info', + staging_message: double('msg', buildpack_data: double('bd', requested?: false)), + credentials: '{"registry":{"username":"password"}}') details.package = package details.staging_guid = droplet.guid end diff --git a/spec/unit/lib/cloud_controller/diego/cnb/staging_action_builder_spec.rb b/spec/unit/lib/cloud_controller/diego/cnb/staging_action_builder_spec.rb index e2bc1bc11c4..b0b6129a9d0 100644 --- a/spec/unit/lib/cloud_controller/diego/cnb/staging_action_builder_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/cnb/staging_action_builder_spec.rb @@ -359,6 +359,14 @@ module CNB expect(builder.stack).to eq('preloaded:build-image') end end + + context 'when the stack is a custom image' do + let(:stack) { 'docker://registry.example.com/my-org/custom-stack:latest' } + + it 'returns a Docker rootfs URI' do + expect(builder.stack).to eq('docker://registry.example.com/my-org/custom-stack#latest') + end + end end describe '#task_environment_variables' do @@ -390,6 +398,15 @@ module CNB expect(builder.task_environment_variables).to include(group_env) end end + + context 'when the stack is a custom image' do + let(:stack) { 'docker://registry.example.com/my-org/custom-stack:latest' } + + it 'uses the full stack image URI as CNB_STACK_ID' do + stack_id_env = ::Diego::Bbs::Models::EnvironmentVariable.new(name: 'CNB_STACK_ID', value: stack) + expect(builder.task_environment_variables).to include(stack_id_env) + end + end end end end diff --git a/spec/unit/lib/cloud_controller/diego/lifecycles/app_buildpack_lifecycle_spec.rb b/spec/unit/lib/cloud_controller/diego/lifecycles/app_buildpack_lifecycle_spec.rb index 49e06cf0fa0..4e3d2e35c01 100644 --- a/spec/unit/lib/cloud_controller/diego/lifecycles/app_buildpack_lifecycle_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/lifecycles/app_buildpack_lifecycle_spec.rb @@ -118,6 +118,8 @@ module VCAP::CloudController expect(BuildpackLifecycleDataValidator).to have_received(:new).with( buildpack_infos: [], stack: 'foo', + stack_name: Stack.default.name, + lifecycle_type: Lifecycles::BUILDPACK, credentials: nil, credentials_from_message: false ) @@ -279,6 +281,22 @@ module VCAP::CloudController expect { AppBuildpackLifecycle.new(message, app) }.not_to raise_error end end + + context 'when updating an existing custom-stack app without requesting a stack' do + let(:app) { create(:app_model, :buildpack) } + let(:message) { AppUpdateMessage.new({ lifecycle: { type: 'buildpack', data: { buildpacks: ['default-stack-buildpack'] } } }) } + + before do + app.buildpack_lifecycle_data.update(stack: 'docker://registry.example.com/custom-stack:latest') + create(:buildpack, name: 'default-stack-buildpack', stack: Stack.default.name) + end + + it 'rejects a buildpack pinned to the default stack' do + lifecycle = AppBuildpackLifecycle.new(message, app) + expect(lifecycle).not_to be_valid + expect(lifecycle.errors[:buildpack]).to include('"default-stack-buildpack" must be an existing admin buildpack or a valid git URI') + end + end end end end diff --git a/spec/unit/lib/cloud_controller/diego/lifecycles/app_cnb_lifecycle_spec.rb b/spec/unit/lib/cloud_controller/diego/lifecycles/app_cnb_lifecycle_spec.rb index 34642d8fa44..6650bbc9a4b 100644 --- a/spec/unit/lib/cloud_controller/diego/lifecycles/app_cnb_lifecycle_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/lifecycles/app_cnb_lifecycle_spec.rb @@ -124,6 +124,22 @@ module VCAP::CloudController end end end + + context 'when updating an existing custom-stack app without requesting a stack' do + let(:app) { create(:app_model, :cnb) } + let(:message) { AppUpdateMessage.new({ lifecycle: { type: 'cnb', data: { buildpacks: ['default-stack-buildpack'] } } }) } + + before do + app.cnb_lifecycle_data.update(stack: 'docker://registry.example.com/custom-stack:latest') + create(:buildpack, name: 'default-stack-buildpack', stack: Stack.default.name, lifecycle: Lifecycles::CNB) + end + + it 'rejects a buildpack pinned to the default stack' do + lifecycle = AppCNBLifecycle.new(message, app) + expect(lifecycle).not_to be_valid + expect(lifecycle.errors[:buildpack]).to include('"default-stack-buildpack" must be an existing admin buildpack or a valid git URI') + end + end end describe '#update_lifecycle_data_model credentials handling' do diff --git a/spec/unit/lib/cloud_controller/diego/lifecycles/buildpack_lifecycle_data_validator_custom_stacks_spec.rb b/spec/unit/lib/cloud_controller/diego/lifecycles/buildpack_lifecycle_data_validator_custom_stacks_spec.rb new file mode 100644 index 00000000000..e6fcd0baab8 --- /dev/null +++ b/spec/unit/lib/cloud_controller/diego/lifecycles/buildpack_lifecycle_data_validator_custom_stacks_spec.rb @@ -0,0 +1,150 @@ +require 'spec_helper' +require 'cloud_controller/diego/lifecycles/buildpack_lifecycle_data_validator' + +module VCAP::CloudController + RSpec.describe BuildpackLifecycleDataValidator do + let(:stack) { create(:stack) } + let(:buildpack_url) { 'https://github.com/my-org/my-buildpack.git' } + let(:buildpack_info) { BuildpackInfo.new(buildpack_url, nil) } + let(:buildpack_infos) { [buildpack_info] } + let(:lifecycle_type) { Lifecycles::BUILDPACK } + + subject(:validator) do + BuildpackLifecycleDataValidator.new( + buildpack_infos: buildpack_infos, + stack: stack, + stack_name: stack&.name, + lifecycle_type: lifecycle_type + ) + end + + describe 'custom stacks' do + let(:stack) { nil } + let(:custom_stack_uri) { 'docker://docker.io/cloudfoundry/cflinuxfs4:1.268.0' } + + subject(:validator) do + BuildpackLifecycleDataValidator.new( + buildpack_infos: buildpack_infos, + stack: nil, + stack_name: custom_stack_uri, + lifecycle_type: lifecycle_type + ) + end + + context 'when diego_custom_stacks feature flag is enabled' do + before do + create(:feature_flag, name: 'diego_custom_stacks', enabled: true) + end + + context 'with custom buildpacks (URL-based)' do + let(:buildpack_info) { BuildpackInfo.new('https://github.com/my-org/my-buildpack.git', nil) } + + it 'is valid' do + expect(validator).to be_valid + end + end + + context 'with an unpinned admin buildpack' do + let(:buildpack_info) { BuildpackInfo.new('ruby_buildpack', create(:buildpack, :nil_stack, name: 'ruby_buildpack')) } + + it 'is valid' do + expect(validator).to be_valid + end + end + + context 'when auto-detecting without an enabled unpinned admin buildpack' do + let(:buildpack_infos) { [] } + + it 'is not valid' do + expect(validator).not_to be_valid + expect(validator.errors[:buildpack]).to include( + 'must specify a custom buildpack URL or an enabled unpinned admin buildpack when using a custom stack' + ) + end + end + + context 'when auto-detecting with an enabled unpinned admin buildpack' do + let(:buildpack_infos) { [] } + + before do + create(:buildpack, :nil_stack, enabled: true) + end + + it 'is valid' do + expect(validator).to be_valid + end + end + + context 'when auto-detecting CNB buildpacks' do + let(:lifecycle_type) { Lifecycles::CNB } + let(:buildpack_infos) { [] } + + before do + create(:buildpack, :nil_stack, lifecycle: Lifecycles::BUILDPACK) + end + + it 'does not use an unpinned buildpack lifecycle admin buildpack' do + expect(validator).not_to be_valid + expect(validator.errors[:buildpack]).to include( + 'must specify a custom buildpack URL or an enabled unpinned admin buildpack when using a custom stack' + ) + end + end + + context 'with credentials embedded in the stack URI' do + let(:custom_stack_uri) { 'docker://user:pass@registry.example.com/org/stack:tag' } + + it 'is not valid' do + expect(validator).not_to be_valid + expect(validator.errors[:stack]).to include('Custom stack URIs must not contain credentials; use data.credentials instead') + end + end + + context 'with an invalid custom stack URI' do + let(:custom_stack_uri) { 'docker://registry.example.com/' } + + it 'reports an invalid container image URI' do + expect(validator).not_to be_valid + expect(validator.errors[:stack]).to contain_exactly('must be a valid container image URI') + end + end + end + + context 'when diego_custom_stacks feature flag is disabled' do + before do + create(:feature_flag, name: 'diego_custom_stacks', enabled: false) + end + + it 'is not valid (stack must exist in DB)' do + expect(validator).not_to be_valid + expect(validator.errors[:stack]).to include('must be an existing stack') + end + end + end + + describe 'system stacks (unchanged behavior)' do + context 'when stack exists in DB' do + it 'is valid' do + expect(validator).to be_valid + end + end + + context 'when stack does not exist in DB' do + let(:stack) { nil } + + subject(:validator) do + BuildpackLifecycleDataValidator.new( + buildpack_infos: buildpack_infos, + stack: nil, + stack_name: 'nonexistent-stack' + ) + end + + it 'is not valid' do + expect(validator).not_to be_valid + expect(validator.errors[:stack]).to include('must be an existing stack') + end + end + end + end +end diff --git a/spec/unit/lib/cloud_controller/diego/lifecycles/buildpack_lifecycle_spec.rb b/spec/unit/lib/cloud_controller/diego/lifecycles/buildpack_lifecycle_spec.rb index a69a89cfd2c..f43bad14bc0 100644 --- a/spec/unit/lib/cloud_controller/diego/lifecycles/buildpack_lifecycle_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/lifecycles/buildpack_lifecycle_spec.rb @@ -218,6 +218,8 @@ module VCAP::CloudController expect(BuildpackLifecycleDataValidator).to have_received(:new).with( buildpack_infos: [], stack: 'foo', + stack_name: Stack.default.name, + lifecycle_type: Lifecycles::BUILDPACK, credentials: nil, credentials_from_message: false ) diff --git a/spec/unit/lib/cloud_controller/diego/task_recipe_builder_custom_stacks_spec.rb b/spec/unit/lib/cloud_controller/diego/task_recipe_builder_custom_stacks_spec.rb new file mode 100644 index 00000000000..c4ca109a16e --- /dev/null +++ b/spec/unit/lib/cloud_controller/diego/task_recipe_builder_custom_stacks_spec.rb @@ -0,0 +1,189 @@ +require 'spec_helper' +require 'cloud_controller/diego/task_recipe_builder' + +module VCAP::CloudController + module Diego + RSpec.describe TaskRecipeBuilder do + subject(:task_recipe_builder) { TaskRecipeBuilder.new } + + describe '#staging_image_credentials (custom stacks)' do + let(:custom_stack_uri) { 'docker://registry.example.com/my-org/my-stack:v1.0' } + let(:lifecycle) do + instance_double( + BuildpackLifecycle, + staging_stack: custom_stack_uri, + credentials_for_staging: { + 'registry.example.com' => { + 'username' => 'my-user', + 'password' => 'my-pass' + } + } + ) + end + let(:package) do + instance_double(PackageModel, docker_username: nil, docker_password: nil) + end + let(:staging_details) do + instance_double(StagingDetails, package: package, lifecycle: lifecycle) + end + + it 'returns credentials from the custom stack lifecycle' do + username, password = task_recipe_builder.send(:staging_image_credentials, staging_details) + expect(username).to eq('my-user') + expect(password).to eq('my-pass') + end + + it 'returns credentials from symbolized staging request data' do + allow(lifecycle).to receive(:credentials_for_staging).and_return( + { + 'registry.example.com': { + username: 'my-user', + password: 'my-pass' + } + } + ) + + username, password = task_recipe_builder.send(:staging_image_credentials, staging_details) + expect(username).to eq('my-user') + expect(password).to eq('my-pass') + end + end + + describe '#staging_image_credentials (docker package)' do + let(:lifecycle) do + instance_double( + BuildpackLifecycle, + staging_stack: 'cflinuxfs4', + credentials_for_staging: nil + ) + end + let(:package) do + instance_double(PackageModel, docker_username: 'docker-user', docker_password: 'docker-pass') + end + let(:staging_details) do + instance_double(StagingDetails, package: package, lifecycle: lifecycle) + end + + it 'uses docker package credentials when both are present' do + username, password = task_recipe_builder.send(:staging_image_credentials, staging_details) + expect(username).to eq('docker-user') + expect(password).to eq('docker-pass') + end + end + + describe '#staging_image_credentials (incomplete custom stack credentials)' do + let(:lifecycle) do + instance_double(BuildpackLifecycle, + staging_stack: 'docker://registry.example.com/my-org/my-stack:v1.0', + credentials_for_staging: { 'registry.example.com' => { 'username' => 'stack-user' } }) + end + let(:package) { instance_double(PackageModel, docker_username: nil, docker_password: nil) } + let(:staging_details) { instance_double(StagingDetails, package: package, lifecycle: lifecycle) } + + it 'does not pass a partial credential pair to Diego' do + expect(task_recipe_builder.send(:staging_image_credentials, staging_details)).to eq([nil, nil]) + end + end + + describe '#staging_image_credentials (mismatched docker credentials)' do + let(:lifecycle) do + instance_double( + BuildpackLifecycle, + staging_stack: 'docker://registry.example.com/my-org/my-stack:v1.0', + credentials_for_staging: { + 'registry.example.com' => { + 'username' => 'stack-user', + 'password' => 'stack-pass' + } + } + ) + end + let(:package) do + instance_double(PackageModel, docker_username: 'only-user', docker_password: nil) + end + let(:staging_details) do + instance_double(StagingDetails, package: package, lifecycle: lifecycle) + end + + it 'falls through to custom stack credentials when docker pair is incomplete' do + username, password = task_recipe_builder.send(:staging_image_credentials, staging_details) + expect(username).to eq('stack-user') + expect(password).to eq('stack-pass') + end + end + + describe '#staging_image_credentials (incomplete docker credentials)' do + let(:lifecycle) do + instance_double( + BuildpackLifecycle, + staging_stack: 'cflinuxfs4', + credentials_for_staging: nil + ) + end + let(:package) do + instance_double(PackageModel, docker_username: 'only-user', docker_password: nil) + end + let(:staging_details) do + instance_double(StagingDetails, package: package, lifecycle: lifecycle) + end + + it 'preserves docker package credentials for a system stack' do + username, password = task_recipe_builder.send(:staging_image_credentials, staging_details) + expect(username).to eq('only-user') + expect(password).to be_nil + end + end + + describe '#task_image_credentials (custom stacks)' do + let(:lifecycle_data) do + instance_double( + BuildpackLifecycleDataModel, + stack: 'docker://registry.example.com/my-org/my-stack:v1.0', + credentials: { + 'registry.example.com' => { + 'username' => 'task-user', + 'password' => 'task-pass' + } + } + ) + end + let(:app_model) do + instance_double(AppModel, lifecycle_data: lifecycle_data) + end + let(:droplet) do + instance_double(DropletModel, docker_receipt_username: nil, docker_receipt_password: nil) + end + let(:task) do + instance_double(TaskModel, app: app_model, droplet: droplet) + end + + it 'returns credentials from the custom stack lifecycle data' do + username, password = task_recipe_builder.send(:task_image_credentials, task) + expect(username).to eq('task-user') + expect(password).to eq('task-pass') + end + end + + describe '#task_image_credentials (docker droplet)' do + let(:lifecycle_data) do + instance_double(BuildpackLifecycleDataModel, stack: 'cflinuxfs4', credentials: nil) + end + let(:app_model) do + instance_double(AppModel, lifecycle_data: lifecycle_data) + end + let(:droplet) do + instance_double(DropletModel, docker_receipt_username: 'droplet-user', docker_receipt_password: 'droplet-pass') + end + let(:task) do + instance_double(TaskModel, app: app_model, droplet: droplet) + end + + it 'uses docker receipt credentials when both are present' do + username, password = task_recipe_builder.send(:task_image_credentials, task) + expect(username).to eq('droplet-user') + expect(password).to eq('droplet-pass') + end + end + end + end +end diff --git a/spec/unit/lib/utils/uri_utils_spec.rb b/spec/unit/lib/utils/uri_utils_spec.rb index 2cd837ef793..03bc4befac0 100644 --- a/spec/unit/lib/utils/uri_utils_spec.rb +++ b/spec/unit/lib/utils/uri_utils_spec.rb @@ -104,6 +104,20 @@ end end + describe '.is_custom_stack_uri?' do + it 'accepts valid Docker image references and identifies their registry' do + expect(UriUtils.is_custom_stack_uri?('docker://registry.example.com/my-org/my-stack:latest')).to be(true) + expect(UriUtils.is_custom_stack_uri?('docker://ubuntu:22.04')).to be(true) + expect(UriUtils.custom_stack_registry_host('docker://ubuntu:22.04')).to eq('docker.io') + end + + it 'rejects non-Docker and invalid Docker references' do + expect(UriUtils.is_custom_stack_uri?('cflinuxfs4')).to be(false) + expect(UriUtils.is_custom_stack_uri?('https://example.com/stack')).to be(false) + expect(UriUtils.is_custom_stack_uri?('docker://')).to be(false) + end + end + describe '.is_uri_path?' do it 'is false if the object is not a string' do expect(UriUtils.is_uri_path?(1)).to be false diff --git a/spec/unit/messages/app_manifest_message_spec.rb b/spec/unit/messages/app_manifest_message_spec.rb index e134e6ae7b4..796d7ff0881 100644 --- a/spec/unit/messages/app_manifest_message_spec.rb +++ b/spec/unit/messages/app_manifest_message_spec.rb @@ -2181,7 +2181,10 @@ 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) do + { name: 'cnb', lifecycle: 'cnb', buildpacks: %w[nodejs java], stack: stack.name, + cnb_credentials: { 'registry.example.com' => { 'username' => 'user', 'password' => 'pass' } } } + end it 'adds credentials to the lifecycle_data' do message = AppManifestMessage.create_from_yml(parsed_yaml) @@ -2190,7 +2193,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.example.com': { username: 'user', password: 'pass' } }) end end @@ -2309,6 +2312,27 @@ module VCAP::CloudController expect(message.errors.full_messages).to include('Feature Disabled: I am a banana') end end + + context 'when custom stacks are disabled' do + let(:parsed_yaml) do + { + name: 'custom-stack-app', + stack: 'docker://registry.example.com/org/stack:tag', + buildpacks: ['https://example.com/buildpack.git'] + } + end + + before do + create(:feature_flag, name: 'diego_custom_stacks', enabled: false, error_message: 'custom stacks are disabled') + end + + it 'is not valid' do + message = AppManifestMessage.create_from_yml(parsed_yaml) + + expect(message).not_to be_valid + expect(message.errors.full_messages).to include('Feature Disabled: custom stacks are disabled') + end + end end end diff --git a/spec/unit/models/runtime/feature_flag_spec.rb b/spec/unit/models/runtime/feature_flag_spec.rb index 8996a05c9f6..075b55ee63d 100644 --- a/spec/unit/models/runtime/feature_flag_spec.rb +++ b/spec/unit/models/runtime/feature_flag_spec.rb @@ -90,7 +90,7 @@ module VCAP::CloudController end describe '.enabled?' do - let(:key) { :user_org_creation } + let(:key) { :diego_custom_stacks } let(:default_value) { FeatureFlag::DEFAULT_FLAGS[key] } context 'when the feature flag is overridden' do diff --git a/spec/unit/repositories/app_event_repository_spec.rb b/spec/unit/repositories/app_event_repository_spec.rb index e73ac02b304..e167381cca7 100644 --- a/spec/unit/repositories/app_event_repository_spec.rb +++ b/spec/unit/repositories/app_event_repository_spec.rb @@ -662,6 +662,27 @@ module Repositories end end end + + context 'v3 lifecycle registry credentials' do + let(:app) { create(:app_model) } + let(:attrs) do + { + 'lifecycle' => { + 'type' => 'buildpack', + 'data' => { + 'stack' => 'docker://registry.example.com/cloudfoundry/cflinuxfs4:1.0.0', + 'credentials' => { 'registry.example.com' => { 'username' => 'user', 'password' => 'pass' } } + } + } + } + end + + it 'redacts the credentials in the recorded event' do + event = app_event_repository.record_app_create(app, space, user_audit_info, attrs).reload + + expect(event.metadata.dig('request', 'lifecycle', 'data', 'credentials')).to eq(Presenters::Censorship::PRIVATE_DATA_HIDDEN) + end + end end context 'with a v3 app' do