From 908035d0aea10056f4f2c4abc08aeb1bfef86b45 Mon Sep 17 00:00:00 2001 From: Tom Kennedy Date: Thu, 24 Apr 2025 10:32:00 -0400 Subject: [PATCH 1/7] Add support for uploading buildpacks packaged as cnb (tar) files - This is a follow on to https://github.com/cloudfoundry/cloud_controller_ng/pull/3898 which only supports uploading CNBs as zip or tgz whereas they are released in cnb format Signed-off-by: Tom Kennedy --- app/messages/buildpack_upload_message.rb | 6 ++++- spec/support/test_cnb.rb | 16 ++++++++++++++ .../cloud_controller/upload_buildpack_spec.rb | 22 ++++++++++++++++++- .../messages/buildpack_upload_message_spec.rb | 15 +++++++++++-- 4 files changed, 55 insertions(+), 4 deletions(-) create mode 100644 spec/support/test_cnb.rb diff --git a/app/messages/buildpack_upload_message.rb b/app/messages/buildpack_upload_message.rb index f0ebe0b1da0..6e10fe5dc80 100644 --- a/app/messages/buildpack_upload_message.rb +++ b/app/messages/buildpack_upload_message.rb @@ -3,6 +3,7 @@ module VCAP::CloudController GZIP_MIME = Regexp.new("\x1F\x8B\x08".force_encoding('binary')) ZIP_MIME = Regexp.new("PK\x03\x04".force_encoding('binary')) + CNB_MIME = Regexp.new("\x75\x73\x74\x61\x72\x00\x30\x30".force_encoding('binary')) class BuildpackUploadMessage < BaseMessage class MissingFilePathError < StandardError; end @@ -53,7 +54,10 @@ def is_archive return if mime_bits =~ /^#{VCAP::CloudController::GZIP_MIME}/ || mime_bits =~ /^#{VCAP::CloudController::ZIP_MIME}/ - errors.add(:base, "#{bits_name} is not a zip or gzip archive") + mime_bits_at_offset = File.read(bits_path, 8, 257) + return if mime_bits_at_offset =~ /^#{VCAP::CloudController::CNB_MIME}/ + + errors.add(:base, "#{bits_name} is not a zip or gzip archive or cnb file") end def missing_file_path diff --git a/spec/support/test_cnb.rb b/spec/support/test_cnb.rb new file mode 100644 index 00000000000..ba6935e7101 --- /dev/null +++ b/spec/support/test_cnb.rb @@ -0,0 +1,16 @@ +require 'zlib' +require 'rubygems/package' + +module TestCnb + def self.create(name, file_count, file_size = 1024, &) + File.open(name, 'wb') do |file| + Gem::Package::TarWriter.new(file) do |tar| + file_count.times do |i| + tar.add_file_simple("test_#{i}", 0o644, file_size) do |f| + f.write('A' * file_size) + end + end + end + end + end +end diff --git a/spec/unit/lib/cloud_controller/upload_buildpack_spec.rb b/spec/unit/lib/cloud_controller/upload_buildpack_spec.rb index 67d93a1ea4c..507f56f9abd 100644 --- a/spec/unit/lib/cloud_controller/upload_buildpack_spec.rb +++ b/spec/unit/lib/cloud_controller/upload_buildpack_spec.rb @@ -10,10 +10,12 @@ module VCAP::CloudController let(:tmpdir) { Dir.mktmpdir } let(:filename) { 'file.zip' } let(:tgz_filename) { 'file.tgz' } + let(:cnb_filename) { 'file.cnb' } let(:sha_valid_zip) { Digester.new(algorithm: OpenSSL::Digest::SHA256).digest_file(valid_zip) } let(:sha_valid_zip2) { Digester.new(algorithm: OpenSSL::Digest::SHA256).digest_file(valid_zip2) } let(:sha_valid_tgz) { Digester.new(algorithm: OpenSSL::Digest::SHA256).digest_file(valid_tgz) } + let(:sha_valid_cnb) { Digester.new(algorithm: OpenSSL::Digest::SHA256).digest_file(valid_cnb) } let(:valid_zip_manifest_stack) { nil } let(:valid_zip) do @@ -43,6 +45,13 @@ module VCAP::CloudController Rack::Test::UploadedFile.new(tgz_file) end + let(:valid_cnb) do + cnb_name = File.join(tmpdir, cnb_filename) + TestCnb.create(cnb_name, 3, 1024) + cnb_file = File.new(cnb_name) + Rack::Test::UploadedFile.new(cnb_file) + end + let(:staging_timeout) { TestConfig.config_instance.get(:staging, :timeout_in_seconds) } let(:expected_sha_valid_zip) { "#{buildpack.guid}_#{sha_valid_zip}" } @@ -152,7 +161,7 @@ module VCAP::CloudController end end - context 'lifecycle: cnb' do + context 'lifecycle: gzip cnb' do let!(:buildpack) { VCAP::CloudController::Buildpack.create_from_hash({ name: 'upload_cnb_buildpack', stack: 'cider', position: 0, lifecycle: 'cnb' }) } let(:expected_sha_valid_tgz) { "#{buildpack.guid}_#{sha_valid_tgz}" } @@ -163,6 +172,17 @@ module VCAP::CloudController end end + context 'lifecycle: cnb' do + let!(:buildpack) { VCAP::CloudController::Buildpack.create_from_hash({ name: 'upload_cnb_buildpack', stack: 'cider', position: 0, lifecycle: 'cnb' }) } + let(:expected_sha_valid_cnb) { "#{buildpack.guid}_#{sha_valid_cnb}" } + + it 'uploads' do + expect(buildpack_blobstore).to receive(:cp_to_blobstore).with(valid_cnb, expected_sha_valid_cnb) + + upload_buildpack.upload_buildpack(buildpack, valid_cnb, cnb_filename) + end + end + it 'updates the buildpack filename' do expect do upload_buildpack.upload_buildpack(buildpack, valid_zip, filename) diff --git a/spec/unit/messages/buildpack_upload_message_spec.rb b/spec/unit/messages/buildpack_upload_message_spec.rb index 7c295b783ac..9c84814bc67 100644 --- a/spec/unit/messages/buildpack_upload_message_spec.rb +++ b/spec/unit/messages/buildpack_upload_message_spec.rb @@ -102,20 +102,31 @@ module VCAP::CloudController allow(File).to receive(:read).and_return("PX\x03\x04".force_encoding('binary')) upload_message = BuildpackUploadMessage.new(opts) expect(upload_message).not_to be_valid - expect(upload_message.errors.full_messages[0]).to include('buildpack.abcd is not a zip or gzip') + expect(upload_message.errors.full_messages[0]).to include('buildpack.abcd is not a zip or gzip archive or cnb file') end end context 'when the file is a tgz' do let(:opts) { { bits_path: '/tmp/bar', bits_name: 'buildpack.tgz' } } - it 'is not valid' do + it 'is valid' do allow(File).to receive(:read).and_return("\x1F\x8B\x08".force_encoding('binary')) upload_message = BuildpackUploadMessage.new(opts) expect(upload_message).to be_valid end end + context 'when the file is a cnb/tar' do + let(:opts) { { bits_path: '/tmp/bar', bits_name: 'buildpack.cnb' } } + + it 'is valid' do + values = ["\x0".force_encoding('binary'), "\x75\x73\x74\x61\x72\x00\x30\x30".force_encoding('binary')] + allow(File).to receive(:read).and_return(*values) + upload_message = BuildpackUploadMessage.new(opts) + expect(upload_message).to be_valid + end + end + context 'when the file is empty' do let(:opts) { { bits_path: '/tmp/bar', bits_name: 'buildpack.zip' } } let(:stat_double) { instance_double(File::Stat, size: 0) } From 4f368790b1a272d888836c71ab76d6f80856b17e Mon Sep 17 00:00:00 2001 From: Seth Boyles Date: Thu, 24 Apr 2025 13:52:18 -0700 Subject: [PATCH 2/7] Validate buildpack filetype based on lifecycle --- app/controllers/v3/buildpacks_controller.rb | 2 +- app/messages/buildpack_upload_message.rb | 25 +++- spec/support/test_cnb.rb | 2 +- spec/unit/actions/buildpack_upload_spec.rb | 2 +- .../messages/buildpack_upload_message_spec.rb | 113 +++++++++++++----- 5 files changed, 107 insertions(+), 37 deletions(-) diff --git a/app/controllers/v3/buildpacks_controller.rb b/app/controllers/v3/buildpacks_controller.rb index 28386823917..2916970fa59 100644 --- a/app/controllers/v3/buildpacks_controller.rb +++ b/app/controllers/v3/buildpacks_controller.rb @@ -77,7 +77,7 @@ def upload unauthorized! unless permission_queryer.can_write_globally? - message = BuildpackUploadMessage.create_from_params(hashed_params[:body]) + message = BuildpackUploadMessage.create_from_params(hashed_params[:body], buildpack.lifecycle) combine_messages(message.errors.full_messages) unless message.valid? unprocessable!('Buildpack is locked') if buildpack.locked diff --git a/app/messages/buildpack_upload_message.rb b/app/messages/buildpack_upload_message.rb index 6e10fe5dc80..0742ea88102 100644 --- a/app/messages/buildpack_upload_message.rb +++ b/app/messages/buildpack_upload_message.rb @@ -17,8 +17,15 @@ class MissingFilePathError < StandardError; end validate :is_not_empty validate :missing_file_path - def self.create_from_params(params) - BuildpackUploadMessage.new(params.dup.symbolize_keys) + attr_reader :lifecycle + + def initialize(params, lifecycle) + @lifecycle = lifecycle + super(params) + end + + def self.create_from_params(params, lifecycle) + BuildpackUploadMessage.new(params.dup.symbolize_keys, lifecycle) end def nginx_fields @@ -52,12 +59,18 @@ def is_archive mime_bits = File.read(bits_path, 4) - return if mime_bits =~ /^#{VCAP::CloudController::GZIP_MIME}/ || mime_bits =~ /^#{VCAP::CloudController::ZIP_MIME}/ + if lifecycle == VCAP::CloudController::Lifecycles::BUILDPACK + return if mime_bits =~ /^#{VCAP::CloudController::ZIP_MIME}/ + + errors.add(:base, "#{bits_name} is not a zip file. Buildpacks of lifecycle \"#{lifecycle}\" must be valid zip files.") + elsif lifecycle == VCAP::CloudController::Lifecycles::CNB + return if mime_bits =~ /^#{VCAP::CloudController::GZIP_MIME}/ - mime_bits_at_offset = File.read(bits_path, 8, 257) - return if mime_bits_at_offset =~ /^#{VCAP::CloudController::CNB_MIME}/ + mime_bits_at_offset = File.read(bits_path, 8, 257) + return if mime_bits_at_offset =~ /^#{VCAP::CloudController::CNB_MIME}/ - errors.add(:base, "#{bits_name} is not a zip or gzip archive or cnb file") + errors.add(:base, "#{bits_name} is not a gzip archive or cnb file. Buildpacks of lifecycle \"#{lifecycle}\" must be valid gzip archives or cnb files.") + end end def missing_file_path diff --git a/spec/support/test_cnb.rb b/spec/support/test_cnb.rb index ba6935e7101..b17a5c35321 100644 --- a/spec/support/test_cnb.rb +++ b/spec/support/test_cnb.rb @@ -2,7 +2,7 @@ require 'rubygems/package' module TestCnb - def self.create(name, file_count, file_size = 1024, &) + def self.create(name, file_count, file_size=1024, &) File.open(name, 'wb') do |file| Gem::Package::TarWriter.new(file) do |tar| file_count.times do |i| diff --git a/spec/unit/actions/buildpack_upload_spec.rb b/spec/unit/actions/buildpack_upload_spec.rb index cf3c5f4aa8a..642e56e1428 100644 --- a/spec/unit/actions/buildpack_upload_spec.rb +++ b/spec/unit/actions/buildpack_upload_spec.rb @@ -7,7 +7,7 @@ module VCAP::CloudController describe '#upload_async' do let!(:buildpack) { VCAP::CloudController::Buildpack.create_from_hash({ name: 'upload_binary_buildpack', stack: nil, position: 0 }) } - let(:message) { BuildpackUploadMessage.new({ 'bits_path' => '/tmp/path', 'bits_name' => 'buildpack.zip' }) } + let(:message) { BuildpackUploadMessage.new({ 'bits_path' => '/tmp/path', 'bits_name' => 'buildpack.zip' }, VCAP::CloudController::Lifecycles::BUILDPACK) } let(:config) { Config.new({ name: 'local', index: '1' }) } before do diff --git a/spec/unit/messages/buildpack_upload_message_spec.rb b/spec/unit/messages/buildpack_upload_message_spec.rb index 9c84814bc67..97e689e4b94 100644 --- a/spec/unit/messages/buildpack_upload_message_spec.rb +++ b/spec/unit/messages/buildpack_upload_message_spec.rb @@ -5,6 +5,8 @@ module VCAP::CloudController RSpec.describe BuildpackUploadMessage do before { TestConfig.override(directories: { tmpdir: '/tmp/' }) } + let(:lifecycle) { VCAP::CloudController::Lifecycles::BUILDPACK } + describe 'validations' do let(:stat_double) { instance_double(File::Stat, size: 2) } @@ -19,7 +21,7 @@ module VCAP::CloudController let(:opts) { { '' => '', bits_path: '/tmp/foobar', bits_name: 'buildpack.zip' } } it 'is invalid' do - upload_message = BuildpackUploadMessage.new(opts) + upload_message = BuildpackUploadMessage.new(opts, lifecycle) expect(upload_message).not_to be_valid expect(upload_message.errors[:base]).to include('Uploaded bits are not a valid buildpack file') end @@ -29,7 +31,7 @@ module VCAP::CloudController let(:opts) { { bits_path: '/tmp/foobar', bits_name: 'buildpack.zip' } } it 'is valid' do - upload_message = BuildpackUploadMessage.new(opts) + upload_message = BuildpackUploadMessage.new(opts, lifecycle) expect(upload_message).to be_valid end end @@ -38,7 +40,7 @@ module VCAP::CloudController let(:opts) { { bits_path: '../tmp/mango/pear', bits_name: 'buildpack.zip' } } it 'is valid' do - upload_message = BuildpackUploadMessage.new(opts) + upload_message = BuildpackUploadMessage.new(opts, lifecycle) expect(upload_message).to be_valid end end @@ -47,7 +49,7 @@ module VCAP::CloudController let(:opts) { { bits_path: '../tmp-not!/mango/pear' } } it 'is not valid' do - upload_message = BuildpackUploadMessage.new(opts) + upload_message = BuildpackUploadMessage.new(opts, lifecycle) expect(upload_message).not_to be_valid expect(upload_message.errors[:bits_path]).to include('is invalid') end @@ -57,7 +59,7 @@ module VCAP::CloudController let(:opts) { {} } it 'is not valid' do - upload_message = BuildpackUploadMessage.new(opts) + upload_message = BuildpackUploadMessage.new(opts, lifecycle) expect(upload_message).not_to be_valid expect(upload_message.errors[:base]).to include('A buildpack zip file must be uploaded as \'bits\'') end @@ -67,7 +69,7 @@ module VCAP::CloudController let(:opts) { { bits_path: '/tmp/bar', unexpected: 'foo' } } it 'is not valid' do - message = BuildpackUploadMessage.new(opts) + message = BuildpackUploadMessage.new(opts, lifecycle) expect(message).not_to be_valid expect(message.errors.full_messages[0]).to include("Unknown field(s): 'unexpected'") @@ -78,7 +80,7 @@ module VCAP::CloudController let(:opts) { { bits_path: '/secret/file', bits_name: 'buildpack.zip' } } it 'is not valid' do - message = BuildpackUploadMessage.new(opts) + message = BuildpackUploadMessage.new(opts, lifecycle) expect(message).not_to be_valid expect(message.errors.full_messages[0]).to include('Bits path is invalid') @@ -89,7 +91,7 @@ module VCAP::CloudController let(:opts) { { bits_path: '/tmp/bar' } } it 'is not valid' do - upload_message = BuildpackUploadMessage.new(opts) + upload_message = BuildpackUploadMessage.new(opts, lifecycle) expect(upload_message).not_to be_valid expect(upload_message.errors[:base]).to include('A buildpack zip file must be uploaded as \'bits\'') end @@ -100,30 +102,85 @@ module VCAP::CloudController it 'is not valid' do allow(File).to receive(:read).and_return("PX\x03\x04".force_encoding('binary')) - upload_message = BuildpackUploadMessage.new(opts) + upload_message = BuildpackUploadMessage.new(opts, lifecycle) expect(upload_message).not_to be_valid - expect(upload_message.errors.full_messages[0]).to include('buildpack.abcd is not a zip or gzip archive or cnb file') + expect(upload_message.errors.full_messages[0]).to include('buildpack.abcd is not a zip file. Buildpacks of lifecycle "buildpack" must be valid zip files.') end end - context 'when the file is a tgz' do - let(:opts) { { bits_path: '/tmp/bar', bits_name: 'buildpack.tgz' } } + context 'buildpacks' do + let(:lifecycle) { VCAP::CloudController::Lifecycles::BUILDPACK } - it 'is valid' do - allow(File).to receive(:read).and_return("\x1F\x8B\x08".force_encoding('binary')) - upload_message = BuildpackUploadMessage.new(opts) - expect(upload_message).to be_valid + context 'when the file is a zip' do + let(:opts) { { bits_path: '/tmp/bar', bits_name: 'buildpack.zip' } } + + it 'is valid' do + allow(File).to receive(:read).and_return("PK\x03\x04".force_encoding('binary')) + upload_message = BuildpackUploadMessage.new(opts, lifecycle) + expect(upload_message).to be_valid + end end - end - context 'when the file is a cnb/tar' do - let(:opts) { { bits_path: '/tmp/bar', bits_name: 'buildpack.cnb' } } + context 'when the file is a tgz' do + let(:opts) { { bits_path: '/tmp/bar', bits_name: 'buildpack.tgz' } } - it 'is valid' do - values = ["\x0".force_encoding('binary'), "\x75\x73\x74\x61\x72\x00\x30\x30".force_encoding('binary')] - allow(File).to receive(:read).and_return(*values) - upload_message = BuildpackUploadMessage.new(opts) - expect(upload_message).to be_valid + it 'is not valid' do + allow(File).to receive(:read).and_return("\x1F\x8B\x08".force_encoding('binary')) + upload_message = BuildpackUploadMessage.new(opts, lifecycle) + expect(upload_message).not_to be_valid + expect(upload_message.errors.full_messages[0]).to include('buildpack.tgz is not a zip file. Buildpacks of lifecycle "buildpack" must be valid zip files.') + end + end + + context 'when the file is a cnb/tar' do + let(:opts) { { bits_path: '/tmp/bar', bits_name: 'buildpack.cnb' } } + + it 'is not valid' do + values = ["\x0".force_encoding('binary'), "\x75\x73\x74\x61\x72\x00\x30\x30".force_encoding('binary')] + allow(File).to receive(:read).and_return(*values) + upload_message = BuildpackUploadMessage.new(opts, lifecycle) + expect(upload_message).not_to be_valid + expect(upload_message.errors.full_messages[0]).to include('buildpack.cnb is not a zip file. Buildpacks of lifecycle "buildpack" must be valid zip files.') + end + end + end + + context 'cloud native buildpacks (cnb)' do + let(:lifecycle) { VCAP::CloudController::Lifecycles::CNB } + + context 'buildpacks' do + context 'when the file is a zip' do + let(:opts) { { bits_path: '/tmp/bar', bits_name: 'buildpack.zip' } } + + it 'is valid' do + allow(File).to receive(:read).and_return("PK\x03\x04".force_encoding('binary')) + upload_message = BuildpackUploadMessage.new(opts, lifecycle) + expect(upload_message).not_to be_valid + expect(upload_message.errors.full_messages[0]).to + include('buildpack.zip is not a gzip archive or cnb file. Buildpacks of lifecycle "cnb" must be valid gzip archives or cnb files.') + end + end + + context 'when the file is a tgz' do + let(:opts) { { bits_path: '/tmp/bar', bits_name: 'buildpack.tgz' } } + + it 'is valid' do + allow(File).to receive(:read).and_return("\x1F\x8B\x08".force_encoding('binary')) + upload_message = BuildpackUploadMessage.new(opts, lifecycle) + expect(upload_message).to be_valid + end + end + + context 'when the file is a cnb/tar' do + let(:opts) { { bits_path: '/tmp/bar', bits_name: 'buildpack.cnb' } } + + it 'is valid' do + values = ["\x0".force_encoding('binary'), "\x75\x73\x74\x61\x72\x00\x30\x30".force_encoding('binary')] + allow(File).to receive(:read).and_return(*values) + upload_message = BuildpackUploadMessage.new(opts, lifecycle) + expect(upload_message).to be_valid + end + end end end @@ -132,7 +189,7 @@ module VCAP::CloudController let(:stat_double) { instance_double(File::Stat, size: 0) } it 'is not valid' do - upload_message = BuildpackUploadMessage.new(opts) + upload_message = BuildpackUploadMessage.new(opts, lifecycle) expect(upload_message).not_to be_valid expect(upload_message.errors.full_messages[0]).to include('buildpack.zip cannot be empty') end @@ -140,7 +197,7 @@ module VCAP::CloudController end describe '#bits_path=' do - subject(:upload_message) { BuildpackUploadMessage.new(bits_path: 'not-nil') } + subject(:upload_message) { BuildpackUploadMessage.new({ bits_path: 'not-nil' }, lifecycle) } context 'when the bits_path is relative' do it 'makes it absolute (within the tmpdir)' do @@ -162,7 +219,7 @@ module VCAP::CloudController let(:params) { { 'bits_path' => '/tmp/foobar', 'bits_name' => 'buildpack.zip' } } it 'returns the correct BuildpackUploadMessage' do - message = BuildpackUploadMessage.create_from_params(params) + message = BuildpackUploadMessage.create_from_params(params, lifecycle) expect(message).to be_a(BuildpackUploadMessage) expect(message.bits_path).to eq('/tmp/foobar') @@ -170,7 +227,7 @@ module VCAP::CloudController end it 'converts requested keys to symbols' do - message = BuildpackUploadMessage.create_from_params(params) + message = BuildpackUploadMessage.create_from_params(params, lifecycle) expect(message).to be_requested(:bits_path) expect(message).to be_requested(:bits_name) From 0c1c5c8dea21681ef2c47757f18d574b1770727b Mon Sep 17 00:00:00 2001 From: Seth Boyles Date: Tue, 29 Apr 2025 14:16:55 -0700 Subject: [PATCH 3/7] Remove default buildpacks filter and change default sort to 'lifecycle' * also add missing docs --- app/fetchers/buildpack_list_fetcher.rb | 3 +- app/messages/buildpacks_list_message.rb | 5 +- .../includes/api_resources/_buildpacks.erb | 2 + .../resources/buildpacks/_create.md.erb | 1 + .../resources/buildpacks/_list.md.erb | 3 +- .../resources/buildpacks/_object.md.erb | 3 +- .../paging/pagination_options.rb | 8 +- .../paging/sequel_paginator.rb | 2 + spec/request/buildpacks_spec.rb | 387 +++++++++++++++++- .../v3/buildpacks_controller_spec.rb | 18 +- .../fetchers/buildpack_list_fetcher_spec.rb | 6 +- .../paging/pagination_options_spec.rb | 28 ++ .../paging/sequel_paginator_spec.rb | 44 ++ .../messages/buildpack_upload_message_spec.rb | 4 +- 14 files changed, 488 insertions(+), 26 deletions(-) diff --git a/app/fetchers/buildpack_list_fetcher.rb b/app/fetchers/buildpack_list_fetcher.rb index 1ae20e8052c..1c3713b269c 100644 --- a/app/fetchers/buildpack_list_fetcher.rb +++ b/app/fetchers/buildpack_list_fetcher.rb @@ -19,7 +19,8 @@ def filter(message, dataset) dataset = dataset.where(name: message.names) if message.requested?(:names) dataset = NullFilterQueryGenerator.add_filter(dataset, :stack, message.stacks) if message.requested?(:stacks) - dataset = dataset.where(lifecycle: message.requested?(:lifecycle) ? message.lifecycle : Config.config.get(:default_app_lifecycle)) + + dataset = dataset.where(lifecycle: message.lifecycle) if message.requested?(:lifecycle) if message.requested?(:label_selector) dataset = LabelSelectorQueryGenerator.add_selector_queries( diff --git a/app/messages/buildpacks_list_message.rb b/app/messages/buildpacks_list_message.rb index b0ba8cb5a64..293ec500248 100644 --- a/app/messages/buildpacks_list_message.rb +++ b/app/messages/buildpacks_list_message.rb @@ -21,7 +21,8 @@ class BuildpacksListMessage < MetadataListMessage def initialize(params={}) super - pagination_options.default_order_by = :position + pagination_options.default_order_by = :lifecycle + pagination_options.secondary_default_order_by = :position end def self.from_params(params) @@ -33,7 +34,7 @@ def to_param_hash end def valid_order_by_values - super << :position + super + %i[position lifecycle] end end diff --git a/docs/v3/source/includes/api_resources/_buildpacks.erb b/docs/v3/source/includes/api_resources/_buildpacks.erb index c2586026f10..2caea659bf6 100644 --- a/docs/v3/source/includes/api_resources/_buildpacks.erb +++ b/docs/v3/source/includes/api_resources/_buildpacks.erb @@ -8,6 +8,7 @@ "filename": null, "stack": "windows64", "position": 42, + "lifecycle": "buildpack", "enabled": true, "locked": false, "metadata": { @@ -52,6 +53,7 @@ "filename": null, "stack": "my-stack", "position": 1, + "lifecycle": "cnb", "enabled": true, "locked": false, "metadata": { diff --git a/docs/v3/source/includes/resources/buildpacks/_create.md.erb b/docs/v3/source/includes/resources/buildpacks/_create.md.erb index 00e818e08e3..dbe4eb8907e 100644 --- a/docs/v3/source/includes/resources/buildpacks/_create.md.erb +++ b/docs/v3/source/includes/resources/buildpacks/_create.md.erb @@ -44,6 +44,7 @@ Name | Type | Description ----------- | -------- | ----------------------------------------------------------------------------- | ------- **stack** | _string_ | The name of the stack that the buildpack will use | null **position** | _integer_ | The order in which the buildpacks are checked during buildpack auto-detection | 1 +**lifecycle**| _string_ | The version of buildpack the buildpack will use. `buildpack` indicates [Classic Buildpacks](https://docs.cloudfoundry.org/buildpacks/classic.html). `cnb` indicates [Cloud Native Buildpacks](https://docs.cloudfoundry.org/buildpacks/cnb/) | buildpack **enabled** | _boolean_ | Whether or not the buildpack will be used for staging | true **locked** | _boolean_ | Whether or not the buildpack is locked to prevent updating the bits | false **metadata.labels** | [_label object_](#labels) | Labels applied to the buildpack diff --git a/docs/v3/source/includes/resources/buildpacks/_list.md.erb b/docs/v3/source/includes/resources/buildpacks/_list.md.erb index bfad835b683..3fe885d0478 100644 --- a/docs/v3/source/includes/resources/buildpacks/_list.md.erb +++ b/docs/v3/source/includes/resources/buildpacks/_list.md.erb @@ -34,7 +34,8 @@ Name | Type | Description **per_page** | _integer_ | Number of results per page;
valid values are 1 through 5000 **names** | _list of strings_ | Comma-delimited list of buildpack names to filter by **stacks**| _list of strings_ | Comma-delimited list of stack names to filter by -**order_by** | _string_ | Value to sort by; defaults to ascending. Prepend with `-` to sort descending.
Valid values are `created_at`, `updated_at`, and `position` +**lifecycle**| _string_ | Type of buildpack. Valid values are `buildpack` and `cnb` +**order_by** | _string_ | Value to sort by; defaults to ascending. Prepend with `-` to sort descending.
Valid values are `created_at`, `updated_at`, `lifecycle`, and `position` **label_selector** | _string_ | A query string containing a list of [label selector](#labels-and-selectors) requirements **created_ats** | _[timestamp](#timestamps)_ | Timestamp to filter by. When filtering on equality, several comma-delimited timestamps may be passed. Also supports filtering with [relational operators](#relational-operators) **updated_ats** | _[timestamp](#timestamps)_ | Timestamp to filter by. When filtering on equality, several comma-delimited timestamps may be passed. Also supports filtering with [relational operators](#relational-operators) diff --git a/docs/v3/source/includes/resources/buildpacks/_object.md.erb b/docs/v3/source/includes/resources/buildpacks/_object.md.erb index 462f3b4a778..aa8918501f7 100644 --- a/docs/v3/source/includes/resources/buildpacks/_object.md.erb +++ b/docs/v3/source/includes/resources/buildpacks/_object.md.erb @@ -15,7 +15,8 @@ Name | Type | Description **updated_at** | _[timestamp](#timestamps)_ | The time with zone when the object was last updated **name** | _string_ | The name of the buildpack; to be used by app buildpack field (only alphanumeric characters) **state** | _string_ | The state of the buildpack; valid states are: `AWAITING_UPLOAD`, `READY` -**stack** | _string_ | The name of the stack that the buildpack will use +**stack** | _string_ | The name of the stack that the buildpack uses +**lifecycle** | _string_ | The version of buildpacks the buildpack uses. `buildpack` indicates [Classic Buildpacks](https://docs.cloudfoundry.org/buildpacks/classic.html). `cnb` indicates [Cloud Native Buildpacks](https://docs.cloudfoundry.org/buildpacks/cnb/) **filename** | _string_ | The filename of the buildpack **position** | _integer_ | The order in which the buildpacks are checked during buildpack auto-detection **enabled** | _boolean_ | Whether or not the buildpack can be used for staging diff --git a/lib/cloud_controller/paging/pagination_options.rb b/lib/cloud_controller/paging/pagination_options.rb index 0555db155dc..795ac55617c 100644 --- a/lib/cloud_controller/paging/pagination_options.rb +++ b/lib/cloud_controller/paging/pagination_options.rb @@ -12,7 +12,7 @@ class PaginationOptions DIRECTION_DEFAULT = 'asc'.freeze VALID_DIRECTIONS = %w[asc desc].freeze - attr_writer :order_by, :order_direction, :default_order_by + attr_writer :order_by, :order_direction, :default_order_by, :secondary_default_order_by attr_accessor :page, :per_page def initialize(params) @@ -38,6 +38,12 @@ def order_direction @order_direction || DIRECTION_DEFAULT end + def secondary_order_by + return if @order_by && @order_by.to_s != default_order_by.to_s + + @secondary_default_order_by + end + def keys %i[page per_page order_by order_direction] end diff --git a/lib/cloud_controller/paging/sequel_paginator.rb b/lib/cloud_controller/paging/sequel_paginator.rb index 46d8acf933c..c104b0de08b 100644 --- a/lib/cloud_controller/paging/sequel_paginator.rb +++ b/lib/cloud_controller/paging/sequel_paginator.rb @@ -13,6 +13,8 @@ def get_page(dataset, pagination_options) order_type = Sequel.send(order_direction, Sequel.qualify(table_name, order_by)) dataset = dataset.order(order_type) + secondary_order_by = pagination_options.secondary_order_by + dataset = dataset.order_append(Sequel.send(order_direction, Sequel.qualify(table_name, secondary_order_by))) if secondary_order_by dataset = dataset.order_append(Sequel.send(order_direction, Sequel.qualify(table_name, :guid))) if order_by != 'id' && has_guid_column distinct_opt = dataset.opts[:distinct] diff --git a/spec/request/buildpacks_spec.rb b/spec/request/buildpacks_spec.rb index 991d70e6e46..81b07353d88 100644 --- a/spec/request/buildpacks_spec.rb +++ b/spec/request/buildpacks_spec.rb @@ -93,24 +93,26 @@ let!(:stack2) { VCAP::CloudController::Stack.make } let!(:stack3) { VCAP::CloudController::Stack.make } + let!(:buildpack4) { VCAP::CloudController::Buildpack.make(stack: stack1.name, position: 2, lifecycle: 'cnb') } + let!(:buildpack5) { VCAP::CloudController::Buildpack.make(stack: stack1.name, position: 1, lifecycle: 'cnb') } + let!(:buildpack1) { VCAP::CloudController::Buildpack.make(stack: stack1.name, position: 1) } let!(:buildpack2) { VCAP::CloudController::Buildpack.make(stack: stack2.name, position: 3) } let!(:buildpack3) { VCAP::CloudController::Buildpack.make(stack: stack3.name, position: 2) } - let!(:buildpack4) { VCAP::CloudController::Buildpack.make(stack: stack1.name, position: 1, lifecycle: 'cnb') } - it 'returns a paginated list of buildpacks, sorted by position' do + it 'returns a paginated list of buildpacks, sorted by lifecycle and position' do get '/v3/buildpacks?page=1&per_page=2', nil, headers expect(parsed_response).to be_a_response_like( { 'pagination' => { - 'total_results' => 3, - 'total_pages' => 2, + 'total_results' => 5, + 'total_pages' => 3, 'first' => { 'href' => "#{link_prefix}/v3/buildpacks?page=1&per_page=2" }, 'last' => { - 'href' => "#{link_prefix}/v3/buildpacks?page=2&per_page=2" + 'href' => "#{link_prefix}/v3/buildpacks?page=3&per_page=2" }, 'next' => { 'href' => "#{link_prefix}/v3/buildpacks?page=2&per_page=2" @@ -169,6 +171,144 @@ ) end + it 'with no filters, returns a list of buildpacks, sorted by lifecycle and position' do + get '/v3/buildpacks', nil, headers + + expect(parsed_response).to be_a_response_like( + { + 'pagination' => { + 'total_results' => 5, + 'total_pages' => 1, + 'first' => { + 'href' => "#{link_prefix}/v3/buildpacks?page=1&per_page=50" + }, + 'last' => { + 'href' => "#{link_prefix}/v3/buildpacks?page=1&per_page=50" + }, + 'next' => nil, + 'previous' => nil + }, + 'resources' => [ + { + 'guid' => buildpack1.guid, + 'lifecycle' => 'buildpack', + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'name' => buildpack1.name, + 'state' => buildpack1.state, + 'filename' => buildpack1.filename, + 'stack' => buildpack1.stack, + 'position' => 1, + 'enabled' => true, + 'locked' => false, + 'metadata' => { 'labels' => {}, 'annotations' => {} }, + 'links' => { + 'self' => { + 'href' => "#{link_prefix}/v3/buildpacks/#{buildpack1.guid}" + }, + 'upload' => { + 'href' => "#{link_prefix}/v3/buildpacks/#{buildpack1.guid}/upload", + 'method' => 'POST' + } + } + }, + { + 'guid' => buildpack3.guid, + 'lifecycle' => 'buildpack', + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'name' => buildpack3.name, + 'state' => buildpack3.state, + 'filename' => buildpack3.filename, + 'stack' => buildpack3.stack, + 'position' => 2, + 'enabled' => true, + 'locked' => false, + 'metadata' => { 'labels' => {}, 'annotations' => {} }, + 'links' => { + 'self' => { + 'href' => "#{link_prefix}/v3/buildpacks/#{buildpack3.guid}" + }, + 'upload' => { + 'href' => "#{link_prefix}/v3/buildpacks/#{buildpack3.guid}/upload", + 'method' => 'POST' + } + } + }, + { + 'guid' => buildpack2.guid, + 'lifecycle' => 'buildpack', + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'name' => buildpack2.name, + 'state' => buildpack2.state, + 'filename' => buildpack2.filename, + 'stack' => buildpack2.stack, + 'position' => 3, + 'enabled' => true, + 'locked' => false, + 'metadata' => { 'labels' => {}, 'annotations' => {} }, + 'links' => { + 'self' => { + 'href' => "#{link_prefix}/v3/buildpacks/#{buildpack2.guid}" + }, + 'upload' => { + 'href' => "#{link_prefix}/v3/buildpacks/#{buildpack2.guid}/upload", + 'method' => 'POST' + } + } + }, + { + 'guid' => buildpack5.guid, + 'lifecycle' => 'cnb', + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'name' => buildpack5.name, + 'state' => buildpack5.state, + 'filename' => buildpack5.filename, + 'stack' => buildpack5.stack, + 'position' => 1, + 'enabled' => true, + 'locked' => false, + 'metadata' => { 'labels' => {}, 'annotations' => {} }, + 'links' => { + 'self' => { + 'href' => "#{link_prefix}/v3/buildpacks/#{buildpack5.guid}" + }, + 'upload' => { + 'href' => "#{link_prefix}/v3/buildpacks/#{buildpack5.guid}/upload", + 'method' => 'POST' + } + } + }, + { + 'guid' => buildpack4.guid, + 'lifecycle' => 'cnb', + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'name' => buildpack4.name, + 'state' => buildpack4.state, + 'filename' => buildpack4.filename, + 'stack' => buildpack4.stack, + 'position' => 2, + 'enabled' => true, + 'locked' => false, + 'metadata' => { 'labels' => {}, 'annotations' => {} }, + 'links' => { + 'self' => { + 'href' => "#{link_prefix}/v3/buildpacks/#{buildpack4.guid}" + }, + 'upload' => { + 'href' => "#{link_prefix}/v3/buildpacks/#{buildpack4.guid}/upload", + 'method' => 'POST' + } + } + } + ] + } + ) + end + it 'returns a list of filtered buildpacks' do get "/v3/buildpacks?names=#{buildpack1.name},#{buildpack3.name}&stacks=#{stack1.name}", nil, headers @@ -215,13 +355,84 @@ ) end + it 'returns a paginated list of buildpacks filtered by lifecycle' do + get '/v3/buildpacks?lifecycle=buildpack&per_page=2', nil, headers + + expect(parsed_response).to be_a_response_like( + { + 'pagination' => { + 'total_results' => 3, + 'total_pages' => 2, + 'first' => { + 'href' => "#{link_prefix}/v3/buildpacks?lifecycle=buildpack&page=1&per_page=2" + }, + 'last' => { + 'href' => "#{link_prefix}/v3/buildpacks?lifecycle=buildpack&page=2&per_page=2" + }, + 'next' => { + 'href' => "#{link_prefix}/v3/buildpacks?lifecycle=buildpack&page=2&per_page=2" + }, + 'previous' => nil + }, + 'resources' => [ + { + 'guid' => buildpack1.guid, + 'lifecycle' => 'buildpack', + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'name' => buildpack1.name, + 'state' => buildpack1.state, + 'filename' => buildpack1.filename, + 'stack' => buildpack1.stack, + 'position' => 1, + 'enabled' => true, + 'locked' => false, + 'metadata' => { 'labels' => {}, 'annotations' => {} }, + 'links' => { + 'self' => { + 'href' => "#{link_prefix}/v3/buildpacks/#{buildpack1.guid}" + }, + 'upload' => { + 'href' => "#{link_prefix}/v3/buildpacks/#{buildpack1.guid}/upload", + 'method' => 'POST' + } + } + }, + { + 'guid' => buildpack3.guid, + 'lifecycle' => 'buildpack', + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'name' => buildpack3.name, + 'state' => buildpack3.state, + 'filename' => buildpack3.filename, + 'stack' => buildpack3.stack, + 'position' => 2, + 'enabled' => true, + 'locked' => false, + 'metadata' => { 'labels' => {}, 'annotations' => {} }, + 'links' => { + 'self' => { + 'href' => "#{link_prefix}/v3/buildpacks/#{buildpack3.guid}" + }, + 'upload' => { + 'href' => "#{link_prefix}/v3/buildpacks/#{buildpack3.guid}/upload", + 'method' => 'POST' + } + } + } + ] + } + ) + end + it 'returns a list of buildpacks filtered by lifecycle' do get '/v3/buildpacks?lifecycle=cnb', nil, headers expect(parsed_response).to be_a_response_like( { 'pagination' => { - 'total_results' => 1, + 'total_results' => 2, 'total_pages' => 1, 'first' => { 'href' => "#{link_prefix}/v3/buildpacks?lifecycle=cnb&page=1&per_page=50" @@ -233,6 +444,29 @@ 'previous' => nil }, 'resources' => [ + { + 'guid' => buildpack5.guid, + 'lifecycle' => 'cnb', + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'name' => buildpack5.name, + 'state' => buildpack5.state, + 'filename' => buildpack5.filename, + 'stack' => buildpack5.stack, + 'position' => 1, + 'enabled' => true, + 'locked' => false, + 'metadata' => { 'labels' => {}, 'annotations' => {} }, + 'links' => { + 'self' => { + 'href' => "#{link_prefix}/v3/buildpacks/#{buildpack5.guid}" + }, + 'upload' => { + 'href' => "#{link_prefix}/v3/buildpacks/#{buildpack5.guid}/upload", + 'method' => 'POST' + } + } + }, { 'guid' => buildpack4.guid, 'lifecycle' => 'cnb', @@ -242,7 +476,7 @@ 'state' => buildpack4.state, 'filename' => buildpack4.filename, 'stack' => buildpack4.stack, - 'position' => 1, + 'position' => 2, 'enabled' => true, 'locked' => false, 'metadata' => { 'labels' => {}, 'annotations' => {} }, @@ -329,6 +563,145 @@ } ) end + + it 'reverse orders by lifecycle (with position as secondary)' do + get '/v3/buildpacks?order_by=-lifecycle', nil, headers + + expect(parsed_response).to(be_a_response_like( + { + 'pagination' => { + 'total_results' => 5, + 'total_pages' => 1, + 'first' => { + 'href' => "#{link_prefix}/v3/buildpacks?order_by=-lifecycle&page=1&per_page=50" + }, + 'last' => { + 'href' => "#{link_prefix}/v3/buildpacks?order_by=-lifecycle&page=1&per_page=50" + }, + 'next' => nil, + 'previous' => nil + }, + 'resources' => [ + { + 'guid' => buildpack4.guid, + 'lifecycle' => 'cnb', + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'name' => buildpack4.name, + 'state' => buildpack4.state, + 'filename' => buildpack4.filename, + 'stack' => buildpack4.stack, + 'position' => 2, + 'enabled' => true, + 'locked' => false, + 'metadata' => { 'labels' => {}, 'annotations' => {} }, + 'links' => { + 'self' => { + 'href' => "#{link_prefix}/v3/buildpacks/#{buildpack4.guid}" + }, + 'upload' => { + 'href' => "#{link_prefix}/v3/buildpacks/#{buildpack4.guid}/upload", + 'method' => 'POST' + } + } + }, + { + 'guid' => buildpack5.guid, + 'lifecycle' => 'cnb', + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'name' => buildpack5.name, + 'state' => buildpack5.state, + 'filename' => buildpack5.filename, + 'stack' => buildpack5.stack, + 'position' => 1, + 'enabled' => true, + 'locked' => false, + 'metadata' => { 'labels' => {}, 'annotations' => {} }, + 'links' => { + 'self' => { + 'href' => "#{link_prefix}/v3/buildpacks/#{buildpack5.guid}" + }, + 'upload' => { + 'href' => "#{link_prefix}/v3/buildpacks/#{buildpack5.guid}/upload", + 'method' => 'POST' + } + } + }, + { + 'guid' => buildpack2.guid, + 'lifecycle' => 'buildpack', + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'name' => buildpack2.name, + 'state' => buildpack2.state, + 'filename' => buildpack2.filename, + 'stack' => buildpack2.stack, + 'position' => 3, + 'enabled' => true, + 'locked' => false, + 'metadata' => { 'labels' => {}, 'annotations' => {} }, + 'links' => { + 'self' => { + 'href' => "#{link_prefix}/v3/buildpacks/#{buildpack2.guid}" + }, + 'upload' => { + 'href' => "#{link_prefix}/v3/buildpacks/#{buildpack2.guid}/upload", + 'method' => 'POST' + } + } + }, + { + 'guid' => buildpack3.guid, + 'lifecycle' => 'buildpack', + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'name' => buildpack3.name, + 'state' => buildpack3.state, + 'filename' => buildpack3.filename, + 'stack' => buildpack3.stack, + 'position' => 2, + 'enabled' => true, + 'locked' => false, + 'metadata' => { 'labels' => {}, 'annotations' => {} }, + 'links' => { + 'self' => { + 'href' => "#{link_prefix}/v3/buildpacks/#{buildpack3.guid}" + }, + 'upload' => { + 'href' => "#{link_prefix}/v3/buildpacks/#{buildpack3.guid}/upload", + 'method' => 'POST' + } + } + }, + { + 'guid' => buildpack1.guid, + 'lifecycle' => 'buildpack', + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'name' => buildpack1.name, + 'state' => buildpack1.state, + 'filename' => buildpack1.filename, + 'stack' => buildpack1.stack, + 'position' => 1, + 'enabled' => true, + 'locked' => false, + 'metadata' => { 'labels' => {}, 'annotations' => {} }, + 'links' => { + 'self' => { + 'href' => "#{link_prefix}/v3/buildpacks/#{buildpack1.guid}" + }, + 'upload' => { + 'href' => "#{link_prefix}/v3/buildpacks/#{buildpack1.guid}/upload", + 'method' => 'POST' + } + } + } + + ] + } + )) + end end context 'permissions' do diff --git a/spec/unit/controllers/v3/buildpacks_controller_spec.rb b/spec/unit/controllers/v3/buildpacks_controller_spec.rb index f99d423e528..89fcc22444f 100644 --- a/spec/unit/controllers/v3/buildpacks_controller_spec.rb +++ b/spec/unit/controllers/v3/buildpacks_controller_spec.rb @@ -52,9 +52,9 @@ let!(:stack1) { VCAP::CloudController::Stack.make } let!(:stack2) { VCAP::CloudController::Stack.make } - let!(:buildpack1) { VCAP::CloudController::Buildpack.make(stack: stack1.name) } - let!(:buildpack2) { VCAP::CloudController::Buildpack.make(stack: stack2.name) } - let!(:buildpack3) { VCAP::CloudController::Buildpack.make(stack: stack1.name, lifecycle: 'cnb') } + let!(:buildpack1) { VCAP::CloudController::Buildpack.make(stack: stack1.name, position: 2) } + let!(:buildpack2) { VCAP::CloudController::Buildpack.make(stack: stack2.name, position: 1) } + let!(:buildpack3) { VCAP::CloudController::Buildpack.make(stack: stack1.name, lifecycle: 'cnb', position: 1) } before do set_current_user(user) @@ -63,8 +63,9 @@ it 'renders a paginated list of buildpacks' do get :index - expect(parsed_body['resources'].first['guid']).to eq(buildpack1.guid) - expect(parsed_body['resources'].second['guid']).to eq(buildpack2.guid) + expect(parsed_body['resources'].first['guid']).to eq(buildpack2.guid) + expect(parsed_body['resources'].second['guid']).to eq(buildpack1.guid) + expect(parsed_body['resources'].third['guid']).to eq(buildpack3.guid) end it 'renders a lifecycle filtered list of buildpacks' do @@ -90,9 +91,10 @@ it 'renders an ordered list of buildpacks' do get :index, params: { order_by: '-position' } - expect(parsed_body['resources']).to have(2).buildpack - expect(parsed_body['resources'].first['position']).to eq(buildpack2.position) - expect(parsed_body['resources'].second['position']).to eq(buildpack1.position) + expect(parsed_body['resources']).to have(3).buildpack + expect(parsed_body['resources'].first['position']).to eq(2) + expect(parsed_body['resources'].second['position']).to eq(1) + expect(parsed_body['resources'].third['position']).to eq(1) end it 'eager loads associated resources that the presenter specifies' do diff --git a/spec/unit/fetchers/buildpack_list_fetcher_spec.rb b/spec/unit/fetchers/buildpack_list_fetcher_spec.rb index 9f95ea95cf1..c3891f6bc6f 100644 --- a/spec/unit/fetchers/buildpack_list_fetcher_spec.rb +++ b/spec/unit/fetchers/buildpack_list_fetcher_spec.rb @@ -38,7 +38,7 @@ module VCAP::CloudController let(:filters) { {} } it 'fetches all the buildpacks' do - expect(subject).to contain_exactly(buildpack1, buildpack2, buildpack3, buildpack4, buildpack_without_stack) + expect(subject).to contain_exactly(buildpack1, buildpack2, buildpack3, buildpack4, buildpack_without_stack, buildpack5, buildpack6, buildpack7) end end @@ -63,7 +63,7 @@ module VCAP::CloudController end it 'returns all of the desired buildpacks' do - expect(subject).to contain_exactly(buildpack_without_stack) + expect(subject).to contain_exactly(buildpack_without_stack, buildpack7) end end @@ -73,7 +73,7 @@ module VCAP::CloudController end it 'returns all of the desired buildpacks' do - expect(subject).to contain_exactly(buildpack2, buildpack_without_stack) + expect(subject).to contain_exactly(buildpack2, buildpack_without_stack, buildpack6, buildpack7) end end diff --git a/spec/unit/lib/cloud_controller/paging/pagination_options_spec.rb b/spec/unit/lib/cloud_controller/paging/pagination_options_spec.rb index 7197c0b7e5c..a122662c729 100644 --- a/spec/unit/lib/cloud_controller/paging/pagination_options_spec.rb +++ b/spec/unit/lib/cloud_controller/paging/pagination_options_spec.rb @@ -104,6 +104,34 @@ module VCAP::CloudController end end + context 'when secondary_default_order_by is configured' do + before do + pagination_options.secondary_default_order_by = 'id' + pagination_options.default_order_by = 'something' + end + + context 'when order_by is not configured by the user' do + it 'secondary_order_by returns the secondary_default_order_by' do + pagination_options.order_by = nil + expect(pagination_options.secondary_order_by).to eq('id') + end + end + + context 'when order_by is configured by the user' do + it 'secondary_order_by returns nil' do + pagination_options.order_by = 'first_name' + expect(pagination_options.secondary_order_by).to be_nil + end + end + + context 'when order_by is configured by the user to be the same as the default' do + it 'secondary_order_by returns the secondary_default_order_b' do + pagination_options.order_by = 'something' + expect(pagination_options.secondary_order_by).to eq('id') + end + end + end + context 'order_direction' do context 'when the order_direction is nil' do let(:order_direction) { nil } diff --git a/spec/unit/lib/cloud_controller/paging/sequel_paginator_spec.rb b/spec/unit/lib/cloud_controller/paging/sequel_paginator_spec.rb index 7f74df6d7d6..c1ef2d196cd 100644 --- a/spec/unit/lib/cloud_controller/paging/sequel_paginator_spec.rb +++ b/spec/unit/lib/cloud_controller/paging/sequel_paginator_spec.rb @@ -143,6 +143,50 @@ class TableWithoutGuid < Sequel::Model(:table_without_guid); end expect(paginated_result.records.first.keys).to match_array(AppModel.columns) end + it 'orders by secondary_default_order_by if using default order_by' do + Space.make(guid: '1') + Space.make(guid: '2') + Space.make(guid: '3') + Space.make(guid: '4') + options = { page: page, per_page: 4, order_direction: 'asc' } + app_model1.update(guid: '1', space_guid: '2', name: 'yourapp') + app_model2.update(guid: '2', space_guid: '1', name: 'yourapp') + app_model3.update(guid: '3', space_guid: '3', name: 'myapp') + app_model4.update(guid: '4', space_guid: '4', name: 'myapp') + pagination_options = PaginationOptions.new(options) + pagination_options.default_order_by = 'name' + pagination_options.secondary_default_order_by = 'space_guid' + + paginated_result = paginator.get_page(dataset, pagination_options) + + expect(paginated_result.records[0].guid).to eq(app_model3.guid) + expect(paginated_result.records[1].guid).to eq(app_model4.guid) + expect(paginated_result.records[2].guid).to eq(app_model2.guid) + expect(paginated_result.records[3].guid).to eq(app_model1.guid) + end + + it 'does not order by secondary_default_order_by if order_by is set' do + Space.make(guid: '1') + Space.make(guid: '2') + Space.make(guid: '3') + Space.make(guid: '4') + options = { page: page, order_by: 'name', per_page: 4, order_direction: 'asc' } + app_model1.update(guid: '1', space_guid: '2', name: 'yourapp') + app_model2.update(guid: '2', space_guid: '1', name: 'yourapp') + app_model3.update(guid: '3', space_guid: '3', name: 'myapp') + app_model4.update(guid: '4', space_guid: '4', name: 'myapp') + pagination_options = PaginationOptions.new(options) + pagination_options.default_order_by = 'guid' + pagination_options.secondary_default_order_by = 'space_guid' + + paginated_result = paginator.get_page(dataset, pagination_options) + + expect(paginated_result.records[0].guid).to eq(app_model3.guid) + expect(paginated_result.records[1].guid).to eq(app_model4.guid) + expect(paginated_result.records[2].guid).to eq(app_model1.guid) + expect(paginated_result.records[3].guid).to eq(app_model2.guid) + end + it 'orders by GUID as a secondary field when available' do options = { page: page, per_page: 2, order_by: 'created_at', order_direction: 'asc' } app_model1.update(guid: '1', created_at: '2019-12-25T13:00:00Z') diff --git a/spec/unit/messages/buildpack_upload_message_spec.rb b/spec/unit/messages/buildpack_upload_message_spec.rb index 97e689e4b94..fee9b05a1c3 100644 --- a/spec/unit/messages/buildpack_upload_message_spec.rb +++ b/spec/unit/messages/buildpack_upload_message_spec.rb @@ -156,8 +156,8 @@ module VCAP::CloudController allow(File).to receive(:read).and_return("PK\x03\x04".force_encoding('binary')) upload_message = BuildpackUploadMessage.new(opts, lifecycle) expect(upload_message).not_to be_valid - expect(upload_message.errors.full_messages[0]).to - include('buildpack.zip is not a gzip archive or cnb file. Buildpacks of lifecycle "cnb" must be valid gzip archives or cnb files.') + expect(upload_message.errors.full_messages[0]). + to include('buildpack.zip is not a gzip archive or cnb file. Buildpacks of lifecycle "cnb" must be valid gzip archives or cnb files.') end end From 3e35fe061743125be49d4f215cdd9d04678a6c36 Mon Sep 17 00:00:00 2001 From: Seth Boyles Date: Fri, 2 May 2025 16:16:35 -0700 Subject: [PATCH 4/7] Allow specifying buildpacks with CNBs --- app/actions/app_apply_manifest.rb | 1 + .../v3/space_manifests_controller.rb | 1 + app/messages/app_manifest_message.rb | 53 +++++------ spec/unit/actions/app_apply_manifest_spec.rb | 92 +++++++++++++++++++ spec/unit/jobs/deserialization_spec.rb | 1 - .../messages/app_manifest_message_spec.rb | 38 +++++++- 6 files changed, 153 insertions(+), 33 deletions(-) diff --git a/app/actions/app_apply_manifest.rb b/app/actions/app_apply_manifest.rb index 3c41074b104..756ad73f8c4 100644 --- a/app/actions/app_apply_manifest.rb +++ b/app/actions/app_apply_manifest.rb @@ -59,6 +59,7 @@ def apply(app_guid, message) app_update_message = message.app_update_message lifecycle = AppLifecycleProvider.provide_for_update(app_update_message, app) + AppUpdate.new(@user_audit_info, manifest_triggered: true).update(app, app_update_message, lifecycle) update_routes(app, message) diff --git a/app/controllers/v3/space_manifests_controller.rb b/app/controllers/v3/space_manifests_controller.rb index 3a001763d4d..9e8f8cd91ae 100644 --- a/app/controllers/v3/space_manifests_controller.rb +++ b/app/controllers/v3/space_manifests_controller.rb @@ -16,6 +16,7 @@ def apply_manifest can_write_space(space) messages = parsed_app_manifests.map { |app_manifest| AppManifestMessage.create_from_yml(app_manifest) } + errors = messages.each_with_index.flat_map { |message, i| errors_for_message(message, i) } compound_error!(errors) unless errors.empty? diff --git a/app/messages/app_manifest_message.rb b/app/messages/app_manifest_message.rb index ee4757fd13f..36faa3f0e88 100644 --- a/app/messages/app_manifest_message.rb +++ b/app/messages/app_manifest_message.rb @@ -123,16 +123,32 @@ def audit_hash end def app_lifecycle_hash - lifecycle_data = if requested?(:lifecycle) && @lifecycle == 'cnb' - cnb_lifecycle_data + lifecycle_type = if requested?(:lifecycle) && @lifecycle == 'cnb' + Lifecycles::CNB + elsif requested?(:lifecycle) && @lifecycle == 'buildpack' + Lifecycles::BUILDPACK elsif requested?(:docker) - docker_lifecycle_data - elsif requested?(:buildpacks) || requested?(:buildpack) || requested?(:stack) - buildpacks_lifecycle_data + Lifecycles::DOCKER end + data = { + buildpacks: requested_buildpacks, + stack: @stack, + credentials: @cnb_credentials + }.compact + + if lifecycle_type == Lifecycles::DOCKER + lifecycle = { + type: Lifecycles::DOCKER + } + elsif lifecycle_type || data.present? + lifecycle = {} + lifecycle[:type] = lifecycle_type if lifecycle_type.present? + lifecycle[:data] = data if data.present? + end + { - lifecycle: lifecycle_data, + lifecycle: lifecycle, metadata: requested?(:metadata) ? metadata : nil }.compact end @@ -279,31 +295,6 @@ def service_bindings_attribute_mapping mapping end - def docker_lifecycle_data - { type: Lifecycles::DOCKER } - end - - def cnb_lifecycle_data - { - type: Lifecycles::CNB, - data: { - buildpacks: requested_buildpacks, - stack: @stack, - credentials: @cnb_credentials - }.compact - } - end - - def buildpacks_lifecycle_data - { - type: Lifecycles::BUILDPACK, - data: { - buildpacks: requested_buildpacks, - stack: @stack - }.compact - } - end - def should_autodetect?(buildpack) buildpack == 'default' || buildpack == 'null' || buildpack.nil? end diff --git a/spec/unit/actions/app_apply_manifest_spec.rb b/spec/unit/actions/app_apply_manifest_spec.rb index f0761d7cfab..5dcb67fc7cb 100644 --- a/spec/unit/actions/app_apply_manifest_spec.rb +++ b/spec/unit/actions/app_apply_manifest_spec.rb @@ -147,6 +147,98 @@ module VCAP::CloudController end end + context 'cnb apps' do + let(:buildpack) { VCAP::CloudController::Buildpack.make } + let(:message) { AppManifestMessage.create_from_yml({ name: 'blah' }) } + let(:app_update_message) { message.app_update_message } + let(:app) { AppModel.make(:cnb) } + + before do + TestConfig.override(default_app_lifecycle: 'cnb') + end + + context 'when the default_app_lifecycle is set and the the lifecycle is not specified' do + it 'preserves the lifecycle' do + app_apply_manifest.apply(app.guid, message) + expect(AppUpdate).to have_received(:new).with(user_audit_info, manifest_triggered: true) + expect(app_update).to have_received(:update). + with(app, app_update_message, instance_of(AppCNBLifecycle)) + expect(app.reload.lifecycle_type).to eq('cnb') + end + end + + context 'when buildpack is specified' do + let(:message) { AppManifestMessage.create_from_yml({ name: 'blah', buildpack: buildpack.name }) } + + it 'preserves the lifecycle' do + app_apply_manifest.apply(app.guid, message) + expect(AppUpdate).to have_received(:new).with(user_audit_info, manifest_triggered: true) + expect(app_update).to have_received(:update). + with(app, app_update_message, instance_of(AppCNBLifecycle)) + expect(app.reload.lifecycle_type).to eq('cnb') + end + end + + context 'when the default differs from what is already set on the app' do + let(:message) { AppManifestMessage.create_from_yml({ name: 'blah', buildpack: buildpack.name }) } + let(:app) { AppModel.make(:buildpack) } + + it 'preserves the apps lifecycle' do + app_apply_manifest.apply(app.guid, message) + expect(AppUpdate).to have_received(:new).with(user_audit_info, manifest_triggered: true) + expect(app_update).to have_received(:update). + with(app, app_update_message, instance_of(AppBuildpackLifecycle)) + expect(app.reload.lifecycle_type).to eq('buildpack') + end + end + end + + context 'buildpack apps' do + let(:buildpack) { VCAP::CloudController::Buildpack.make } + let(:message) { AppManifestMessage.create_from_yml({ name: 'blah' }) } + let(:app_update_message) { message.app_update_message } + let(:app) { AppModel.make(:buildpack) } + + before do + TestConfig.override(default_app_lifecycle: 'buildpack') + end + + context 'when the default_app_lifecycle is set and the the lifecycle is not specified' do + it 'preserves the lifecycle' do + app_apply_manifest.apply(app.guid, message) + expect(AppUpdate).to have_received(:new).with(user_audit_info, manifest_triggered: true) + expect(app_update).to have_received(:update). + with(app, app_update_message, instance_of(AppBuildpackLifecycle)) + expect(app.reload.lifecycle_type).to eq('buildpack') + end + end + + context 'when buildpack is specified' do + let(:message) { AppManifestMessage.create_from_yml({ name: 'blah', buildpack: buildpack.name }) } + + it 'preserves the lifecycle' do + app_apply_manifest.apply(app.guid, message) + expect(AppUpdate).to have_received(:new).with(user_audit_info, manifest_triggered: true) + expect(app_update).to have_received(:update). + with(app, app_update_message, instance_of(AppBuildpackLifecycle)) + expect(app.reload.lifecycle_type).to eq('buildpack') + end + end + + context 'when the default differs from what is already set on the app' do + let(:message) { AppManifestMessage.create_from_yml({ name: 'blah', buildpack: buildpack.name }) } + let(:app) { AppModel.make(:cnb) } + + it 'preserves the apps lifecycle' do + app_apply_manifest.apply(app.guid, message) + expect(AppUpdate).to have_received(:new).with(user_audit_info, manifest_triggered: true) + expect(app_update).to have_received(:update). + with(app, app_update_message, instance_of(AppCNBLifecycle)) + expect(app.reload.lifecycle_type).to eq('cnb') + end + end + end + describe 'updating stack' do let(:message) { AppManifestMessage.create_from_yml({ name: 'stack-test', stack: 'cflinuxfs4' }) } let(:app_update_message) { message.app_update_message } diff --git a/spec/unit/jobs/deserialization_spec.rb b/spec/unit/jobs/deserialization_spec.rb index 31b350a3e41..c8d15dedc47 100644 --- a/spec/unit/jobs/deserialization_spec.rb +++ b/spec/unit/jobs/deserialization_spec.rb @@ -178,7 +178,6 @@ module Jobs - :lifecycle extra_keys: [] lifecycle: - :type: buildpack :data: :buildpacks: - ruby diff --git a/spec/unit/messages/app_manifest_message_spec.rb b/spec/unit/messages/app_manifest_message_spec.rb index 3680e2de7bc..c46f2531b52 100644 --- a/spec/unit/messages/app_manifest_message_spec.rb +++ b/spec/unit/messages/app_manifest_message_spec.rb @@ -1064,6 +1064,7 @@ module VCAP::CloudController message = AppManifestMessage.create_from_yml(params_from_yaml) expect(message).not_to be_valid + expect(message.errors).to have(1).items expect(message.errors.full_messages).to include('Cannot specify both buildpack(s) and docker keys') end @@ -1995,7 +1996,6 @@ module VCAP::CloudController it 'does not forward missing attributes to the AppUpdateMessage' do message = AppManifestMessage.create_from_yml(parsed_yaml) - expect(message.app_update_message.requested?(:lifecycle)).to be false end end @@ -2105,6 +2105,42 @@ module VCAP::CloudController end end + context 'when buildpack is the default_app_lifecycle but lifecycle is not set' do + before do + TestConfig.override(default_app_lifecycle: 'buildpack') + end + + let(:parsed_yaml) { { name: 'cnb', buildpacks: %w[nodejs java], stack: stack.name } } + + it 'sets the app lifecycle to nil' do + message = AppManifestMessage.create_from_yml(parsed_yaml) + + expect(message).to be_valid + expect(message.app_update_message.lifecycle_type).to eq(nil) + 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 be_nil + end + end + + context 'when cnb is the default_app_lifecycle but lifecycle is not set' do + before do + TestConfig.override(default_app_lifecycle: 'cnb') + end + + let(:parsed_yaml) { { name: 'cnb', buildpacks: %w[nodejs java], stack: stack.name } } + + it 'sets the app lifecycle to cnb' do + message = AppManifestMessage.create_from_yml(parsed_yaml) + + expect(message).to be_valid + expect(message.app_update_message.lifecycle_type).to eq(nil) + 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 be_nil + end + end + context 'when cnb is disabled' do before do FeatureFlag.make(name: 'diego_cnb', enabled: false, error_message: 'I am a banana') From 2a6adba9071e5af3ccdb66fe14f9749e271e11a6 Mon Sep 17 00:00:00 2001 From: Seth Boyles Date: Mon, 5 May 2025 13:48:51 -0700 Subject: [PATCH 5/7] Rubocop fixes --- spec/unit/messages/app_manifest_message_spec.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/unit/messages/app_manifest_message_spec.rb b/spec/unit/messages/app_manifest_message_spec.rb index c46f2531b52..64fd81bf2e5 100644 --- a/spec/unit/messages/app_manifest_message_spec.rb +++ b/spec/unit/messages/app_manifest_message_spec.rb @@ -2116,7 +2116,7 @@ module VCAP::CloudController message = AppManifestMessage.create_from_yml(parsed_yaml) expect(message).to be_valid - expect(message.app_update_message.lifecycle_type).to eq(nil) + expect(message.app_update_message.lifecycle_type).to be_nil 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 be_nil @@ -2134,7 +2134,7 @@ module VCAP::CloudController message = AppManifestMessage.create_from_yml(parsed_yaml) expect(message).to be_valid - expect(message.app_update_message.lifecycle_type).to eq(nil) + expect(message.app_update_message.lifecycle_type).to be_nil 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 be_nil From e46f7690f4da3637df4f1c21282e02793d58cb72 Mon Sep 17 00:00:00 2001 From: Seth Boyles Date: Tue, 6 May 2025 16:14:09 -0700 Subject: [PATCH 6/7] Support .cnb, .tgz, and .tar.gz as part of install_buildpacks --- lib/cloud_controller/install_buildpacks.rb | 2 +- .../install_buildpacks_spec.rb | 78 +++++++++++++++++-- 2 files changed, 74 insertions(+), 6 deletions(-) diff --git a/lib/cloud_controller/install_buildpacks.rb b/lib/cloud_controller/install_buildpacks.rb index 1dbd8cb8c50..fa356d2a051 100644 --- a/lib/cloud_controller/install_buildpacks.rb +++ b/lib/cloud_controller/install_buildpacks.rb @@ -75,7 +75,7 @@ def detected_stack(file, opts) def buildpack_zip(package, zipfile) return zipfile if zipfile - job_dir = File.join('/var/vcap/packages', package, '*.zip') + job_dir = File.join('/var/vcap/packages', package, '*[.zip|.cnb|.tgz|.tar.gz]') Dir[job_dir].first end diff --git a/spec/unit/lib/cloud_controller/install_buildpacks_spec.rb b/spec/unit/lib/cloud_controller/install_buildpacks_spec.rb index e3b7d9f8320..932edeb18a4 100644 --- a/spec/unit/lib/cloud_controller/install_buildpacks_spec.rb +++ b/spec/unit/lib/cloud_controller/install_buildpacks_spec.rb @@ -40,6 +40,74 @@ module VCAP::CloudController end end + context 'when a cnb buildpack is specified' do + let(:file) { 'buildpack.cnb' } + let(:buildpack_fields) do + { name: 'cnb_buildpack', file: file, stack: nil, options: { lifecycle: Lifecycles::CNB } } + end + + let(:install_buildpack_config) do + { + install_buildpacks: [ + { + 'name' => 'cnb_buildpack', + 'package' => 'cnb-buildpack-package', + 'lifecycle' => 'cnb' + } + ] + } + end + + before do + expect(Dir).to receive(:[]).with('/var/vcap/packages/cnb-buildpack-package/*[.zip|.cnb|.tgz|.tar.gz]'). + and_return([file]) + expect(File).to receive(:file?).with(file). + and_return(true) + allow(job_factory).to receive(:plan).with('cnb_buildpack', [buildpack_fields]).and_return([enqueued_job1]) + allow(Jobs::Enqueuer).to receive(:new).and_return(enqueuer) + end + + it 'succeeds' do + installer.install(TestConfig.config_instance.get(:install_buildpacks)) + + expect(job_factory).to have_received(:plan).with('cnb_buildpack', [buildpack_fields]) + end + end + + context 'when a cnb buildpack is specified with .tgz extension' do + let(:file) { 'buildpack.tgz' } + let(:buildpack_fields) do + { name: 'cnb_buildpack', file: file, stack: nil, options: { lifecycle: Lifecycles::CNB } } + end + + let(:install_buildpack_config) do + { + install_buildpacks: [ + { + 'name' => 'cnb_buildpack', + 'package' => 'cnb-buildpack-package', + 'lifecycle' => 'cnb' + } + ] + } + end + + before do + expect(Dir).to receive(:[]).with('/var/vcap/packages/cnb-buildpack-package/*[.zip|.cnb|.tgz|.tar.gz]'). + and_return([file]) + expect(File).to receive(:file?).with(file). + and_return(true) + allow(job_factory).to receive(:plan).with('cnb_buildpack', [buildpack_fields]).and_return([enqueued_job1]) + allow(Jobs::Enqueuer).to receive(:new).and_return(enqueuer) + end + + it 'succeeds' do + installer.install(TestConfig.config_instance.get(:install_buildpacks)) + + expect(job_factory).to have_received(:plan).with('cnb_buildpack', [buildpack_fields]) + end + end + context 'when there are multiple buildpacks' do let(:buildpack1a_file) { 'abuildpacka.zip' } let(:buildpack1b_file) { 'abuildpackb.zip' } @@ -56,15 +124,15 @@ module VCAP::CloudController end before do - expect(Dir).to receive(:[]).with('/var/vcap/packages/mybuildpackpkg/*.zip'). + expect(Dir).to receive(:[]).with('/var/vcap/packages/mybuildpackpkg/*[.zip|.cnb|.tgz|.tar.gz]'). and_return([buildpack1a_file]) expect(File).to receive(:file?).with(buildpack1a_file). and_return(true) - expect(Dir).to receive(:[]).with('/var/vcap/packages/myotherpkg/*.zip'). + expect(Dir).to receive(:[]).with('/var/vcap/packages/myotherpkg/*[.zip|.cnb|.tgz|.tar.gz]'). and_return([buildpack1b_file]) expect(File).to receive(:file?).with(buildpack1b_file). and_return(true) - expect(Dir).to receive(:[]).with('/var/vcap/packages/myotherpkg2/*.zip'). + expect(Dir).to receive(:[]).with('/var/vcap/packages/myotherpkg2/*[.zip|.cnb|.tgz|.tar.gz]'). and_return([buildpack2_file]) expect(File).to receive(:file?).with(buildpack2_file). and_return(true) @@ -124,7 +192,7 @@ module VCAP::CloudController end it 'logs an error when no buildpack zip file is found' do - expect(Dir).to receive(:[]).with('/var/vcap/packages/mybuildpackpkg/*.zip').and_return([]) + expect(Dir).to receive(:[]).with('/var/vcap/packages/mybuildpackpkg/*[.zip|.cnb|.tgz|.tar.gz]').and_return([]) expect(installer.logger).to receive(:error).with(/No file found for the buildpack/) installer.install(TestConfig.config_instance.get(:install_buildpacks)) @@ -212,7 +280,7 @@ module VCAP::CloudController it 'passes optional attributes to the job factory' do expect(Dir).to receive(:[]). - with('/var/vcap/packages/mybuildpackpkg/*.zip'). + with('/var/vcap/packages/mybuildpackpkg/*[.zip|.cnb|.tgz|.tar.gz]'). and_return(['abuildpack.zip']) expect(File).to receive(:file?). with('abuildpack.zip'). From 43e2609289ac14718dbd3e501f31087f3837418b Mon Sep 17 00:00:00 2001 From: Seth Boyles Date: Wed, 7 May 2025 14:28:52 -0700 Subject: [PATCH 7/7] Clarify that app lifecycle type cannot be changed --- docs/v3/source/includes/resources/apps/_update.md.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/v3/source/includes/resources/apps/_update.md.erb b/docs/v3/source/includes/resources/apps/_update.md.erb index d28292408c9..4c28f1a6d55 100644 --- a/docs/v3/source/includes/resources/apps/_update.md.erb +++ b/docs/v3/source/includes/resources/apps/_update.md.erb @@ -39,7 +39,7 @@ Content-Type: application/json Name | Type | Description ---- | ---- | ----------- **name** | _string_ | Name of the app -**lifecycle** | [_lifecycle object_](#the-lifecycle-object) | Lifecycle to be used when updating the app; note: `data` is a required field in lifecycle if lifecycle is updated +**lifecycle** | [_lifecycle object_](#the-lifecycle-object) | Lifecycle to be used when updating the app; note: `data` is a required field in lifecycle if lifecycle is updated. `type` may NOT be changed from its current value. **metadata.labels** | [_label object_](#labels) | Labels applied to the app **metadata.annotations** | [_annotation object_](#annotations) | Annotations applied to the app