Skip to content

Commit 39c1627

Browse files
committed
[PoC] Implement Custom Stacks RFC
1 parent cf40b64 commit 39c1627

22 files changed

+471
-22
lines changed

app/controllers/v3/space_manifests_controller.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,9 @@ def incompatible_with_buildpacks(lifecycle_type, manifest)
127127
end
128128

129129
def incompatible_with_docker(lifecycle_type, manifest)
130+
# Allow docker + buildpack when lifecycle is explicitly set to buildpack (custom stack usage)
131+
return false if lifecycle_type == 'buildpack' && manifest.requested?(:lifecycle) && manifest.lifecycle == 'buildpack'
132+
130133
lifecycle_type == 'buildpack' && manifest.docker
131134
end
132135
end

app/fetchers/buildpack_lifecycle_fetcher.rb

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,16 @@ module VCAP::CloudController
44
class BuildpackLifecycleFetcher
55
class << self
66
def fetch(buildpack_names, stack_name, lifecycle=Config.config.get(:default_app_lifecycle))
7+
# Try to find the stack in the database first
8+
stack = Stack.find(name: stack_name) if stack_name.is_a?(String)
9+
10+
# If not found and it looks like a custom stack URL, use it as-is (normalized)
11+
if stack.nil? && stack_name.is_a?(String) && is_custom_stack?(stack_name)
12+
stack = normalize_stack_url(stack_name)
13+
end
14+
715
{
8-
stack: Stack.find(name: stack_name),
16+
stack: stack,
917
buildpack_infos: ordered_buildpacks(buildpack_names, stack_name, lifecycle)
1018
}
1119
end
@@ -20,6 +28,21 @@ def ordered_buildpacks(buildpack_names, stack_name, lifecycle)
2028
BuildpackInfo.new(buildpack_name, buildpack_record)
2129
end
2230
end
31+
32+
def is_custom_stack?(stack_name)
33+
# Check for various container registry URL formats
34+
return true if stack_name.include?('docker://')
35+
return true if stack_name.match?(%r{^https?://}) # Any https/http URL
36+
return true if stack_name.include?('.') # Any string with a dot (likely a registry)
37+
false
38+
end
39+
40+
def normalize_stack_url(stack_url)
41+
return stack_url if stack_url.start_with?('docker://')
42+
return stack_url.sub(/^https?:\/\//, 'docker://') if stack_url.match?(%r{^https?://})
43+
return "docker://#{stack_url}" if stack_url.match?(%r{^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/.+})
44+
stack_url
45+
end
2346
end
2447
end
2548
end

app/messages/app_manifest_message.rb

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,9 +138,17 @@ def app_lifecycle_hash
138138
Lifecycles::DOCKER
139139
end
140140

141+
# Use docker image as custom stack when lifecycle is explicitly buildpack
142+
stack_value = if requested?(:lifecycle) && @lifecycle == 'buildpack' && requested?(:docker) && docker
143+
docker_image = docker[:image] || docker['image']
144+
docker_image ? "docker://#{docker_image}" : @stack
145+
else
146+
@stack
147+
end
148+
141149
data = {
142150
buildpacks: requested_buildpacks,
143-
stack: @stack,
151+
stack: stack_value,
144152
credentials: @cnb_credentials
145153
}.compact
146154

@@ -475,6 +483,9 @@ def validate_cnb_enabled!
475483
def validate_docker_buildpacks_combination!
476484
return unless requested?(:docker) && (requested?(:buildpack) || requested?(:buildpacks))
477485

486+
# Allow docker + buildpacks when lifecycle is explicitly set to buildpack (custom stack usage)
487+
return if requested?(:lifecycle) && @lifecycle == 'buildpack'
488+
478489
errors.add(:base, 'Cannot specify both buildpack(s) and docker keys')
479490
end
480491

app/messages/buildpack_lifecycle_data_message.rb

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
require 'uri'
2+
13
module VCAP::CloudController
24
class BuildpackLifecycleDataMessage < BaseMessage
35
register_allowed_keys %i[buildpacks stack credentials]
@@ -19,6 +21,7 @@ class BuildpackLifecycleDataMessage < BaseMessage
1921

2022
validate :buildpacks_content
2123
validate :credentials_content
24+
validate :custom_stack_requires_custom_buildpack
2225

2326
def buildpacks_content
2427
return unless buildpacks.is_a?(Array)
@@ -40,7 +43,44 @@ def buildpacks_content
4043
def credentials_content
4144
return unless credentials.is_a?(Hash)
4245

43-
errors.add(:credentials, 'credentials value must be a hash') if credentials.any? { |_, v| !v.is_a?(Hash) }
46+
credentials.each do |registry, creds|
47+
unless creds.is_a?(Hash)
48+
errors.add(:credentials, "for registry '#{registry}' must be a hash")
49+
next
50+
end
51+
52+
has_username = creds.key?('username') || creds.key?(:username)
53+
has_password = creds.key?('password') || creds.key?(:password)
54+
errors.add(:base, "credentials for #{registry} must include 'username' and 'password'") unless has_username && has_password
55+
end
56+
end
57+
58+
def custom_stack_requires_custom_buildpack
59+
return unless stack.is_a?(String) && is_custom_stack?(stack)
60+
return unless FeatureFlag.enabled?(:diego_custom_stacks)
61+
return unless buildpacks.is_a?(Array)
62+
63+
buildpacks.each do |buildpack_name|
64+
# If buildpack is a URL, it's custom
65+
next if buildpack_name&.match?(URI::DEFAULT_PARSER.make_regexp)
66+
67+
# Check if it's a system buildpack
68+
system_buildpack = Buildpack.find(name: buildpack_name)
69+
if system_buildpack
70+
errors.add(:base, 'Buildpack must be a custom buildpack when using a custom stack')
71+
break
72+
end
73+
end
74+
end
75+
76+
private
77+
78+
def is_custom_stack?(stack_name)
79+
# Check for various container registry URL formats
80+
return true if stack_name.include?('docker://')
81+
return true if stack_name.match?(%r{^https?://}) # Any https/http URL
82+
return true if stack_name.include?('.') # Any string with a dot (likely a registry)
83+
false
4484
end
4585
end
4686
end

app/messages/validators.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ def validate(record)
198198
lifecycle_data_message = lifecycle_data_message_class.new(record.lifecycle_data)
199199
return if lifecycle_data_message.valid?
200200

201-
lifecycle_data_message.errors.full_messages.each do |message|
201+
lifecycle_data_message.errors.each do |attribute, message|
202202
record.errors.add(:lifecycle, message:)
203203
end
204204
end

app/models/runtime/cnb_lifecycle_data_model.rb

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,13 +68,10 @@ def using_custom_buildpack?
6868
end
6969

7070
def to_hash
71-
hash = {
71+
{
7272
buildpacks: buildpacks.map { |buildpack| CloudController::UrlSecretObfuscator.obfuscate(buildpack) },
7373
stack: stack
7474
}
75-
hash[:credentials] = Presenters::Censorship::REDACTED_CREDENTIAL unless credentials.nil?
76-
77-
hash
7875
end
7976

8077
def validate

app/models/runtime/feature_flag.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ class UndefinedFeatureFlagError < StandardError
1414
service_instance_creation: true,
1515
diego_docker: false,
1616
diego_cnb: false,
17+
diego_custom_stacks: false,
1718
set_roles_by_username: true,
1819
unset_roles_by_username: true,
1920
task_creation: true,

lib/cloud_controller/diego/buildpack/desired_lrp_builder.rb

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,14 @@ def cached_dependencies
2626

2727
lifecycle_bundle_key = :"buildpack/#{@stack}"
2828
lifecycle_bundle = @config.get(:diego, :lifecycle_bundles)[lifecycle_bundle_key]
29+
30+
# If custom stack doesn't have a bundle, use the default stack's bundle
31+
if !lifecycle_bundle && @stack.is_a?(String) && is_custom_stack?(@stack)
32+
default_stack = Stack.default.name
33+
lifecycle_bundle_key = :"buildpack/#{default_stack}"
34+
lifecycle_bundle = @config.get(:diego, :lifecycle_bundles)[lifecycle_bundle_key]
35+
end
36+
2937
raise InvalidStack.new("no compiler defined for requested stack '#{@stack}'") unless lifecycle_bundle
3038

3139
[
@@ -38,6 +46,11 @@ def cached_dependencies
3846
end
3947

4048
def root_fs
49+
# Handle custom stacks (docker:// URLs)
50+
if @stack.is_a?(String) && is_custom_stack?(@stack)
51+
return normalize_stack_url(@stack)
52+
end
53+
4154
@stack_obj ||= Stack.find(name: @stack)
4255
raise CloudController::Errors::ApiError.new_from_details('StackNotFound', @stack) unless @stack_obj
4356

@@ -65,9 +78,22 @@ def image_layers
6578

6679
lifecycle_bundle_key = :"buildpack/#{@stack}"
6780
lifecycle_bundle = @config.get(:diego, :lifecycle_bundles)[lifecycle_bundle_key]
81+
82+
# If custom stack doesn't have a bundle, use the default stack's bundle
83+
if !lifecycle_bundle && @stack.is_a?(String) && is_custom_stack?(@stack)
84+
default_stack = Stack.default.name
85+
lifecycle_bundle_key = :"buildpack/#{default_stack}"
86+
lifecycle_bundle = @config.get(:diego, :lifecycle_bundles)[lifecycle_bundle_key]
87+
end
88+
6889
raise InvalidStack.new("no compiler defined for requested stack '#{@stack}'") unless lifecycle_bundle
6990

7091
destination = @config.get(:diego, :droplet_destinations)[@stack.to_sym]
92+
# For custom stacks, use a default destination if not configured
93+
if !destination && @stack.is_a?(String) && is_custom_stack?(@stack)
94+
default_stack = Stack.default.name
95+
destination = @config.get(:diego, :droplet_destinations)[default_stack.to_sym]
96+
end
7197
raise InvalidStack.new("no droplet destination defined for requested stack '#{@stack}'") unless destination
7298

7399
layers = [
@@ -116,6 +142,23 @@ def port_environment_variables
116142
def privileged?
117143
@config.get(:diego, :use_privileged_containers_for_running)
118144
end
145+
146+
private
147+
148+
def is_custom_stack?(stack_name)
149+
# Check for various container registry URL formats
150+
return true if stack_name.include?('docker://')
151+
return true if stack_name.match?(%r{^https?://}) # Any https/http URL
152+
return true if stack_name.include?('.') # Any string with a dot (likely a registry)
153+
false
154+
end
155+
156+
def normalize_stack_url(stack_url)
157+
return stack_url if stack_url.start_with?('docker://')
158+
return stack_url.sub(/^https?:\/\//, 'docker://') if stack_url.match?(%r{^https?://})
159+
return "docker://#{stack_url}" if stack_url.include?('.')
160+
stack_url
161+
end
119162
end
120163
end
121164
end

lib/cloud_controller/diego/buildpack/task_action_builder.rb

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,11 @@ def task_environment_variables
9090
end
9191

9292
def stack
93+
# Handle custom stacks (docker:// URLs)
94+
if lifecycle_stack.is_a?(String) && is_custom_stack?(lifecycle_stack)
95+
return normalize_stack_url(lifecycle_stack)
96+
end
97+
9398
@stack ||= Stack.find(name: lifecycle_stack)
9499
raise CloudController::Errors::ApiError.new_from_details('StackNotFound', lifecycle_stack) unless @stack
95100

@@ -117,6 +122,21 @@ def lifecycle_bundle_key
117122
def lifecycle_stack
118123
lifecycle_data[:stack]
119124
end
125+
126+
def is_custom_stack?(stack_name)
127+
# Check for various container registry URL formats
128+
return true if stack_name.include?('docker://')
129+
return true if stack_name.match?(%r{^https?://}) # Any https/http URL
130+
return true if stack_name.include?('.') # Any string with a dot (likely a registry)
131+
false
132+
end
133+
134+
def normalize_stack_url(stack_url)
135+
return stack_url if stack_url.start_with?('docker://')
136+
return stack_url.sub(%r{^https?://}, 'docker://') if stack_url.match?(%r{^https?://})
137+
return "docker://#{stack_url}" if stack_url.include?('.')
138+
stack_url
139+
end
120140
end
121141
end
122142
end

lib/cloud_controller/diego/cnb/desired_lrp_builder.rb

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,14 @@ def cached_dependencies
2626

2727
lifecycle_bundle_key = :"cnb/#{@stack}"
2828
lifecycle_bundle = @config.get(:diego, :lifecycle_bundles)[lifecycle_bundle_key]
29+
30+
# If custom stack doesn't have a bundle, use the default stack's bundle
31+
if !lifecycle_bundle && @stack.is_a?(String) && is_custom_stack?(@stack)
32+
default_stack = Stack.default.name
33+
lifecycle_bundle_key = :"cnb/#{default_stack}"
34+
lifecycle_bundle = @config.get(:diego, :lifecycle_bundles)[lifecycle_bundle_key]
35+
end
36+
2937
raise InvalidStack.new("no compiler defined for requested stack '#{@stack}'") unless lifecycle_bundle
3038

3139
[
@@ -38,6 +46,11 @@ def cached_dependencies
3846
end
3947

4048
def root_fs
49+
# Handle custom stacks (docker:// URLs)
50+
if @stack.is_a?(String) && is_custom_stack?(@stack)
51+
return normalize_stack_url(@stack)
52+
end
53+
4154
@stack_obj ||= Stack.find(name: @stack)
4255
raise CloudController::Errors::ApiError.new_from_details('StackNotFound', @stack) unless @stack_obj
4356

@@ -65,9 +78,22 @@ def image_layers
6578

6679
lifecycle_bundle_key = :"cnb/#{@stack}"
6780
lifecycle_bundle = @config.get(:diego, :lifecycle_bundles)[lifecycle_bundle_key]
81+
82+
# If custom stack doesn't have a bundle, use the default stack's bundle
83+
if !lifecycle_bundle && @stack.is_a?(String) && is_custom_stack?(@stack)
84+
default_stack = Stack.default.name
85+
lifecycle_bundle_key = :"cnb/#{default_stack}"
86+
lifecycle_bundle = @config.get(:diego, :lifecycle_bundles)[lifecycle_bundle_key]
87+
end
88+
6889
raise InvalidStack.new("no compiler defined for requested stack '#{@stack}'") unless lifecycle_bundle
6990

7091
destination = @config.get(:diego, :droplet_destinations)[@stack.to_sym]
92+
# For custom stacks, use a default destination if not configured
93+
if !destination && @stack.is_a?(String) && is_custom_stack?(@stack)
94+
default_stack = Stack.default.name
95+
destination = @config.get(:diego, :droplet_destinations)[default_stack.to_sym]
96+
end
7197
raise InvalidStack.new("no droplet destination defined for requested stack '#{@stack}'") unless destination
7298

7399
layers = [
@@ -124,6 +150,23 @@ def default_container_env
124150
::Diego::Bbs::Models::EnvironmentVariable.new(name: 'CNB_APP_DIR', value: '/home/vcap/workspace')
125151
]
126152
end
153+
154+
private
155+
156+
def is_custom_stack?(stack_name)
157+
# Check for various container registry URL formats
158+
return true if stack_name.include?('docker://')
159+
return true if stack_name.match?(%r{^https?://}) # Any https/http URL
160+
return true if stack_name.include?('.') # Any string with a dot (likely a registry)
161+
false
162+
end
163+
164+
def normalize_stack_url(stack_url)
165+
return stack_url if stack_url.start_with?('docker://')
166+
return stack_url.sub(/^https?:\/\//, 'docker://') if stack_url.match?(%r{^https?://})
167+
return "docker://#{stack_url}" if stack_url.include?('.')
168+
stack_url
169+
end
127170
end
128171
end
129172
end

0 commit comments

Comments
 (0)