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..726007e626f 100644 --- a/docs/v3/source/includes/concepts/_lifecycles.md.erb +++ b/docs/v3/source/includes/concepts/_lifecycles.md.erb @@ -40,8 +40,10 @@ 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`.
When the `diego_custom_stacks` feature flag is enabled, this can also be a container image URI with the `docker://` scheme. A custom stack requires custom buildpacks or admin buildpacks that are not associated with a stack. Its image layers count toward the app instance's disk quota. +**data.credentials** | _object_ | Credentials used to download custom buildpacks from private Git repositories or to pull custom stacks from private registries, keyed by host. Each entry contains a username/password pair. If any buildpack URL embeds credentials, **data.credentials** must be empty; provide credentials in one place only.
For private custom stacks, configure credentials on the app lifecycle for running instances and tasks. + +During staging with a custom stack, Cloud Foundry uses the final repository component of the image URI, excluding its tag or digest, as `CF_STACK`. For example, `docker://registry.example.com/team/cflinuxfs4:1.268.0` sets `CF_STACK` to `cflinuxfs4`. The custom stack and buildpack must be compatible. 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 +86,10 @@ 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 to pull custom stacks, keyed by registry host. Each entry contains a username/password pair or a token. Custom stack credentials must be a username/password pair. CNB buildpack URLs must not embed credentials; use **data.credentials** instead.
For private custom stacks, configure credentials on the app lifecycle for running instances and tasks. +**data.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 container image URI with the `docker://` scheme. A custom stack requires custom buildpacks or admin buildpacks that are not associated with a stack. Its image layers count toward the app instance's disk quota. + +During staging with a custom stack, Cloud Foundry uses the final repository component of the image URI, excluding its tag or digest, as `CNB_STACK_ID`. For example, `docker://registry.example.com/team/cflinuxfs4:1.268.0` sets `CNB_STACK_ID` to `cflinuxfs4`. The custom stack and buildpack must be compatible. 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/builds/_create.md.erb b/docs/v3/source/includes/resources/builds/_create.md.erb index 1ff9e360122..249c2714555 100644 --- a/docs/v3/source/includes/resources/builds/_create.md.erb +++ b/docs/v3/source/includes/resources/builds/_create.md.erb @@ -40,7 +40,7 @@ Name | Type | Description Name | Type | Description | Default ---- | ---- | ----------- | ------- -**lifecycle** | [_lifecycle object_](#the-lifecycle-object) | Lifecycle information for a build | lifecycle on the app +**lifecycle** | [_lifecycle object_](#the-lifecycle-object) | Lifecycle information for a build.
Credentials supplied here apply only to staging and do not update the app's stored credentials. | lifecycle on the app **staging_memory_in_mb** | _integer_ | Memory in MB allocated for staging of the build **staging_disk_in_mb** | _integer_ | Disk space in MB allocated for staging of the build **staging_log_rate_limit_bytes_per_second** | _integer_ | Log rate limit in bytes per second allocated for staging of the build 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..61a1036da4b 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 as the stack for apps that use the buildpack or CNB lifecycle. When disabled, apps can use only platform-provided stacks."], ["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..2512e5e5322 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 or to pull custom stacks, keyed by host. Each entry contains a username/password pair; CNB buildpack credentials can instead contain a token. Custom stack credentials must be a username/password pair. 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,12 +117,11 @@ 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 container image URI with the `docker://` scheme. A custom stack requires custom buildpacks or admin buildpacks that are not associated with a stack. Custom stack URIs must not include credentials; use **credentials** instead. Custom stack image layers count toward each process instance's **disk_quota**. **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** - #### Process-level configuration This configuration is for the individual process. Each process is created if it does not already exist. diff --git a/lib/cloud_controller/diego/app_recipe_builder.rb b/lib/cloud_controller/diego/app_recipe_builder.rb index 25a4c10866f..cedf097dd06 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/custom_stack_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,24 @@ 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, + **lrp_image_credentials, 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 + # The app lifecycle determines the LRP rootfs, even if its droplet has a different lifecycle. + if process.app.docker? + droplet = process.desired_droplet + { image_username: droplet.docker_receipt_username, image_password: droplet.docker_receipt_password } + else + lifecycle_data = process.app.lifecycle_data + CustomStackCredentialResolver.resolve(stack_uri: lifecycle_data.stack, credentials_by_host: lifecycle_data.credentials) + 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..2797d16771a 100644 --- a/lib/cloud_controller/diego/cnb/staging_action_builder.rb +++ b/lib/cloud_controller/diego/cnb/staging_action_builder.rb @@ -2,6 +2,7 @@ require 'diego/action_builder' require 'cloud_controller/diego/staging_action_builder' require 'digest/xxhash' +require 'utils/uri_utils' module VCAP::CloudController module Diego @@ -15,9 +16,10 @@ 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) ] + cnb_stack_id = UriUtils.custom_stack_buildpack_identifier(lifecycle_stack) || 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/custom_stack_credential_resolver.rb b/lib/cloud_controller/diego/custom_stack_credential_resolver.rb new file mode 100644 index 00000000000..da3fa890ab6 --- /dev/null +++ b/lib/cloud_controller/diego/custom_stack_credential_resolver.rb @@ -0,0 +1,23 @@ +require 'utils/uri_utils' + +module VCAP::CloudController + module Diego + module CustomStackCredentialResolver + module_function + + def resolve(stack_uri:, credentials_by_host:) + registry_host = UriUtils.custom_stack_registry_host(stack_uri) + return { image_username: nil, image_password: nil } unless registry_host && credentials_by_host.is_a?(Hash) + + registry_entry = credentials_by_host.deep_stringify_keys[registry_host] + return { image_username: nil, image_password: nil } unless registry_entry.is_a?(Hash) + + username = registry_entry['username'] + password = registry_entry['password'] + return { image_username: nil, image_password: nil } unless username.present? && password.present? + + { image_username: username, image_password: password } + end + 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..36a5fd89738 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) ) @@ -50,10 +53,12 @@ def lifecycle_data_for_validation(app) end def validating_stored_buildpacks? + return false if message.buildpack_data.requested?(:buildpacks) + return true if message.buildpack_data.requested?(:stack) && message.buildpack_data.stack.present? + # Stored URLs are needed to detect embedded userinfo when credentials are added separately. message.buildpack_data.requested?(:credentials) && - message.buildpack_data.credentials.present? && - !message.buildpack_data.requested?(:buildpacks) + message.buildpack_data.credentials.present? end def effective_credentials(app) @@ -68,6 +73,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.rb b/lib/cloud_controller/diego/lifecycles/buildpack_lifecycle.rb index 5744c020a57..c46644114ec 100644 --- a/lib/cloud_controller/diego/lifecycles/buildpack_lifecycle.rb +++ b/lib/cloud_controller/diego/lifecycles/buildpack_lifecycle.rb @@ -19,12 +19,16 @@ def create_lifecycle_data_model(build) def staging_environment_variables { - 'CF_STACK' => staging_stack + 'CF_STACK' => buildpack_stack } end private + def buildpack_stack + UriUtils.custom_stack_buildpack_identifier(staging_stack) || staging_stack + end + def app_stack @package.app.buildpack_lifecycle_data.try(:stack) 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..ea5ffa1e1d3 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,14 @@ 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 :custom_stack_credentials_are_strings validate :no_dual_credential_sources def buildpacks_are_uri_or_nil @@ -25,7 +29,52 @@ def buildpacks_are_uri_or_nil end def stack_exists_in_db - errors.add(:stack, 'must be an existing stack') if stack.nil? + 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) + return unless stack.nil? + + message = if FeatureFlag.enabled?(:diego_custom_stacks) + 'must be an existing stack or a valid container image URI starting with "docker://"' + else + 'must be an existing stack' + end + errors.add(:stack, message) + end + + def custom_stack_uri_is_valid + return if stack + 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 starting with "docker://"') + 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(:base, 'You must either specify a custom buildpack URL or your platform operator must provide an enabled buildpack that is not pinned to a stack') + end + + def custom_stack_credentials_are_strings + registry_host = UriUtils.custom_stack_registry_host(stack_name) + return unless registry_host && credentials.is_a?(Hash) + + registry_credentials = credentials.deep_stringify_keys[registry_host] + return unless registry_credentials.is_a?(Hash) + + %w[username password].each do |field| + value = registry_credentials[field] + next if value.nil? || value.is_a?(String) + + errors.add(:credentials, "#{field} for custom stack registry \"#{registry_host}\" must be a string") + end end def no_dual_credential_sources @@ -41,8 +90,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..fc071640b80 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 @@ -43,7 +45,7 @@ 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" ) ] @@ -92,7 +94,7 @@ def image_layers layers = [ ::Diego::Bbs::Models::ImageLayer.new( - name: "#{@prefix}-#{lifecycle_stack}-lifecycle", + name: "#{@prefix}-#{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, @@ -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 @@ -194,7 +200,11 @@ def skip_detect? end def lifecycle_bundle_key - :"#{@prefix}/#{lifecycle_data[:stack]}" + :"#{@prefix}/#{lifecycle_bundle_stack}" + end + + def lifecycle_bundle_stack + 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..4e55e677a09 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/custom_stack_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,7 @@ def build_app_task(config, task) "app:#{task.app_guid}" ] ), - image_username: task.droplet.docker_receipt_username, - image_password: task.droplet.docker_receipt_password, + **task_image_credentials(task), volume_mounted_files: ServiceBindingFilesBuilder.build(task.app) }.compact) rescue ServiceBindingFilesBuilder::IncompatibleBindings => e @@ -93,8 +93,7 @@ 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, + **staging_image_credentials(staging_details), volume_mounted_files: ServiceBindingFilesBuilder.build(staging_details.package.app) }.compact) rescue ServiceBindingFilesBuilder::IncompatibleBindings => e @@ -103,6 +102,30 @@ def build_staging_task(config, staging_details) private + def task_image_credentials(task) + # The droplet lifecycle determines the task rootfs, even if the app lifecycle differs. + if task.droplet.docker? + { image_username: task.droplet.docker_receipt_username, image_password: task.droplet.docker_receipt_password } + else + lifecycle_data = task.app.lifecycle_data + credentials_by_host = lifecycle_data.credentials if lifecycle_data.respond_to?(:credentials) + CustomStackCredentialResolver.resolve(stack_uri: lifecycle_data.stack, credentials_by_host: credentials_by_host) + end + end + + def staging_image_credentials(staging_details) + if staging_details.lifecycle.type == Lifecycles::DOCKER + username = staging_details.package.docker_username + password = staging_details.package.docker_password + return { image_username: username, image_password: password } if username.present? && password.present? + + { image_username: nil, image_password: nil } + else + lifecycle = staging_details.lifecycle + CustomStackCredentialResolver.resolve(stack_uri: lifecycle.staging_stack, credentials_by_host: lifecycle.credentials_for_staging) + end + 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..e6e7454ee03 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,37 @@ 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 custom_stack_buildpack_identifier(stack_uri) + return nil unless stack_uri.is_a?(String) && stack_uri.start_with?('docker://') + + _, path, = parse_docker_uri(stack_uri.delete_prefix('docker://')) + path.delete_suffix('@sha256').split('/').last + 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,10 +107,12 @@ 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) - raise InvalidDockerURI.new "Invalid image name [#{path}]" unless DOCKER_PATH_REGEX =~ path + raise InvalidDockerURI.new "Invalid image name [#{path}]" unless valid_docker_path?(path) raise InvalidDockerURI.new "Invalid image tag [#{tag_digest}]" if tag_digest && DOCKER_TAG_DIGEST_REGEX !~ tag_digest # if only sha256 presented, we add hash value as fragment to the uri, @@ -94,6 +128,10 @@ def parse_docker_uri(docker_uri) private + def valid_docker_path?(path) + DOCKER_PATH_REGEX.match?(path) && path.split('/', -1).none?(&:empty?) + end + def official_docker_registry(host) host == DOCKER_INDEX_SERVER end 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/actions/build_create_spec.rb b/spec/unit/actions/build_create_spec.rb index 697670b2f66..f4dd831e568 100644 --- a/spec/unit/actions/build_create_spec.rb +++ b/spec/unit/actions/build_create_spec.rb @@ -266,6 +266,38 @@ module VCAP::CloudController end end + %w[buildpack cnb].each do |lifecycle_type| + context "with #{lifecycle_type} build-only custom stack credentials" do + let(:app) { create(:app_model, lifecycle_type.to_sym, space:) } + let(:lifecycle) { LifecycleProvider.provide(package, staging_message) } + let(:runtime_stack) { 'docker://registry.example.com/team/cflinuxfs4:runtime' } + let(:build_credentials) { { 'registry.example.com' => { 'username' => 'build-user', 'password' => 'build-pass' } } } + let(:request_lifecycle) { { type: lifecycle_type, data: lifecycle_data } } + let(:lifecycle_data) do + { + stack: 'docker://registry.example.com/team/cflinuxfs4:staging', + buildpacks: [buildpack_git_url], + credentials: build_credentials + } + end + + before do + create(:feature_flag, name: 'diego_custom_stacks', enabled: true) + app.lifecycle_data.update(stack: runtime_stack, credentials: nil) + end + + it 'uses the build credentials for staging without changing app credentials or stack' do + action.create_and_stage(package:, lifecycle:) + + expect(stager).to have_received(:stage) do |staging_details| + expect(staging_details.lifecycle.staging_stack).to eq(lifecycle_data[:stack]) + expect(staging_details.lifecycle.credentials_for_staging.deep_stringify_keys).to eq(build_credentials) + end + expect(app.reload.lifecycle_data).to have_attributes(stack: runtime_stack, credentials: nil) + end + end + end + describe 'creating a stage request' do it 'initiates a staging request' do build = action.create_and_stage(package:, lifecycle:) diff --git a/spec/unit/controllers/v3/apps_controller_spec.rb b/spec/unit/controllers/v3/apps_controller_spec.rb index 7d7d7f281f4..c9c4fe4de35 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,33 @@ 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) + 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', 'diego_custom_stacks') + end + end end describe '#update' do @@ -609,6 +637,31 @@ 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) + 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', 'diego_custom_stacks') + end + end + context 'lifecycle data' do let(:new_name) { 'potato' } @@ -1264,6 +1317,20 @@ 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) + 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', 'diego_custom_stacks') + end + end end describe '#stop' do @@ -1367,6 +1434,23 @@ 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) + 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', '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..b8177e6bc27 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..15b650d2fa1 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,33 @@ 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 + + context 'when a Docker droplet is assigned to the buildpack app' do + let(:droplet) do + create(:droplet_model, :docker, app: app_model, docker_receipt_username: 'docker-user', docker_receipt_password: 'docker-pass') + end + + it 'uses credentials for the app stack rather than the Docker droplet image' do + lrp = builder.build_app_lrp + expect(lrp.image_username).to eq('stack-user') + expect(lrp.image_password).to eq('stack-password') + end + 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) @@ -1032,6 +1059,19 @@ module Diego expect(lrp.image_layers).to eq(expected_image_layers) end + context 'when the CNB app uses a private custom stack' do + before do + app_model.cnb_lifecycle_data.update(stack: 'docker://registry.example.com/team/stack:latest', + credentials: { 'registry.example.com' => { 'username' => 'stack-user', 'password' => 'stack-pass' } }) + end + + it 'passes the stack registry credentials to Diego' do + lrp = builder.build_app_lrp + expect(lrp.image_username).to eq('stack-user') + expect(lrp.image_password).to eq('stack-pass') + end + end + describe 'ssh' do before do process.app.update(enable_ssh: true) diff --git a/spec/unit/lib/cloud_controller/diego/buildpack/desired_lrp_builder_spec.rb b/spec/unit/lib/cloud_controller/diego/buildpack/desired_lrp_builder_spec.rb index 54b1f60c342..ee4bdeb9dc6 100644 --- a/spec/unit/lib/cloud_controller/diego/buildpack/desired_lrp_builder_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/buildpack/desired_lrp_builder_spec.rb @@ -276,6 +276,45 @@ module Buildpack end end + context 'with a custom stack' do + let(:stack) { 'docker://docker.io/cloudfoundry/cflinuxfs4:1.268.0' } + let(:lifecycle_bundles) { { "buildpack/#{Stack.default.name}": 'platform-lifecycle-bundle' } } + let(:droplet_destinations) { { Stack.default.name.to_sym => '/value/from/config/based/on/stack' } } + + before do + allow(LifecycleBundleUriGenerator).to receive(:uri).with('platform-lifecycle-bundle').and_return('foo://bar.baz') + end + + it 'uses the custom image as the rootfs' do + expect(builder.root_fs).to eq('docker://docker.io/cloudfoundry/cflinuxfs4#1.268.0') + end + + it 'uses the default stack lifecycle bundle' do + expect(builder.cached_dependencies.first).to have_attributes( + from: 'foo://bar.baz', + cache_key: "buildpack-#{Stack.default.name}-lifecycle" + ) + end + + context 'with declarative asset downloads enabled' do + let(:enable_declarative_asset_downloads) { true } + let(:opts) { super().merge(checksum_algorithm: 'sha256') } + + it 'uses the default stack for the lifecycle bundle and droplet destination' do + layers = builder.image_layers + expect(layers.first).to have_attributes( + name: "buildpack-#{Stack.default.name}-lifecycle", + url: 'foo://bar.baz', + destination_path: '/tmp/lifecycle' + ) + expect(layers.find { |layer| layer.name == 'droplet' }).to have_attributes( + url: 'http://droplet-uri.com:1234?token=&%40home---%3E', + destination_path: '/value/from/config/based/on/stack' + ) + end + end + end + describe '#global_environment_variables' do context 'when there are additional container-level env vars provided' do let(:additional_env_vars) do 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/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/staging_action_builder_spec.rb b/spec/unit/lib/cloud_controller/diego/cnb/staging_action_builder_spec.rb index e2bc1bc11c4..570b2af0533 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 final image repository component as CNB_STACK_ID' do + stack_id_env = ::Diego::Bbs::Models::EnvironmentVariable.new(name: 'CNB_STACK_ID', value: 'custom-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/custom_stack_credential_resolver_spec.rb b/spec/unit/lib/cloud_controller/diego/custom_stack_credential_resolver_spec.rb new file mode 100644 index 00000000000..7c9b2853db9 --- /dev/null +++ b/spec/unit/lib/cloud_controller/diego/custom_stack_credential_resolver_spec.rb @@ -0,0 +1,59 @@ +require 'spec_helper' +require 'cloud_controller/diego/custom_stack_credential_resolver' + +module VCAP::CloudController + module Diego + RSpec.describe CustomStackCredentialResolver do + let(:stack_uri) { 'docker://registry.example.com/team/stack:latest' } + let(:credentials_by_host) do + { 'registry.example.com' => { 'username' => 'stack-user', 'password' => 'stack-pass' } } + end + + subject(:image_credentials) { described_class.resolve(stack_uri:, credentials_by_host:) } + + it 'selects credentials for the stack registry' do + expect(image_credentials).to eq(image_username: 'stack-user', image_password: 'stack-pass') + end + + context 'with symbol keys' do + let(:credentials_by_host) { { 'registry.example.com': { username: 'stack-user', password: 'stack-pass' } } } + + it 'accepts the credentials' do + expect(image_credentials).to eq(image_username: 'stack-user', image_password: 'stack-pass') + end + end + + context 'with credentials for another registry' do + let(:credentials_by_host) { { 'other.example.com' => { 'username' => 'other', 'password' => 'secret' } } } + + it 'does not forward them' do + expect(image_credentials).to eq(image_username: nil, image_password: nil) + end + end + + context 'with an incomplete pair' do + let(:credentials_by_host) { { 'registry.example.com' => { 'username' => 'stack-user' } } } + + it 'does not forward the partial pair' do + expect(image_credentials).to eq(image_username: nil, image_password: nil) + end + end + + context 'without registry credentials' do + let(:credentials_by_host) { nil } + + it 'uses no image credentials' do + expect(image_credentials).to eq(image_username: nil, image_password: nil) + end + end + + context 'with a system stack' do + let(:stack_uri) { 'cflinuxfs4' } + + it 'does not use registry credentials' do + expect(image_credentials).to eq(image_username: nil, image_password: nil) + 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..6a9513935c1 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 @@ -10,6 +10,7 @@ module VCAP::CloudController let(:lifecycle_request_data) { {} } it_behaves_like 'a app lifecycle' + it_behaves_like 'custom-stack lifecycle updates', 'buildpack', 'https://example.com/buildpack.zip' describe '#create_lifecycle_data_model' do let!(:app) { create(:app_model) } @@ -118,6 +119,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/lifecycles/app_cnb_lifecycle_spec.rb b/spec/unit/lib/cloud_controller/diego/lifecycles/app_cnb_lifecycle_spec.rb index 34642d8fa44..19d8ddccce8 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 @@ -10,6 +10,7 @@ module VCAP::CloudController let(:lifecycle_request_data) { { buildpacks: ['http://acme.com'] } } it_behaves_like 'a app lifecycle' + it_behaves_like 'custom-stack lifecycle updates', 'cnb', 'docker://registry.example.com/team/buildpack:latest' describe '#create_lifecycle_data_model' do let!(:app) { create(:app_model) } diff --git a/spec/unit/lib/cloud_controller/diego/lifecycles/app_lifecycle_shared.rb b/spec/unit/lib/cloud_controller/diego/lifecycles/app_lifecycle_shared.rb index 55afb340b54..a66cb14e09b 100644 --- a/spec/unit/lib/cloud_controller/diego/lifecycles/app_lifecycle_shared.rb +++ b/spec/unit/lib/cloud_controller/diego/lifecycles/app_lifecycle_shared.rb @@ -22,3 +22,56 @@ expect(subject.type).to be_a(String) end end + +RSpec.shared_examples_for 'custom-stack lifecycle updates' do |lifecycle_type, buildpack_url| + subject(:lifecycle) { described_class.new(message, app) } + + let(:app) { create(:app_model, lifecycle_type.to_sym) } + let(:stored_stack) { 'docker://registry.example.com/team/stack:v1' } + let(:requested_stack) { 'docker://registry.example.com/team/stack:v2' } + let(:stored_buildpacks) { [buildpack_url] } + let(:pinned_buildpack) { create(:buildpack, name: 'default-stack-buildpack', stack: VCAP::CloudController::Stack.default.name, lifecycle: lifecycle_type) } + let(:message) { VCAP::CloudController::AppUpdateMessage.new(lifecycle: { type: lifecycle_type, data: { stack: requested_stack } }) } + + before do + create(:feature_flag, name: 'diego_custom_stacks', enabled: true) + app.lifecycle_data.update(stack: stored_stack, buildpacks: stored_buildpacks) + end + + it 'validates and retains the configured custom buildpacks' do + expect(lifecycle).to be_valid + lifecycle.update_lifecycle_data_model(app) + + expect(app.lifecycle_data.reload).to have_attributes(stack: requested_stack, buildpacks: stored_buildpacks) + end + + context 'with a retained stack-pinned admin buildpack' do + let(:stored_stack) { VCAP::CloudController::Stack.default.name } + let(:stored_buildpacks) { [pinned_buildpack.name] } + + it 'rejects the incompatible buildpack instead of treating the request as auto-detection' do + 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 + + context 'when only buildpacks are updated' do + let(:message) { VCAP::CloudController::AppUpdateMessage.new(lifecycle: { type: lifecycle_type, data: { buildpacks: [pinned_buildpack.name] } }) } + + it 'validates against the retained custom stack, not the default stack' do + 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 + + context 'when resetting the stack to null' do + let(:requested_stack) { nil } + + it 'keeps the existing stack-reset behavior' do + expect(lifecycle).to be_valid + lifecycle.update_lifecycle_data_model(app) + + expect(app.lifecycle_data.reload).to have_attributes(stack: nil, buildpacks: stored_buildpacks) + end + end +end 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..736a30ee5bd --- /dev/null +++ b/spec/unit/lib/cloud_controller/diego/lifecycles/buildpack_lifecycle_data_validator_custom_stacks_spec.rb @@ -0,0 +1,161 @@ +require 'spec_helper' +require 'cloud_controller/diego/lifecycles/buildpack_lifecycle_data_validator' + +module VCAP::CloudController + RSpec.describe BuildpackLifecycleDataValidator do + 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 } + let(:credentials) { nil } + + describe 'custom stacks' do + 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, + credentials: credentials + ) + end + + context 'when diego_custom_stacks feature flag is enabled' do + before do + create(:feature_flag, name: 'diego_custom_stacks', enabled: true) + end + + it 'accepts a custom buildpack URL' do + expect(validator).to be_valid + 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.full_messages).to include( + 'You must either specify a custom buildpack URL or your platform operator must provide an enabled buildpack that is not pinned to a 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.full_messages).to include( + 'You must either specify a custom buildpack URL or your platform operator must provide an enabled buildpack that is not pinned to a 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 registry credentials' do + let(:custom_stack_uri) { 'docker://registry.example.com/team/stack:latest' } + let(:registry_credentials) { { 'username' => 'registry-user', 'password' => 'registry-pass' } } + let(:credentials) { { 'registry.example.com' => registry_credentials } } + + it 'accepts string credentials' do + expect(validator).to be_valid + end + + %w[username password].each do |field| + context "with a non-string #{field}" do + let(:registry_credentials) { super().merge(field => 123) } + + it 'rejects the value without exposing it in the error' do + expect(validator).not_to be_valid + expect(validator.errors[:credentials]).to contain_exactly("#{field} for custom stack registry \"registry.example.com\" must be a string") + end + end + end + + context 'with null credential fields' do + let(:registry_credentials) { { 'username' => nil, 'password' => nil } } + + it 'preserves support for absent credentials' do + expect(validator).to be_valid + end + end + + context 'with CNB token credentials for a buildpack registry' do + let(:lifecycle_type) { Lifecycles::CNB } + let(:credentials) { super().merge('buildpacks.example.com' => { 'token' => 'buildpack-token' }) } + + it 'accepts tokens separately from the stack credentials' do + expect(validator).to be_valid + end + end + end + + context 'with an invalid custom stack URI' do + let(:custom_stack_uri) { 'docker://registry.example.com/team/' } + + it 'reports an invalid container image URI' do + expect(validator).not_to be_valid + expect(validator.errors.full_messages).to contain_exactly('Stack must be a valid container image URI starting with "docker://"') + end + end + + context 'with a stack that is neither an existing stack nor a valid custom stack URI' do + let(:custom_stack_uri) { 'ocker://registry.example.com/org/stack:tag' } + + it 'reports both supported stack formats' do + expect(validator).not_to be_valid + expect(validator.errors.full_messages).to contain_exactly( + 'Stack must be an existing stack or a valid container image URI starting with "docker://"' + ) + 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 + 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..7c761c74fd8 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 @@ -119,9 +119,24 @@ module VCAP::CloudController it 'provides staging environment variables' do staging_message.buildpack_data.stack = 'cool-stack' - expect(buildpack_lifecycle.staging_environment_variables).to eq({ - 'CF_STACK' => 'cool-stack' - }) + expect(buildpack_lifecycle.staging_environment_variables).to eq({ 'CF_STACK' => 'cool-stack' }) + end + + context 'with a custom stack' do + let(:request_data) do + { + buildpacks: ['https://github.com/cloudfoundry/nodejs-buildpack'], + stack: 'docker://registry.example.com/team/cflinuxfs4:1.268.0' + } + end + + before do + create(:feature_flag, name: 'diego_custom_stacks', enabled: true) + end + + it 'uses the final image repository component as CF_STACK' do + expect(buildpack_lifecycle.staging_environment_variables).to eq({ 'CF_STACK' => 'cflinuxfs4' }) + end end end @@ -218,6 +233,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/staging_action_builder_spec.rb b/spec/unit/lib/cloud_controller/diego/staging_action_builder_spec.rb index 737105ab317..cc7e86439a3 100644 --- a/spec/unit/lib/cloud_controller/diego/staging_action_builder_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/staging_action_builder_spec.rb @@ -8,6 +8,53 @@ module Diego let(:config) { Config.new({ staging: { legacy_md5_buildpack_paths_enabled: } }) } + %w[buildpack cnb].each do |prefix| + context "with a custom stack and #{prefix} lifecycle assets" do + subject(:builder) { described_class.new(config, nil, lifecycle_data, prefix, nil, nil, nil) } + + let(:enable_declarative_asset_downloads) { false } + let(:config) do + Config.new({ + diego: { + lifecycle_bundles: { "#{prefix}/#{Stack.default.name}": 'platform-bundle' }, + enable_declarative_asset_downloads: enable_declarative_asset_downloads + } + }) + end + let(:lifecycle_data) do + { + stack: 'docker://registry.example.com/team/stack:v1', + buildpacks: [], + app_bits_checksum: { type: 'sha1' } + } + end + + before do + allow(LifecycleBundleUriGenerator).to receive(:uri).with('platform-bundle').and_return('http://lifecycle.example.com/bundle.tgz') + end + + it 'shares the platform lifecycle cache entry across custom image tags' do + dependency = builder.cached_dependencies.first + expect(dependency).to have_attributes(from: 'http://lifecycle.example.com/bundle.tgz', cache_key: "#{prefix}-#{Stack.default.name}-lifecycle") + + lifecycle_data[:stack] = 'docker://registry.example.com/team/stack:v2' + expect(builder.cached_dependencies.first).to eq(dependency) + end + + context 'with declarative asset downloads enabled' do + let(:enable_declarative_asset_downloads) { true } + + it 'names the lifecycle layer after the platform stack' do + expect(builder.image_layers.first).to have_attributes( + name: "#{prefix}-#{Stack.default.name}-lifecycle", + url: 'http://lifecycle.example.com/bundle.tgz', + destination_path: '/tmp/lifecycle' + ) + end + end + end + end + describe '#buildpack_path' do context 'when legacy_md5_buildpack_paths_enabled is false' do let(:legacy_md5_buildpack_paths_enabled) { false } diff --git a/spec/unit/lib/cloud_controller/diego/task_recipe_builder_spec.rb b/spec/unit/lib/cloud_controller/diego/task_recipe_builder_spec.rb index 9daaf547724..1047b185402 100644 --- a/spec/unit/lib/cloud_controller/diego/task_recipe_builder_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/task_recipe_builder_spec.rb @@ -238,6 +238,36 @@ module Diego expect(result.environment_variables).to eq(lifecycle_environment_variables) end + context 'when staging with a custom stack' do + let(:request_data) do + { + stack: 'docker://registry.example.com/team/stack:latest', + credentials: { 'registry.example.com' => { 'username' => 'stack-user', 'password' => 'stack-pass' } } + } + end + + before do + create(:feature_flag, name: 'diego_custom_stacks', enabled: true) + end + + it 'sets the registry credentials on the Diego task' do + result = task_recipe_builder.build_staging_task(config, staging_details) + expect(result.image_username).to eq('stack-user') + expect(result.image_password).to eq('stack-pass') + end + + context 'with a CNB lifecycle' do + let(:app) { create(:app_model, :cnb, name: 'MyApp', guid: 'banana-guid', space: space) } + let(:lifecycle_type) { Lifecycles::CNB } + + it 'sets the registry credentials on the Diego task' do + result = task_recipe_builder.build_staging_task(config, staging_details) + expect(result.image_username).to eq('stack-user') + expect(result.image_password).to eq('stack-pass') + end + end + end + context 'when there is no isolation segment' do let(:isolation_segment) { nil } @@ -398,6 +428,16 @@ module Diego expect(result.image_username).to eq('dockeruser') expect(result.image_password).to eq('dockerpass') end + + context 'when Docker package credentials are incomplete' do + let(:package) { create(:package_model, :docker, app: app, docker_username: 'dockeruser', docker_password: nil) } + + it 'does not pass the partial pair to Diego' do + result = task_recipe_builder.build_staging_task(config, staging_details) + expect(result.image_username).to be_empty + expect(result.image_password).to be_empty + end + end end end @@ -568,6 +608,29 @@ module Diego expect(result.volume_mounted_files).to be_empty end + context 'when the app uses a custom stack' do + before do + app.lifecycle_data.update(stack: 'docker://registry.example.com/team/stack:latest', + credentials: { 'registry.example.com' => { 'username' => 'stack-user', 'password' => 'stack-pass' } }) + end + + it 'sets the registry credentials on the Diego task' do + result = task_recipe_builder.build_app_task(config, task) + expect(result.image_username).to eq('stack-user') + expect(result.image_password).to eq('stack-pass') + end + end + + context 'when the app has Docker lifecycle data but the task uses a buildpack droplet' do + let(:app) { create(:app_model, :docker, name: 'MyApp', guid: 'banana-guid', space: space) } + + it 'does not try to read custom stack credentials from the Docker lifecycle' do + result = task_recipe_builder.build_app_task(config, task) + expect(result.image_username).to be_empty + expect(result.image_password).to be_empty + end + end + context 'when a volume mount is provided' do let(:service_instance) { create(:managed_service_instance, space: app.space) } let(:multiple_volume_mounts) do @@ -733,6 +796,34 @@ module Diego expect(result.volume_mounted_files).to be_empty end + context 'when Docker droplet credentials are incomplete' do + let(:droplet) do + create(:droplet_model, :docker, app: app, docker_receipt_username: 'dockerusername', docker_receipt_password: nil) + end + + it 'preserves the receipt username for the runtime task' do + result = task_recipe_builder.build_app_task(config, task) + expect(result.image_username).to eq('dockerusername') + expect(result.image_password).to be_empty + end + end + + context 'when the buildpack app also has custom stack credentials' do + let(:droplet) { create(:droplet_model, :docker, app: app) } + + before do + app.lifecycle_data.update(stack: 'docker://registry.example.com/team/stack:latest', + credentials: { 'registry.example.com' => { 'username' => 'stack-user', 'password' => 'stack-pass' } }) + end + + it 'does not use the stack credentials for the Docker task image' do + result = task_recipe_builder.build_app_task(config, task) + expect(result.root_fs).to eq('docker://potato-stack') + expect(result.image_username).to be_empty + expect(result.image_password).to be_empty + end + end + context 'when a volume mount is provided' do let(:service_instance) { create(:managed_service_instance, space: app.space) } let(:multiple_volume_mounts) do diff --git a/spec/unit/lib/utils/uri_utils_spec.rb b/spec/unit/lib/utils/uri_utils_spec.rb index 2cd837ef793..145c4a63579 100644 --- a/spec/unit/lib/utils/uri_utils_spec.rb +++ b/spec/unit/lib/utils/uri_utils_spec.rb @@ -104,6 +104,42 @@ 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) + expect(UriUtils.is_custom_stack_uri?('docker://registry.example.com/team/')).to be(false) + end + end + + describe '.custom_stack_buildpack_identifier' do + it 'returns the final repository component without its tag' do + expect(UriUtils.custom_stack_buildpack_identifier('docker://registry.example.com/team/cflinuxfs4:1.268.0')).to eq('cflinuxfs4') + end + + it 'returns the final repository component without its digest' do + digest = 'e118d023acaee5cf13471ead39f68416ad6172ff0899f3257ce1481cd2b28a6a' + + expect(UriUtils.custom_stack_buildpack_identifier("docker://registry.example.com/team/alpine-stack@sha256:#{digest}")).to eq('alpine-stack') + end + + it 'supports Docker Hub shorthand references' do + expect(UriUtils.custom_stack_buildpack_identifier('docker://ubuntu:22.04')).to eq('ubuntu') + end + + it 'returns nil for invalid or non-Docker references' do + expect(UriUtils.custom_stack_buildpack_identifier('docker://registry.example.com/')).to be_nil + expect(UriUtils.custom_stack_buildpack_identifier('cflinuxfs4')).to be_nil + 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 @@ -173,6 +209,12 @@ expect { UriUtils.parse_docker_uri('publish/buildpack:?@sha256:e118d023acaee5cf13471ead39f68416ad6172ff0899f3257ce1481cd2b28a6a') }.to raise_error(UriUtils::InvalidDockerURI) expect { UriUtils.parse_docker_uri('publish/buildpack@sha256:e118d0') }.to raise_error(UriUtils::InvalidDockerURI) end + + it 'rejects empty repository path components' do + %w[registry.example.com/team/ registry.example.com//stack registry.example.com/team//stack].each do |image| + expect { UriUtils.parse_docker_uri(image) }.to raise_error(UriUtils::InvalidDockerURI, /Invalid image name/) + end + end end describe '.url_has_userinfo?' do 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