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