This commit is contained in:
Chris
2025-11-10 10:41:06 -05:00
62 changed files with 2263 additions and 259 deletions

View File

@@ -39,6 +39,9 @@ RUN apt-get update -qq && \
RUN curl -fL https://app.getambassador.io/download/tel2oss/releases/download/v2.21.1/telepresence-linux-amd64 -o /usr/local/bin/telepresence && \
chmod a+x /usr/local/bin/telepresence
# Install pack CLI for Cloud Native Buildpacks
RUN curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.38.2/pack-v0.38.2-linux.tgz" | tar -xz -C /usr/local/bin
# Install application gems
COPY Gemfile Gemfile.lock ./
RUN bundle install && \
@@ -87,6 +90,7 @@ COPY --from=build /rails /rails
COPY --from=build /usr/local/bin/kubectl /usr/local/bin/kubectl
COPY --from=build /usr/local/bin/helm /usr/local/bin/helm
COPY --from=build /usr/local/bin/telepresence /usr/local/bin/telepresence
COPY --from=build /usr/local/bin/pack /usr/local/bin/pack
# Entrypoint prepares the database.
ENTRYPOINT ["/rails/bin/docker-entrypoint"]

View File

@@ -101,7 +101,7 @@ GEM
activerecord (>= 3.2, < 8.0)
rake (>= 10.4, < 14.0)
ast (2.4.3)
avo (3.25.1)
avo (3.25.3)
actionview (>= 6.1)
active_link_to
activerecord (>= 6.1)
@@ -120,7 +120,7 @@ GEM
avo-heroicons (0.1.1)
base64 (0.3.0)
bcrypt (3.1.20)
benchmark (0.4.1)
benchmark (0.5.0)
bigdecimal (3.3.1)
bindex (0.8.1)
bootsnap (1.18.6)
@@ -140,7 +140,7 @@ GEM
coderay (1.1.3)
concurrent-ruby (1.3.5)
connection_pool (2.5.4)
crack (1.0.0)
crack (1.0.1)
bigdecimal
rexml
crass (1.0.6)
@@ -152,7 +152,7 @@ GEM
activerecord (>= 5.a)
database_cleaner-core (~> 2.0)
database_cleaner-core (2.0.1)
date (3.4.1)
date (3.5.0)
debug (1.11.0)
irb (~> 1.10)
reline (>= 0.3.8)
@@ -192,7 +192,7 @@ GEM
dry-inflector (~> 1.0)
dry-logic (~> 1.4)
zeitwerk (~> 2.6)
erb (5.1.1)
erb (5.1.3)
erubi (1.13.1)
et-orbi (1.4.0)
tzinfo
@@ -244,7 +244,7 @@ GEM
fugit (>= 1.11.0)
railties (>= 6.1.0)
thor (>= 1.0.0)
hashdiff (1.2.0)
hashdiff (1.2.1)
hashie (5.0.0)
http (5.2.0)
addressable (~> 2.8)
@@ -322,8 +322,8 @@ GEM
net-smtp
marcel (1.1.0)
matrix (0.4.2)
meta-tags (2.22.1)
actionpack (>= 6.0.0, < 8.1)
meta-tags (2.22.2)
actionpack (>= 6.0.0, < 8.2)
method_source (1.1.0)
mime-types (3.5.2)
mime-types-data (~> 3.2015)
@@ -472,7 +472,7 @@ GEM
thor (~> 1.0, >= 1.2.2)
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.3.0)
rake (13.3.1)
rdoc (6.15.0)
erb
psych (>= 4.0.0)
@@ -488,7 +488,7 @@ GEM
http-cookie (>= 1.0.2, < 2.0)
mime-types (>= 1.16, < 4.0)
netrc (~> 0.8)
rexml (3.4.1)
rexml (3.4.4)
rspec-core (3.13.5)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.5)
@@ -538,7 +538,7 @@ GEM
ruby-vips (2.2.2)
ffi (~> 1.12)
logger
rubyzip (3.2.0)
rubyzip (3.2.1)
sanitize (6.1.3)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
@@ -599,7 +599,7 @@ GEM
railties (>= 7.0.0)
thor (1.4.0)
tilt (2.4.0)
timeout (0.4.3)
timeout (0.4.4)
tsort (0.2.0)
turbo-rails (2.0.17)
actionpack (>= 7.1.0)
@@ -614,8 +614,8 @@ GEM
uri (1.0.3)
useragent (0.16.11)
version_gem (1.1.4)
view_component (4.0.2)
activesupport (>= 7.1.0, < 8.1)
view_component (4.1.0)
activesupport (>= 7.1.0, < 8.2)
concurrent-ruby (~> 1)
warden (1.2.9)
rack (>= 2.0.9)
@@ -624,7 +624,7 @@ GEM
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
webmock (3.25.1)
webmock (3.26.1)
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)

View File

@@ -0,0 +1,78 @@
class Buildpacks::Details
extend LightService::Action
expects :namespace, :name
promises :result
# Struct for individual buildpack versions
BuildpackVersion = Struct.new(
:version,
:link,
keyword_init: true
) do
def self.from_hash(hash)
new(
version: hash["version"],
link: hash["_link"]
)
end
end
# Struct for latest buildpack information (details endpoint has fewer fields)
BuildpackLatestDetails = Struct.new(
:version,
:namespace,
:name,
:description,
:homepage,
:licenses,
:stacks,
:id,
:verified,
keyword_init: true
) do
VERIFIED_NAMESPACES = %w[io.buildpacks paketo-buildpacks heroku tanzu-buildpacks].freeze
def self.from_hash(hash)
new(
version: hash["version"],
namespace: hash["namespace"],
name: hash["name"],
description: hash["description"],
homepage: hash["homepage"],
licenses: hash["licenses"],
stacks: hash["stacks"],
id: hash["id"],
verified: VERIFIED_NAMESPACES.include?(hash["namespace"])
)
end
end
# Struct for buildpack details result
BuildpackDetailsResult = Struct.new(
:latest,
:versions,
keyword_init: true
) do
def self.from_hash(hash)
new(
latest: BuildpackLatestDetails.from_hash(hash["latest"]),
versions: hash["versions"]&.map { |v| BuildpackVersion.from_hash(v) } || []
)
end
end
executed do |context|
response = HTTParty.get(
"https://registry.buildpacks.io/api/v1/buildpacks/#{context.namespace}/#{context.name}",
headers: {
"Accept" => "application/json"
}
)
if response.success?
context.result = BuildpackDetailsResult.from_hash(response.parsed_response)
else
context.fail_and_return!("Failed to fetch buildpack details: #{response.code}: #{response.message}")
end
end
end

View File

@@ -0,0 +1,94 @@
class Buildpacks::Search
extend LightService::Action
expects :query
promises :results
# Struct for individual buildpack versions
BuildpackVersion = Struct.new(
:version,
:link,
keyword_init: true
) do
def self.from_hash(hash)
new(
version: hash["version"],
link: hash["_link"]
)
end
end
# Struct for latest buildpack information
BuildpackLatest = Struct.new(
:id,
:namespace,
:name,
:version,
:addr,
:yanked,
:description,
:homepage,
:licenses,
:stacks,
:created_at,
:updated_at,
:version_major,
:version_minor,
:version_patch,
:verified,
keyword_init: true
) do
VERIFIED_NAMESPACES = %w[io.buildpacks paketo-buildpacks heroku tanzu-buildpacks].freeze
def self.from_hash(hash)
new(
id: hash["id"],
namespace: hash["namespace"],
name: hash["name"],
version: hash["version"],
addr: hash["addr"],
yanked: hash["yanked"],
description: hash["description"],
homepage: hash["homepage"],
licenses: hash["licenses"],
stacks: hash["stacks"],
created_at: hash["created_at"],
updated_at: hash["updated_at"],
version_major: hash["version_major"],
version_minor: hash["version_minor"],
version_patch: hash["version_patch"],
verified: VERIFIED_NAMESPACES.include?(hash["namespace"])
)
end
end
# Struct for complete buildpack search result
BuildpackResult = Struct.new(
:latest,
:versions,
keyword_init: true
) do
def self.from_hash(hash)
new(
latest: BuildpackLatest.from_hash(hash["latest"]),
versions: hash["versions"]&.map { |v| BuildpackVersion.from_hash(v) } || []
)
end
end
executed do |context|
response = HTTParty.get(
"https://registry.buildpacks.io/api/v1/search",
query: { matches: context.query },
headers: {
"Accept" => "application/json"
}
)
if response.success?
parsed_results = response.parsed_response.map { |result| BuildpackResult.from_hash(result) }
context.results = parsed_results
else
context.fail_and_return!("Failed to search buildpacks: #{response.code}: #{response.message}")
end
end
end

View File

@@ -9,8 +9,6 @@ module Projects
:repository_url,
:branch,
:cluster_id,
:docker_build_context_directory,
:dockerfile_path,
:container_registry_url,
:predeploy_command,
:project_fork_status,
@@ -57,7 +55,10 @@ module Projects
{
provider: project.project_credential_provider.provider,
driver: BuildConfiguration::DEFAULT_BUILDER,
image_repository: project.repository_url
build_type: :dockerfile,
image_repository: project.repository_url,
context_directory: ".",
dockerfile_path: "./Dockerfile"
}
end
@@ -68,6 +69,7 @@ module Projects
end
steps << Projects::ValidateNamespaceAvailability
steps << Projects::InitializeBuildPacks
steps << Projects::Save
# Only register webhook in cloud mode

View File

@@ -0,0 +1,35 @@
# frozen_string_literal: true
module Projects
class InitializeBuildPacks
extend LightService::Action
expects :build_configuration, :params
promises :build_packs
def self.fetch_buildpack_details!(build_pack)
result = Buildpacks::Details.execute(
namespace: build_pack.namespace,
name: build_pack.name
)
build_pack.details = result.result.to_h
build_pack
end
executed do |context|
context.build_packs = []
build_configuration = context.build_configuration
next context unless build_configuration&.buildpacks?
build_packs_params = context.params
.dig(:project, :build_configuration, :build_packs_attributes) || []
next context unless build_packs_params
context.build_packs = build_packs_params.map.with_index do |pack_params, build_order|
permitted = pack_params.permit(:namespace, :name, :version, :reference_type)
build_pack = build_configuration.build_packs.build(permitted.merge(build_order:))
fetch_buildpack_details!(build_pack)
end
end
end
end

View File

@@ -12,7 +12,8 @@ module Projects
build_configuration:,
params:
).reduce(
Projects::UpdateSave
Projects::UpdateSave,
Projects::UpdateBuildPacks
)
end

View File

@@ -0,0 +1,55 @@
class Projects::UpdateBuildPacks
extend LightService::Action
expects :build_configuration, :params
executed do |context|
build_configuration = context.build_configuration
next context unless build_configuration&.buildpacks?
build_packs_params = context.params
.dig(:project, :build_configuration, :build_packs_attributes) || []
# Create a hash of existing build packs keyed by build_pack.key
existing_packs = {}
build_configuration.build_packs.each do |pack|
existing_packs[pack.key] = pack
end
# Track which build packs are in the params and their order
incoming_keys = []
ActiveRecord::Base.transaction do
# Process each incoming build pack
build_packs_params.each_with_index do |pack_params, build_order|
permitted = pack_params.permit(:namespace, :name, :version, :reference_type)
namespace = permitted[:namespace]
name = permitted[:name]
key = "#{namespace}/#{name}"
next if namespace.blank? || name.blank?
incoming_keys << key unless incoming_keys.include?(key)
if existing_packs[key]
# Build pack already exists, update its order
build_pack = existing_packs[key]
build_pack.build_order = build_order
else
# Build pack doesn't exist, create it and fetch details
build_pack = build_configuration.build_packs.build(permitted.merge(build_order:))
Projects::InitializeBuildPacks.fetch_buildpack_details!(build_pack)
end
build_pack.save!
end
# Delete build packs that are not in the incoming params (only persisted ones)
packs_to_delete = build_configuration.build_packs.reject do |pack|
incoming_keys.include?(pack.key)
end
packs_to_delete.each(&:destroy!)
end
context
end
end

View File

@@ -0,0 +1,16 @@
class Avo::Resources::BuildPack < Avo::BaseResource
# self.includes = []
# self.attachments = []
# self.search = {
# query: -> { query.ransack(id_eq: q, m: "or").result(distinct: false) }
# }
def fields
field :id, as: :id
field :build_configuration, as: :belongs_to
field :namespace, as: :text
field :name, as: :text
field :version, as: :text
field :details, as: :code
end
end

View File

@@ -0,0 +1,4 @@
# This controller has been generated to enable Rails' resource routes.
# More information on https://docs.avohq.io/3.0/controllers.html
class Avo::BuildPacksController < Avo::ResourcesController
end

View File

@@ -0,0 +1,22 @@
class BuildPacksController < ApplicationController
def search
result = Buildpacks::Search.execute(query: params[:q])
if result.success?
render json: result.results
else
render json: { error: "Failed to search buildpacks" }, status: :unprocessable_entity
end
end
def details
result = Buildpacks::Details.execute(
namespace: params[:namespace],
name: params[:name]
)
if result.success?
render json: result.result
else
render json: { error: "Failed to fetch buildpack details" }, status: :unprocessable_entity
end
end
end

View File

@@ -0,0 +1,257 @@
import { Controller } from "@hotwired/stimulus"
import Sortable from "sortablejs"
export default class extends Controller {
static targets = ["list", "template", "modal", "baseBuilder", "availableBuildpacks", "selectedBuildpacks"]
static values = {
packs: Object
}
connect() {
this.selectedPacks = []
this.initializeSortable()
// Listen for buildpack selection from search
this.element.addEventListener("buildpack-search:buildpack-selected", this.handleSearchSelection.bind(this))
}
disconnect() {
this.element.removeEventListener("buildpack-search:buildpack-selected", this.handleSearchSelection.bind(this))
}
handleSearchSelection(event) {
const { namespace, name, version, description } = event.detail
// Create a pack object with buildpack.webp as the default image
const pack = {
key: `${namespace}/${name}`,
namespace: namespace,
name: name,
version: version || '',
image: '/images/languages/buildpack.webp',
description: description || '',
reference_type: 'registry'
}
// Check if already selected
if (!this.selectedPacks.some(p => p.key === pack.key)) {
this.selectedPacks.push(pack)
this.displayAvailableBuildpacks()
this.renderSelectedBuildpacks()
}
}
initializeSortable() {
if (this.hasListTarget) {
this.sortable = Sortable.create(this.listTarget, {
animation: 150,
handle: ".drag-handle",
ghostClass: "opacity-50"
})
}
}
initializeModalSortable() {
if (this.hasSelectedBuildpacksTarget && !this.modalSortable) {
this.modalSortable = Sortable.create(this.selectedBuildpacksTarget, {
animation: 150,
ghostClass: "opacity-50",
onEnd: () => {
this.updateSelectedPacksOrder()
}
})
}
}
updateSelectedPacksOrder() {
// Get the current DOM order and update selectedPacks array
const elements = this.selectedBuildpacksTarget.querySelectorAll('[data-key]')
this.selectedPacks = Array.from(elements).map(el => {
const key = el.dataset.key
return { key, ...this.packsValue[key] }
})
}
openModal() {
// Repopulate selectedPacks from existing buildpacks in the form
this.selectedPacks = this.getExistingBuildpacks()
this.displayAvailableBuildpacks()
this.renderSelectedBuildpacks()
this.modalTarget.showModal()
// Initialize sortable after modal is shown
setTimeout(() => this.initializeModalSortable(), 100)
}
closeModal() {
this.modalTarget.close()
}
getExistingBuildpacks() {
const existingPacks = []
const cards = this.listTarget.querySelectorAll('.card')
cards.forEach(card => {
const namespaceInput = card.querySelector('input[name*="[namespace]"]')
const nameInput = card.querySelector('input[name*="[name]"]')
if (namespaceInput && nameInput) {
const key = `${namespaceInput.value}/${nameInput.value}`
const pack = this.packsValue[key]
if (pack) {
existingPacks.push({ key, ...pack })
}
}
})
return existingPacks
}
displayAvailableBuildpacks() {
const builder = this.baseBuilderTarget.value
const namespace = this.detectNamespace(builder)
if (!namespace) {
this.availableBuildpacksTarget.innerHTML = '<div class="text-sm text-gray-500 p-4 text-center">Please select a base builder first</div>'
return
}
const availablePacks = Object.entries(this.packsValue)
.filter(([key, pack]) => pack.namespace === namespace)
.map(([key, pack]) => ({ key, ...pack }))
if (availablePacks.length === 0) {
this.availableBuildpacksTarget.innerHTML = '<div class="text-sm text-gray-500 p-4 text-center">No buildpacks available for this builder</div>'
return
}
this.renderAvailableBuildpacks(availablePacks)
}
renderAvailableBuildpacks(packs) {
const filteredPacks = packs.filter(pack =>
!this.selectedPacks.some(selected => selected.key === pack.key)
)
if (filteredPacks.length === 0) {
this.availableBuildpacksTarget.innerHTML = '<div class="text-sm text-gray-500 p-4 text-center">All buildpacks selected</div>'
return
}
const html = filteredPacks.map(pack => `
<div class="p-3 hover:bg-base-200 cursor-pointer border-b border-base-300 flex items-center gap-3"
data-action="click->buildpack-fields#selectBuildpack"
data-key="${pack.key}">
<img src="${pack.image}" alt="${pack.key}" class="w-10 h-10 object-contain" />
<div class="flex-1">
<div class="font-medium">${pack.namespace}/${pack.name}</div>
<div class="text-sm text-gray-600">${pack.description}</div>
</div>
</div>
`).join('')
this.availableBuildpacksTarget.innerHTML = html
}
renderSelectedBuildpacks() {
if (this.selectedPacks.length === 0) {
this.selectedBuildpacksTarget.innerHTML = '<div class="text-sm text-gray-500 p-4 text-center">No buildpacks selected</div>'
if (this.modalSortable) {
this.modalSortable.destroy()
this.modalSortable = null
}
return
}
const html = this.selectedPacks.map((pack, index) => `
<div class="p-3 hover:bg-base-200 cursor-move border-b border-base-300 flex items-center gap-3"
data-key="${pack.key}"
data-index="${index}">
<img src="${pack.image}" alt="${pack.key}" class="w-10 h-10 object-contain" />
<div class="flex-1">
<div class="font-medium">${pack.namespace}/${pack.name}</div>
<div class="text-sm text-gray-600">${pack.description}</div>
</div>
<button type="button" class="btn btn-xs btn-circle btn-ghost" data-action="click->buildpack-fields#deselectBuildpack" data-index="${index}">
<iconify-icon icon="mdi:close" width="16" height="16"></iconify-icon>
</button>
</div>
`).join('')
this.selectedBuildpacksTarget.innerHTML = html
// Reinitialize sortable after rendering
if (this.modalSortable) {
this.modalSortable.destroy()
this.modalSortable = null
}
this.initializeModalSortable()
}
detectNamespace(builder) {
if (!builder) return null
if (builder.includes("paketo")) {
return "paketo-buildpacks"
} else if (builder.includes("heroku")) {
return "heroku"
}
return null
}
selectBuildpack(event) {
const key = event.currentTarget.dataset.key
const pack = { key, ...this.packsValue[key] }
this.selectedPacks.push(pack)
this.displayAvailableBuildpacks()
this.renderSelectedBuildpacks()
}
deselectBuildpack(event) {
event.stopPropagation()
const index = parseInt(event.currentTarget.dataset.index)
this.selectedPacks.splice(index, 1)
this.displayAvailableBuildpacks()
this.renderSelectedBuildpacks()
}
addSelectedBuildpacks() {
// Clear existing buildpacks from the list
this.listTarget.innerHTML = ''
this.selectedPacks.forEach((pack, index) => {
const template = this.templateTarget.content || this.templateTarget
const clone = template.cloneNode(true)
const img = clone.querySelector('[data-template-image]')
img.src = pack.image
img.alt = pack.key
const title = clone.querySelector('[data-template-title]')
title.textContent = `${pack.namespace}/${pack.name}`
const description = clone.querySelector('[data-template-description]')
description.textContent = pack.description
const namespaceInput = clone.querySelector('[data-template-namespace]')
namespaceInput.value = pack.namespace
const nameInput = clone.querySelector('[data-template-name]')
nameInput.value = pack.name
const referenceTypeInput = clone.querySelector('[data-template-reference-type]')
referenceTypeInput.value = pack.reference_type
const container = document.createElement('div')
container.appendChild(clone)
this.listTarget.insertAdjacentHTML("beforeend", container.innerHTML)
})
this.closeModal()
}
remove(event) {
event.target.closest(".card").remove()
}
}

View File

@@ -0,0 +1,46 @@
import AsyncSearchDropdownController from "./components/async_search_dropdown_controller"
export default class extends AsyncSearchDropdownController {
async fetchResults(query) {
const response = await fetch(`/build_packs/search?q=${encodeURIComponent(query)}`)
if (!response.ok) {
throw new Error('Failed to fetch buildpacks')
}
const data = await response.json()
return data
}
renderItem(buildpack) {
const latest = buildpack.latest
const verifiedBadge = latest.verified
? '<span class="badge badge-primary badge-sm">Verified</span>'
: ''
return `
<div class="flex justify-between items-start text-left">
<div class="flex flex-col flex-1">
<div class="font-semibold">
${latest.namespace}/${latest.name}
</div>
<div class="text-sm text-base-content/60">${latest.description || 'No description'}</div>
<div class="text-xs text-base-content/70 mt-1">
Latest: ${latest.version} | ${latest.licenses?.join(', ') || 'No license info'}
</div>
</div>
${verifiedBadge}
</div>
`
}
onItemSelect(buildpack, itemElement) {
const latest = buildpack.latest
this.dispatch("buildpack-selected", {
detail: {
namespace: latest.namespace,
name: latest.name,
version: latest.version,
description: latest.description
}
})
}
}

View File

@@ -0,0 +1,177 @@
import { Controller } from "@hotwired/stimulus"
import { debounce } from "../../utils"
/**
* Base controller for async search dropdowns with autocomplete
*
* Child controllers must implement:
* - fetchResults(query): Promise<Array> - Fetch and return search results
* - renderItem(item): String - Return HTML string for a single item
* - onItemSelect(item, itemElement): void - Handle item selection
*
* Optional overrides:
* - getInputElement(): HTMLElement - Get the input element (default: finds input in this.element)
* - shouldSearch(query): Boolean - Determine if search should be performed (default: non-empty query)
* - getDebounceDelay(): Number - Debounce delay in ms (default: 500)
*/
export default class extends Controller {
connect() {
this.input = this.getInputElement()
if (!this.input) {
console.error('AsyncSearchDropdown: No input element found')
return
}
// Disable browser autocomplete
this.input.setAttribute('autocomplete', 'off')
// Create dropdown
this.dropdown = this.createDropdown()
this.element.appendChild(this.dropdown)
// Bind search handler with debounce
this.searchHandler = debounce(this.performSearch.bind(this), this.getDebounceDelay())
this.input.addEventListener('input', this.searchHandler)
// Handle click outside to close dropdown
this.clickOutsideHandler = this.handleClickOutside.bind(this)
document.addEventListener('click', this.clickOutsideHandler)
}
disconnect() {
if (this.input) {
this.input.removeEventListener('input', this.searchHandler)
}
document.removeEventListener('click', this.clickOutsideHandler)
}
createDropdown() {
const dropdown = document.createElement('ul')
dropdown.className = 'hidden absolute z-10 w-full mt-1 menu bg-base-200 block rounded-box shadow-lg max-h-[300px] overflow-y-auto'
return dropdown
}
getInputElement() {
return this.element.querySelector('input')
}
getDebounceDelay() {
return 500
}
shouldSearch(query) {
return query.trim().length > 0
}
async performSearch() {
const query = this.input.value
if (!this.shouldSearch(query)) {
this.hideDropdown()
return
}
try {
this.showLoading()
const results = await this.fetchResults(query)
this.renderResults(results)
} catch (error) {
console.error('Search error:', error)
this.showError(error.message || 'Failed to fetch results')
}
}
renderResults(results) {
if (!results || results.length === 0) {
this.showEmpty()
return
}
this.dropdown.innerHTML = results.map((item, index) => `
<li class="cursor-pointer hover:bg-base-300 p-2" data-index="${index}">
${this.renderItem(item)}
</li>
`).join('')
// Store results for later access
this.currentResults = results
// Add click handlers
this.dropdown.querySelectorAll('li').forEach((li, index) => {
li.addEventListener('click', () => {
this.selectItem(results[index], li)
})
})
this.showDropdown()
}
selectItem(item, itemElement) {
this.onItemSelect(item, itemElement)
this.clearInput()
this.hideDropdown()
}
clearInput() {
if (this.input) {
this.input.value = ''
}
}
showDropdown() {
this.dropdown.classList.remove('hidden')
}
hideDropdown() {
this.dropdown.classList.add('hidden')
this.dropdown.innerHTML = ''
}
showLoading() {
this.dropdown.innerHTML = `
<li class="p-4 text-center flex items-center justify-center gap-2">
<span class="loading loading-spinner loading-sm"></span>
<span>Searching...</span>
</li>
`
this.showDropdown()
}
showError(message) {
this.dropdown.innerHTML = `
<li class="p-4 text-center text-error">
${message}
</li>
`
this.showDropdown()
}
showEmpty() {
this.dropdown.innerHTML = `
<li class="p-4 text-center text-base-content/60">
No results found
</li>
`
this.showDropdown()
}
handleClickOutside(event) {
if (!this.element.contains(event.target)) {
this.hideDropdown()
}
}
// Methods to be implemented by child controllers
async fetchResults(query) {
throw new Error('fetchResults must be implemented by child controller')
}
renderItem(item) {
throw new Error('renderItem must be implemented by child controller')
}
onItemSelect(item, itemElement) {
throw new Error('onItemSelect must be implemented by child controller')
}
}

View File

@@ -1,72 +1,33 @@
import { Controller } from "@hotwired/stimulus"
import AsyncSearchDropdownController from "./components/async_search_dropdown_controller"
import { renderHelmChartCard, helmChartHeader } from "../utils/helm_charts"
import { debounce } from "../utils"
export default class extends Controller {
export default class extends AsyncSearchDropdownController {
static values = {
chartName: String
}
connect() {
this.input = this.element.querySelector(`input[name="add_on[metadata][helm_chart][helm_chart.name]"]`)
// disable autocomplete
this.input.setAttribute('autocomplete', 'off')
// Create and append dropdown
this.dropdown = document.createElement('ul')
this.dropdown.className = 'hidden absolute z-10 w-full mt-1 menu bg-base-200 block rounded-box shadow-lg max-h-[300px] overflow-y-auto'
this.element.appendChild(this.dropdown)
// Bind search handler with debounce
this.input.addEventListener('input', debounce(this.performSearch.bind(this), 500));
getInputElement() {
return this.element.querySelector(`input[name="add_on[metadata][helm_chart][helm_chart.name]"]`)
}
async performSearch() {
if (!this.input.value.trim()) {
this.hideDropdown()
return
async fetchResults(query) {
const response = await fetch(`/add_ons/search?q=${encodeURIComponent(query)}`)
if (!response.ok) {
throw new Error('Failed to fetch helm charts')
}
const url = `/add_ons/search?q=${this.input.value}`
const response = await fetch(url)
const data = await response.json()
this.renderResults(data.packages)
return data.packages
}
renderResults(packages) {
if (!packages.length) {
this.hideDropdown()
return
}
this.dropdown.innerHTML = packages.map(pkg => `
<li class="p-2" data-package-name="${pkg.name}" data-package-data="${encodeURIComponent(JSON.stringify(pkg))}">
${helmChartHeader(pkg)}
</li>
`).join('')
// Add click handlers to all list items
this.dropdown.querySelectorAll('li').forEach(li => {
li.addEventListener('click', () => {
this.input.parentElement.classList.add('hidden')
this.input.value = li.dataset.packageName
const packageData = JSON.parse(decodeURIComponent(li.dataset.packageData));
this.hideDropdown()
const chartUrl = `${packageData.repository.name}/${packageData.name}`
document.querySelector(`input[name="add_on[chart_url]"]`).value = chartUrl
this.element.appendChild(renderHelmChartCard(packageData))
})
})
this.showDropdown()
renderItem(pkg) {
return helmChartHeader(pkg)
}
showDropdown() {
this.dropdown.classList.remove('hidden')
}
hideDropdown() {
this.dropdown.classList.add('hidden')
onItemSelect(pkg, itemElement) {
this.input.parentElement.classList.add('hidden')
this.input.value = pkg.name
const chartUrl = `${pkg.repository.name}/${pkg.name}`
document.querySelector(`input[name="add_on[chart_url]"]`).value = chartUrl
this.element.appendChild(renderHelmChartCard(pkg))
}
}

View File

@@ -39,6 +39,7 @@ class BuildConfiguration < ApplicationRecord
belongs_to :project
belongs_to :build_cloud, optional: true
belongs_to :provider
has_many :build_packs, -> { order(:build_order) }, dependent: :destroy
validates_presence_of :project, :provider, :driver
validates_presence_of :image_repository
@@ -48,9 +49,19 @@ class BuildConfiguration < ApplicationRecord
}
def self.permit_params(params)
params.permit(:image_repository, :driver, :build_cloud_id, :provider_id)
params.permit(:image_repository, :driver, :build_cloud_id, :provider_id, :context_directory, :dockerfile_path, :build_type, :buildpack_base_builder)
end
def self.available_buildpacks
packs_file = Rails.root.join("resources", "build_packs", "packs.yaml")
YAML.load_file(packs_file)
end
enum :build_type, {
dockerfile: 0,
buildpacks: 1
}
enum :driver, {
cloud: 0,
docker: 1,

82
app/models/build_pack.rb Normal file
View File

@@ -0,0 +1,82 @@
# == Schema Information
#
# Table name: build_packs
#
# id :bigint not null, primary key
# build_order :integer not null
# details :jsonb
# name :string
# namespace :string
# reference_type :integer not null
# uri :text
# version :string
# created_at :datetime not null
# updated_at :datetime not null
# build_configuration_id :bigint not null
#
# Indexes
#
# index_build_packs_on_build_configuration_id (build_configuration_id)
# index_build_packs_on_config_type_namespace_name (build_configuration_id,reference_type,namespace,name)
# index_build_packs_on_config_uri (build_configuration_id,uri)
#
# Foreign Keys
#
# fk_rails_... (build_configuration_id => build_configurations.id)
#
class BuildPack < ApplicationRecord
VERIFIED_NAMESPACES = %w[io.buildpacks paketo-buildpacks heroku tanzu-buildpacks].freeze
belongs_to :build_configuration
validates_presence_of :build_order
enum :reference_type, {
registry: 0,
git: 1,
url: 2
}
validates :reference_type, presence: true
validates :namespace, presence: true, if: :registry?
validates :name, presence: true, if: :registry?
validates :uri, presence: true, unless: :registry?
# Helper method to get full buildpack reference for pack CLI
def reference
case reference_type.to_sym
when :registry
if version.present?
"#{namespace}/#{name}:#{version}"
else
"#{namespace}/#{name}"
end
else
uri
end
end
# Synthetic property to check if buildpack is from a verified namespace
def verified?
registry? && VERIFIED_NAMESPACES.include?(namespace)
end
# Display name for UI
def display_name
if registry?
"#{namespace}/#{name}"
elsif git? && uri.present?
# Extract repo name from git URL
uri.split('/').last.to_s.gsub('.git', '')
else
uri
end
end
def key
"#{namespace}/#{name}"
end
def static_info
BuildConfiguration.available_buildpacks[key] || {}
end
end

View File

@@ -42,7 +42,7 @@ module Builders
command += [ "--push" ] # Push directly to registry
command += [ "--progress", "plain" ]
command += [ "-t", project.container_image_reference ]
command += [ "-f", File.join(repository_path, project.dockerfile_path) ]
command += [ "-f", File.join(repository_path, project.build_configuration.dockerfile_path) ]
# Add build arguments
project.environment_variables.each do |envar|
@@ -56,7 +56,7 @@ module Builders
command += [ "--push" ]
# Add build context
command << File.join(repository_path, project.docker_build_context_directory)
command << File.join(repository_path, project.build_configuration.context_directory)
command
end

View File

@@ -6,46 +6,18 @@ module Builders
class Docker < Builders::Base
# Build and push the Docker image
def build_image(repository_path)
execute_docker_build(repository_path)
if project.build_configuration.buildpacks?
Builders::Frontends::BuildpackBuilder.new(build).build_with_buildpacks(repository_path)
else
Builders::Frontends::DockerfileBuilder.new(build).build_with_dockerfile(repository_path)
end
end
private
def execute_docker_build(repository_path)
docker_build_command = construct_buildx_command(repository_path)
# Create a new instance of RunAndLog with the build object as the loggable and killable
runner = Cli::RunAndLog.new(build, killable: build)
# Call the runner with the command (joined as a string since RunAndLog expects a string)
runner.call(docker_build_command.join(" "))
rescue Cli::CommandFailedError => e
raise "Docker build failed: #{e.message}"
end
def construct_buildx_command(repository_path)
docker_build_command = [
"docker",
"--context=default",
"buildx",
"build",
"--progress=plain",
"--platform", "linux/amd64",
"-t", project.container_image_reference,
"-f", File.join(repository_path, project.dockerfile_path)
]
# Add environment variables to the build command
project.environment_variables.each do |envar|
docker_build_command.push("--build-arg", "#{envar.name}=\"#{envar.value}\"")
end
docker_build_command.push("--push")
# Add the build context directory at the end
docker_build_command.push(File.join(repository_path, project.docker_build_context_directory))
Rails.logger.info("Docker build command: `#{docker_build_command.join(" ")}`")
docker_build_command
# Pack publishes during build with --publish flag
def publish_during_build?
true
end
end
end

View File

@@ -0,0 +1,86 @@
# frozen_string_literal: true
class Builders::Frontends::BuildpackBuilder
attr_accessor :build, :project
def initialize(build)
@build = build
@project = build.project
end
# Build image using Cloud Native Buildpacks
def build_with_buildpacks(repository_path)
build_config = build.project.build_configuration
build.info("Building with Cloud Native Buildpacks", color: :blue)
build.info("Builder: #{build_config.buildpack_base_builder}", color: :cyan)
build.info("Context: #{build_config.context_directory}", color: :cyan)
# Log buildpacks in order
if build_config.build_packs.any?
build.info("Buildpacks:", color: :cyan)
build_config.build_packs.each do |pack|
verified_badge = pack.verified? ? " [verified]" : ""
build.info(" #{pack.build_order + 1}. #{pack.reference}#{verified_badge}")
end
else
build.info("No buildpacks specified - builder will auto-detect", color: :yellow)
end
# Generate and execute pack command
command = generate_pack_command(repository_path, build_config)
build.info("Running pack build...", color: :green)
run_pack_command(command)
# Push image if not published during build
push_image_after_build unless publish_during_build?
end
private
def generate_pack_command(repository_path, build_config)
image_name = build_config.container_image_reference
context_path = File.join(repository_path, build_config.context_directory)
command = [
"pack", "build", image_name,
"--builder", build_config.buildpack_base_builder,
"--path", context_path
]
# Add buildpacks in order
build_config.build_packs.each do |pack|
command += [ "--buildpack", pack.reference ]
end
# Add publish flag if supported by driver
command << "--publish" if publish_during_build?
# Add pull policy to always pull latest builder
command += [ "--pull-policy", "always" ]
# Trust builder (required for some builders)
command << "--trust-builder"
command.shelljoin
end
def run_pack_command(command)
runner = Cli::RunAndLog.new(build, killable: build)
runner.call(command)
rescue Cli::CommandFailedError => e
raise Projects::BuildJob::BuildFailure, "Pack build failed: #{e.message}"
end
# Override in including class if push happens during build
def publish_during_build?
false
end
# Override in including class to implement image push logic
def push_image_after_build
# Default: assume publish_during_build? is true
# Concrete builders should override if they need separate push
end
end

View File

@@ -0,0 +1,45 @@
class Builders::Frontends::DockerfileBuilder
attr_accessor :build, :project
def initialize(build)
@build = build
@project = build.project
end
def build_with_dockerfile(repository_path)
docker_build_command = construct_buildx_command(repository_path)
# Create a new instance of RunAndLog with the build object as the loggable and killable
runner = Cli::RunAndLog.new(build, killable: build)
# Call the runner with the command (joined as a string since RunAndLog expects a string)
runner.call(docker_build_command.join(" "))
rescue Cli::CommandFailedError => e
raise "Docker build failed: #{e.message}"
end
def construct_buildx_command(repository_path)
docker_build_command = [
"docker",
"--context=default",
"buildx",
"build",
"--progress=plain",
"--platform", "linux/amd64",
"-t", project.container_image_reference,
"-f", File.join(repository_path, project.build_configuration.dockerfile_path)
]
# Add environment variables to the build command
project.environment_variables.each do |envar|
docker_build_command.push("--build-arg", "#{envar.name}=\"#{envar.value}\"")
end
docker_build_command.push("--push")
# Add the build context directory at the end
docker_build_command.push(File.join(repository_path, project.build_configuration.context_directory))
Rails.logger.info("Docker build command: `#{docker_build_command.join(" ")}`")
docker_build_command
end
end

View File

@@ -64,6 +64,8 @@ class Git::Github::Client < Git::Client
def webhook_exists?
webhook.present?
rescue Octokit::NotFound
false
end
def remove_webhook!

View File

@@ -0,0 +1,7 @@
<div class="relative" data-controller="buildpack-search">
<input
type="text"
placeholder="Search for buildpacks (e.g., ruby, node, python)..."
class="input input-bordered w-full"
/>
</div>

View File

@@ -24,7 +24,7 @@
<div class="mt-6 p-4 rounded-lg">
<h3 class="text-lg font-semibold mb-2">Public Git Repository</h3>
<p class="mb-4">Use a public repository by entering the URL below. Features like PR Previews and Auto-Deploy are not available if the repository has not been configured for Render.</p>
<p class="mb-4">Use a public repository by entering the URL below. Features like PR Previews and Auto-Deploy are not available if the repository has not been configured for Canine.</p>
<div class="flex gap-4">
<input type="text" placeholder="czhu12/whiteboarder" data-github-select-repository-target="publicRepository" class="input input-bordered w-full mb-4" />
<button type="button" data-action="github-select-repository#selectPublicRepository" class="btn btn-outline">Continue</button>

View File

@@ -75,7 +75,7 @@
<ul>
<% current_account.projects.order(created_at: :desc).each do |project| %>
<li>
<%= link_to root_projects_path(project), class: "hover:bg-base-content/15 #{'active' if request.path.start_with?("/projects/#{project.id}")}" do %>
<%= link_to root_projects_path(project), class: "hover:bg-base-content/15 #{'active' if request.path.start_with?(project_path(project))}" do %>
<div class="flex items-center gap-2">
<%= project.name %>
</div>
@@ -106,7 +106,7 @@
<ul>
<% current_account.clusters.order(created_at: :desc).each do |cluster| %>
<li>
<%= link_to cluster_path(cluster), class: "hover:bg-base-content/15 #{'active' if current_page?(cluster_path(cluster))}" do %>
<%= link_to cluster_path(cluster), class: "hover:bg-base-content/15 #{'active' if request.path.start_with?(cluster_path(cluster))}" do %>
<div class="flex items-center gap-2">
<%= cluster.name %>
</div>
@@ -137,7 +137,7 @@
<ul>
<% current_account.add_ons.order(created_at: :desc).each do |add_on| %>
<li>
<%= link_to add_on_path(add_on), class: "hover:bg-base-content/15 #{'active' if current_page?(add_on_path(add_on))}" do %>
<%= link_to add_on_path(add_on), class: "hover:bg-base-content/15 #{'active' if request.path.start_with?(add_on_path(add_on))}" do %>
<div class="flex items-center gap-2">
<%= add_on.name %>
</div>

View File

@@ -0,0 +1,87 @@
<div class="flex flex-row gap-6">
<div class="flex-shrink my-4 border-l-2 border-base-300 pl-4">
</div>
<div data-controller="buildpack-fields" data-buildpack-fields-packs-value="<%= BuildConfiguration.available_buildpacks.to_json %>">
<div class="form-control mt-1 mb-2 w-full max-w-md">
<label class="label">
<span class="label-text">Base Builder</span>
</label>
<%= bc_form.select(
:buildpack_base_builder,
options_for_select(
[
["heroku/builder-classic:22", "heroku/builder-classic:22"],
["heroku/builder:22", "heroku/builder:22"],
["heroku/builder:24", "heroku/builder:24"],
["heroku/buildpacks:18", "heroku/buildpacks:18"],
["heroku/buildpacks:20", "heroku/buildpacks:20"],
["paketobuildpacks/builder-jammy-full:latest", "paketobuildpacks/builder-jammy-full:latest"],
["paketobuildpacks/builder:full", "paketobuildpacks/builder:full"]
],
selected: build_configuration.buildpack_base_builder || "heroku/buildpacks:20"
),
{ include_blank: "Select a base builder..." },
{ class: "select select-bordered w-full", data: { buildpack_fields_target: "baseBuilder" } }
) %>
<label class="label">
<span class="label-text-alt">Select the base builder image for Cloud Native Buildpacks</span>
</label>
</div>
<div class="form-control mt-1 mb-2 w-full max-w-md">
<label class="label">
<span class="label-text">Buildpacks</span>
</label>
<div data-buildpack-fields-target="list" class="space-y-2">
<% build_configuration.build_packs.each do |build_pack| %>
<%= render "projects/build_configurations/buildpack_item", build_pack: build_pack %>
<% end %>
</div>
<button type="button" class="btn btn-outline btn-primary mt-2" data-action="click->buildpack-fields#openModal">
<iconify-icon icon="mdi:plus" width="20" height="20"></iconify-icon>
Add Buildpack
</button>
</div>
<template data-buildpack-fields-target="template">
<%= render "projects/build_configurations/buildpack_item" %>
</template>
<dialog data-buildpack-fields-target="modal" class="modal">
<div class="modal-box w-11/12 max-w-5xl">
<h3 class="font-bold text-lg">Add Buildpacks</h3>
<p class="py-2 text-sm text-gray-600">Select buildpacks to add to your build configuration.</p>
<div class="space-y-4 mt-4">
<div>
<h4 class="font-semibold mb-2">Selected Buildpacks</h4>
<div data-buildpack-fields-target="selectedBuildpacks" class="border border-base-300 rounded-lg max-h-48 overflow-y-auto">
<div class="text-sm text-gray-500 p-4 text-center">No buildpacks selected</div>
</div>
</div>
<div>
<h4 class="font-semibold mb-2">Official Buildpacks</h4>
<div data-buildpack-fields-target="availableBuildpacks" class="border border-base-300 rounded-lg max-h-64 overflow-y-auto">
<!-- Official buildpacks will be rendered here -->
</div>
</div>
<div>
<h4 class="font-semibold mb-2">Search Registry</h4>
<%= render "build_packs/search" %>
<div data-buildpack-fields-target="searchResults" class="border border-base-300 rounded-lg max-h-64 overflow-y-auto mt-2">
<!-- Search results will be rendered here -->
</div>
</div>
</div>
<div class="modal-action">
<button type="button" class="btn btn-ghost" data-action="click->buildpack-fields#closeModal">Cancel</button>
<button type="button" class="btn btn-primary" data-action="click->buildpack-fields#addSelectedBuildpacks">Add Selected</button>
</div>
</div>
</dialog>
</div>
</div>

View File

@@ -0,0 +1,19 @@
<% build_pack = local_assigns[:build_pack] || BuildPack.new %>
<div class="card bg-base-200 p-4">
<div class="flex gap-3 items-center">
<div class="drag-handle cursor-move flex items-center">
<iconify-icon icon="mdi:drag-vertical" width="20" height="20" class="text-gray-500"></iconify-icon>
</div>
<img data-template-image class="w-10 h-10 object-contain" src="<%= build_pack.static_info['image'] || '/images/languages/buildpack.webp' %>" />
<div class="flex-1">
<div class="font-medium" data-template-title><%= "#{build_pack.namespace}/#{build_pack.name}" %></div>
<div class="text-sm text-gray-600" data-template-description><%= build_pack.details.dig('latest', 'description') %></div>
</div>
<button type="button" class="btn btn-sm btn-ghost" data-action="click->buildpack-fields#remove">
<iconify-icon icon="mdi:close" width="20" height="20"></iconify-icon>
</button>
</div>
<input type="hidden" data-template-namespace name="project[build_configuration][build_packs_attributes][][namespace]" value="<%= build_pack.namespace %>" />
<input type="hidden" data-template-name name="project[build_configuration][build_packs_attributes][][name]" value="<%= build_pack.name %>" />
<input type="hidden" data-template-reference-type name="project[build_configuration][build_packs_attributes][][reference_type]" value="<%= build_pack.reference_type %>" />
</div>

View File

@@ -0,0 +1,19 @@
<div class="flex flex-row gap-6">
<div class="flex-shrink my-4 border-l-2 border-base-300 pl-4">
</div>
<div class="w-full">
<div class="form-control mt-1 mb-2 w-full max-w-md">
<label class="label">
<span class="label-text">Dockerfile path</span>
</label>
<%= bc_form.text_field(
:dockerfile_path,
class: "input input-bordered w-full focus:outline-offset-0",
value: build_configuration.dockerfile_path
) %>
<label class="label">
<span class="label-text-alt">* Required</span>
</label>
</div>
</div>
</div>

View File

@@ -1,6 +1,6 @@
<div>
<div>
<% build_configuration = project.build_configuration || BuildConfiguration.new(driver: 'docker') %>
<% build_configuration = project.build_configuration || BuildConfiguration.new %>
<%= form.fields_for :build_configuration, build_configuration do |bc_form| %>
<div class="form-control mt-4">
<%= render "shared/partials/radio_selector", selected: build_configuration.driver, options: [
@@ -30,7 +30,22 @@
].select { |option| BuildConfiguration::BUILDER_OPTIONS.include?(option[:value].to_sym) } %>
</div>
<div class="form-control mt-1 mb-2 w-full max-w-sm">
<div class="form-control mt-1 mb-2 w-full max-w-md">
<label class="label">
<span class="label-text">Build context directory</span>
</label>
<%= bc_form.text_field(
:context_directory,
class: "input input-bordered w-full focus:outline-offset-0",
value: build_configuration.context_directory
) %>
<label class="label">
<span class="label-text-alt">Where should we run the build? Defaults to the root of the repository.</span>
</label>
</div>
<div class="form-control mt-1 mb-2 w-full max-w-md">
<label class="label">
<span class="label-text">Credentials</span>
</label>
@@ -49,10 +64,10 @@
{ class: "select select-bordered w-full" }
) %>
<label class="label">
<span class="label-text-alt">Select a provider with a container registry</span>
<span class="label-text-alt">Select a credential that gives access to a container registry</span>
</label>
</div>
<div class="form-control mt-1 mb-2 w-full max-w-sm">
<div class="form-control mt-1 mb-2 w-full max-w-md">
<label class="label">
<span class="label-text">Image repository</span>
</label>
@@ -64,6 +79,34 @@
pattern: "[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]/[a-zA-Z0-9._-]+",
title: "Must be in the format 'namespace/repo'"
) %>
<label class="label">
<span class="label-text-alt">If this is left blank, a container registry will be automatically created for you.</span>
</label>
</div>
<div class="form-control mt-4">
<label class="label mb-2">
<span class="label-text">Build method</span>
</label>
<%= render "shared/partials/radio_selector", selected: build_configuration.build_type || "dockerfile", options: [
{
icon: "skill-icons:docker",
name: "project[build_configuration][build_type]",
label: "Dockerfile",
value: "dockerfile",
description: "Build images using a Dockerfile. Traditional Docker build approach with full control over the build process.",
partial: "projects/build_configurations/dockerfile_fields",
locals: { bc_form:, build_configuration: }
},
{
icon: "devicon:heroku",
name: "project[build_configuration][build_type]",
label: "Buildpacks",
value: "buildpacks",
description: "Use Cloud Native Buildpacks to automatically detect and build your application. No Dockerfile needed.",
partial: "projects/build_configurations/buildpack_fields",
locals: { bc_form:, build_configuration: }
}
] %>
</div>
<% end %>
</div>

View File

@@ -80,16 +80,6 @@
<span class="label-text-alt">* Required</span>
</label>
</div>
<div class="form-control mt-1 mb-2 w-full max-w-sm">
<label class="label">
<span class="label-text">Container registry URL</span>
</label>
<%= form.text_field :container_registry_url, class: "input input-bordered w-full focus:outline-offset-0", value: "" %>
<label class="label">
<span class="label-text-alt">If this is left blank, <span data-new-project-target="gitProviderLabel">Github</span> Container Registry will be used</span>
</label>
</div>
</div>
<% end %>
@@ -100,23 +90,6 @@
<%= form.check_box :autodeploy, class: "checkbox" %>
<% end %>
<%= render(FormFieldComponent.new(
label: "Dockerfile path",
description: "The path to the Dockerfile in your repository."
)) do %>
<%= form.text_field :dockerfile_path, class: "input input-bordered w-full focus:outline-offset-0" %>
<label class="label">
<span class="label-text-alt">* Required</span>
</label>
<% end %>
<%= render(FormFieldComponent.new(
label: "Docker build context directory",
description: "The directory to use as the build context for the Docker build."
)) do %>
<%= form.text_field :docker_build_context_directory, class: "input input-bordered w-full focus:outline-offset-0" %>
<% end %>
<%= render(FormFieldComponent.new(
label: "Predeploy command",
description: "The command to run before deploying the project. This is useful for running migrations or other setup commands."

View File

@@ -22,6 +22,13 @@
<%= render "projects/volumes/index", project: @project %>
</div>
<div>
<h2 class="text-2xl font-bold">Buildpacks</h2>
<hr class="mt-3 mb-4 border-t border-base-300" />
<%= render "build_packs/search" %>
</div>
<div>
<h2 class="text-2xl font-bold">Danger zone</h2>
<hr class="mt-3 mb-4 border-t border-base-300" />

View File

@@ -23,32 +23,10 @@
<%= form.check_box :autodeploy, class: "checkbox" %>
<% end %>
<%= render(FormFieldComponent.new(label: "Dockerfile path")) do %>
<%= form.text_field :dockerfile_path, class: "input input-bordered w-full focus:outline-offset-0" %>
<label class="label">
<span class="label-text-alt">* Required</span>
</label>
<% end %>
<%= render(FormFieldComponent.new(label: "Docker build context directory")) do %>
<%= form.text_field :docker_build_context_directory, class: "input input-bordered w-full focus:outline-offset-0" %>
<% end %>
<%= render(FormFieldComponent.new(label: "Predeploy command")) do %>
<%= form.text_field :predeploy_command, class: "input input-bordered w-full focus:outline-offset-0" %>
<% end %>
<%= render(FormFieldComponent.new(label: "Container registry URL")) do %>
<%= form.text_field(
:container_registry_url,
class: "input input-bordered w-full focus:outline-offset-0",
value: form.object.attributes["container_registry_url"],
) %>
<label class="label">
<span class="label-text-alt">If this is left blank, <%= project.github? ? "Github" : "Gitlab" %> Container Registry will be used</span>
</label>
<% end %>
<% if Flipper.enabled?(:build_configuration, current_account) %>
<%= render(FormFieldComponent.new(label: "Build configuration")) do %>
<%= render "projects/build_configurations/form", form: form, project: project %>

View File

@@ -47,6 +47,12 @@ Rails.application.routes.draw do
# get "/dashboard", to: "dashboard#show", as: :user_root
end
get "/integrations/github/repositories", to: "integrations/github/repositories#index"
resources :build_packs, only: [] do
collection do
get :search
get :details
end
end
resources :add_ons do
collection do
get :search

View File

@@ -0,0 +1,20 @@
class CreateBuildPacks < ActiveRecord::Migration[7.2]
def change
create_table :build_packs do |t|
t.references :build_configuration, null: false, foreign_key: true
t.integer :reference_type, null: false # registry, docker, git, url, path
t.string :namespace # for registry buildpacks
t.string :name # for registry buildpacks
t.string :version
t.integer :build_order, null: false
t.text :uri # for git, url, path, or docker references
t.jsonb :details, default: {}
t.timestamps
end
add_index :build_packs, [ :build_configuration_id, :reference_type, :namespace, :name ], name: 'index_build_packs_on_config_type_namespace_name'
add_index :build_packs, [ :build_configuration_id, :uri ], name: 'index_build_packs_on_config_uri'
add_column :build_configurations, :buildpack_base_builder, :string
end
end

View File

@@ -0,0 +1,49 @@
class MoveDockerFieldsFromProjectToBuildConfiguration < ActiveRecord::Migration[7.2]
def up
# Add build fields to build_configurations (excluding docker_command which is used at runtime)
add_column :build_configurations, :context_directory, :string, default: "./", null: false
add_column :build_configurations, :dockerfile_path, :string, default: "./Dockerfile", null: false
add_column :build_configurations, :build_type, :integer
# Backfill data from projects to build_configurations
reversible do |dir|
dir.up do
Project.reset_column_information
BuildConfiguration.reset_column_information
Project.find_each do |project|
# Create build_configuration if it doesn't exist
if project.build_configuration.nil?
# Skip projects that don't have the required associations
next unless project.project_credential_provider.present?
BuildConfiguration.create!(
project: project,
provider: project.project_credential_provider.provider,
driver: BuildConfiguration::DEFAULT_BUILDER,
image_repository: project.repository_url,
context_directory: project.docker_build_context_directory,
dockerfile_path: project.dockerfile_path,
build_type: :dockerfile,
)
else
# Update existing build_configuration
project.build_configuration.update!(
context_directory: project.docker_build_context_directory,
dockerfile_path: project.dockerfile_path,
build_type: :dockerfile,
)
end
end
end
end
change_column_null :build_configurations, :build_type, false
end
def down
# Remove build fields from build_configurations
remove_column :build_configurations, :context_directory
remove_column :build_configurations, :dockerfile_path
remove_column :build_configurations, :build_type
end
end

4
db/schema.rb generated
View File

@@ -10,7 +10,11 @@
#
# It's strongly recommended that you check this file into your version control system.
<<<<<<< HEAD
ActiveRecord::Schema[7.2].define(version: 2025_11_10_152921) do
=======
ActiveRecord::Schema[7.2].define(version: 2025_11_03_000229) do
>>>>>>> main
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"

View File

@@ -17,13 +17,13 @@ services:
- "5432:5432"
volumes:
- "postgres:/var/lib/postgresql/data"
networks:
- default
web:
build:
context: .
dockerfile: Dockerfile
# Overrides default command so things don't shut down after the process ends.
# command: sleep infinity
depends_on:
- postgres
stdin_open: true
@@ -37,6 +37,9 @@ services:
ACCOUNT_SIGN_IN_ONLY: "true"
volumes:
- ${DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock
networks:
- default
- portainer_network
worker:
build:
@@ -50,6 +53,18 @@ services:
LOCAL_MODE: "true"
volumes:
- ${DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock
networks:
- default
- portainer_network
volumes:
postgres:
networks:
# default local network for this compose file
default:
name: canine_default # optional; can omit to let compose auto-name
# external network created elsewhere
portainer_network:
external: true

View File

@@ -15,13 +15,14 @@
"@codemirror/view": "^6.38.1",
"@gorails/ninja-keys": "^1.2.1",
"@hotwired/stimulus": "^3.2.2",
"@hotwired/turbo-rails": "^8.0.10",
"@hotwired/turbo-rails": "^8.0.20",
"@iconify/tailwind": "^1.1.3",
"@popperjs/core": "^2.11.8",
"@rails/actioncable": "^7.1.0",
"@rails/activestorage": "^7.2.100",
"@rails/request.js": "^0.0.11",
"@rails/request.js": "^0.0.12",
"@rails/ujs": "^7.1.3-4",
"@stimulus-components/sortable": "^5.0.3",
"@tailwindcss/aspect-ratio": "^0.4.0",
"@tailwindcss/forms": "^0.5.0",
"@tailwindcss/typography": "^0.5.15",
@@ -44,6 +45,7 @@
"prettier": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.8",
"sass": "^1.79.3",
"sortablejs": "^1.15.6",
"stimulus-flatpickr": "^3.0.0-0",
"stimulus-textarea-autogrow": "^4.1.0",
"tailwindcss": "^3.4.1",

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -0,0 +1,101 @@
---
# Flat list of available buildpacks
# Key format: namespace/name
paketo-buildpacks/ruby:
namespace: paketo-buildpacks
name: ruby
image: /images/languages/ruby.webp
description: "Paketo Ruby Buildpack - A language family buildpack for building Ruby apps"
reference_type: registry
heroku/ruby:
namespace: heroku
name: ruby
image: /images/languages/ruby.webp
description: "Heroku's buildpack for Ruby applications"
reference_type: registry
paketo-buildpacks/python:
namespace: paketo-buildpacks
name: python
image: /images/languages/python.webp
description: "Paketo Python Buildpack - A language family buildpack for building Python apps"
reference_type: registry
heroku/python:
namespace: heroku
name: python
image: /images/languages/python.webp
description: "Heroku's buildpack for Python applications"
reference_type: registry
paketo-buildpacks/go:
namespace: paketo-buildpacks
name: go
image: /images/languages/golang.webp
description: "Paketo Go Buildpack - A language family buildpack for building Go apps"
reference_type: registry
heroku/go:
namespace: heroku
name: go
image: /images/languages/golang.webp
description: "Heroku's buildpack for Go applications"
reference_type: registry
paketo-buildpacks/java:
namespace: paketo-buildpacks
name: java
image: /images/languages/java.webp
description: "Paketo Java Buildpack - A language family buildpack for building Java apps"
reference_type: registry
heroku/java:
namespace: heroku
name: java
image: /images/languages/java.webp
description: "Heroku's buildpack for Java applications"
reference_type: registry
paketo-buildpacks/nodejs:
namespace: paketo-buildpacks
name: nodejs
image: /images/languages/javascript.webp
description: "Paketo Node.js Buildpack - A language family buildpack for building Node.js apps"
reference_type: registry
heroku/nodejs:
namespace: heroku
name: nodejs
image: /images/languages/javascript.webp
description: "Heroku's buildpack for Node.js applications"
reference_type: registry
paketo-buildpacks/php:
namespace: paketo-buildpacks
name: php
image: /images/languages/php.webp
description: "Paketo PHP Buildpack - A language family buildpack for building PHP apps"
reference_type: registry
heroku/php:
namespace: heroku
name: php
image: /images/languages/php.webp
description: "Heroku's buildpack for PHP applications"
reference_type: registry
paketo-buildpacks/sbt:
namespace: paketo-buildpacks
name: sbt
image: /images/languages/scala.webp
description: "Paketo SBT Buildpack - A buildpack for building Scala apps with SBT"
reference_type: registry
heroku/scala:
namespace: heroku
name: scala
image: /images/languages/scala.webp
description: "Heroku's buildpack for Scala applications"
reference_type: registry

View File

@@ -0,0 +1,95 @@
require 'rails_helper'
RSpec.describe Buildpacks::Details do
describe '.execute' do
let(:namespace) { 'paketo-buildpacks' }
let(:name) { 'passenger' }
let(:fixture_path) { Rails.root.join('spec/resources/build_packs/details_passenger.json') }
let(:fixture_response) { File.read(fixture_path) }
before do
stub_request(:get, "https://registry.buildpacks.io/api/v1/buildpacks/#{namespace}/#{name}")
.to_return(status: 200, body: fixture_response, headers: { 'Content-Type' => 'application/json' })
end
context 'when the API request is successful' do
it 'returns success' do
result = described_class.execute(namespace: namespace, name: name)
expect(result).to be_success
end
it 'returns a BuildpackDetailsResult struct' do
result = described_class.execute(namespace: namespace, name: name)
expect(result.result).to be_a(Buildpacks::Details::BuildpackDetailsResult)
end
it 'parses the latest buildpack information correctly' do
result = described_class.execute(namespace: namespace, name: name)
latest = result.result.latest
expect(latest).to be_a(Buildpacks::Details::BuildpackLatestDetails)
expect(latest.namespace).to eq('paketo-buildpacks')
expect(latest.name).to eq('passenger')
expect(latest.version).to be_present
expect(latest.description).to include('passenger')
expect(latest.homepage).to be_present
expect(latest.licenses).to include('Apache-2.0')
expect(latest.stacks).to be_an(Array)
expect(latest.id).to be_present
end
it 'parses the versions array correctly' do
result = described_class.execute(namespace: namespace, name: name)
versions = result.result.versions
expect(versions).to be_an(Array)
expect(versions.first).to be_a(Buildpacks::Details::BuildpackVersion)
expect(versions.first.version).to be_present
expect(versions.first.link).to be_present
end
it 'versions array contains multiple versions' do
result = described_class.execute(namespace: namespace, name: name)
expect(result.result.versions.length).to be > 1
end
end
context 'when the API request fails' do
before do
stub_request(:get, "https://registry.buildpacks.io/api/v1/buildpacks/#{namespace}/#{name}")
.to_return(status: 404, body: 'Not Found')
end
it 'returns failure' do
result = described_class.execute(namespace: namespace, name: name)
expect(result).to be_failure
end
it 'includes an error message' do
result = described_class.execute(namespace: namespace, name: name)
expect(result.message).to include('Failed to fetch buildpack details')
end
end
context 'with different namespace and name' do
let(:namespace) { 'different-namespace' }
let(:name) { 'different-buildpack' }
before do
stub_request(:get, "https://registry.buildpacks.io/api/v1/buildpacks/#{namespace}/#{name}")
.to_return(status: 200, body: fixture_response, headers: { 'Content-Type' => 'application/json' })
end
it 'constructs the correct URL' do
described_class.execute(namespace: namespace, name: name)
expect(WebMock).to have_requested(:get, "https://registry.buildpacks.io/api/v1/buildpacks/#{namespace}/#{name}")
end
end
end
end

View File

@@ -0,0 +1,77 @@
require 'rails_helper'
RSpec.describe Buildpacks::Search do
describe '.execute' do
let(:query) { 'ruby' }
let(:fixture_path) { Rails.root.join('spec/resources/build_packs/search_ruby.json') }
let(:fixture_response) { File.read(fixture_path) }
before do
stub_request(:get, "https://registry.buildpacks.io/api/v1/search")
.with(query: { matches: query })
.to_return(status: 200, body: fixture_response, headers: { 'Content-Type' => 'application/json' })
end
context 'when the API request is successful' do
it 'returns success' do
result = described_class.execute(query: query)
expect(result).to be_success
end
it 'returns an array of BuildpackResult structs' do
result = described_class.execute(query: query)
expect(result.results).to be_an(Array)
expect(result.results.first).to be_a(Buildpacks::Search::BuildpackResult)
end
it 'parses the latest buildpack information correctly' do
result = described_class.execute(query: query)
first_result = result.results.first
expect(first_result.latest).to be_a(Buildpacks::Search::BuildpackLatest)
expect(first_result.latest.namespace).to eq('paketo-buildpacks')
expect(first_result.latest.name).to eq('ruby')
expect(first_result.latest.description).to include('Ruby')
expect(first_result.latest.licenses).to include('Apache-2.0')
end
it 'parses the versions array correctly' do
result = described_class.execute(query: query)
first_result = result.results.first
expect(first_result.versions).to be_an(Array)
expect(first_result.versions.first).to be_a(Buildpacks::Search::BuildpackVersion)
expect(first_result.versions.first.version).to be_present
expect(first_result.versions.first.link).to be_present
end
it 'handles multiple search results' do
result = described_class.execute(query: query)
expect(result.results.length).to be > 1
end
end
context 'when the API request fails' do
before do
stub_request(:get, "https://registry.buildpacks.io/api/v1/search")
.with(query: { matches: query })
.to_return(status: 500, body: 'Internal Server Error')
end
it 'returns failure' do
result = described_class.execute(query: query)
expect(result).to be_failure
end
it 'includes an error message' do
result = described_class.execute(query: query)
expect(result.message).to include('Failed to search buildpacks')
end
end
end
end

View File

@@ -72,6 +72,68 @@ RSpec.describe Projects::Create do
expect(subject.project.build_configuration.provider_id).to eq(provider.id)
end
end
context 'with buildpacks' do
include_context 'buildpack details stubbing'
let(:params) do
ActionController::Parameters.new({
project: {
name: 'example-repo',
branch: 'main',
cluster_id: cluster.id,
repository_url: 'example/repo',
docker_command: 'rails s',
container_registry_url: '',
project_credential_provider: {
provider_id: provider.id
},
build_configuration: {
driver: 'docker',
image_repository: 'example/repo',
provider_id: provider.id,
build_type: 'buildpacks',
buildpack_base_builder: 'paketobuildpacks/builder:full',
build_packs_attributes: [
{
namespace: 'paketo-buildpacks',
name: 'ruby',
version: '',
reference_type: 'registry'
},
{
namespace: 'paketo-buildpacks',
name: 'nodejs',
version: '1.2.3',
reference_type: 'registry'
}
]
}
}
})
end
it 'creates build packs associated with build configuration' do
expect(subject).to be_success
expect(subject.project.build_configuration).to be_persisted
expect(subject.project.build_configuration.build_type).to eq('buildpacks')
expect(subject.project.build_configuration.buildpack_base_builder).to eq('paketobuildpacks/builder:full')
build_packs = subject.project.build_configuration.build_packs
expect(build_packs.count).to eq(2)
first_pack = build_packs.first
expect(first_pack.namespace).to eq('paketo-buildpacks')
expect(first_pack.name).to eq('ruby')
expect(first_pack.version).to eq('')
expect(first_pack.reference_type).to eq('registry')
second_pack = build_packs.second
expect(second_pack.namespace).to eq('paketo-buildpacks')
expect(second_pack.name).to eq('nodejs')
expect(second_pack.version).to eq('1.2.3')
expect(second_pack.reference_type).to eq('registry')
end
end
end
context 'for docker hub' do
@@ -95,6 +157,7 @@ RSpec.describe Projects::Create do
expect(subject).to eq([
Projects::ValidateGitRepository,
Projects::ValidateNamespaceAvailability,
Projects::InitializeBuildPacks,
Projects::Save,
Projects::RegisterGitWebhook
])
@@ -110,6 +173,7 @@ RSpec.describe Projects::Create do
expect(subject).to eq([
Projects::ValidateGitRepository,
Projects::ValidateNamespaceAvailability,
Projects::InitializeBuildPacks,
Projects::Save
])
end

View File

@@ -0,0 +1,58 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Projects::InitializeBuildPacks do
include_context 'buildpack details stubbing'
let(:provider) { create(:provider, :github) }
let(:project) { create(:project) }
let(:build_configuration) do
create(:build_configuration,
project: project,
provider: provider,
build_type: :buildpacks,
buildpack_base_builder: 'paketobuildpacks/builder:full')
end
let(:params) do
ActionController::Parameters.new({
project: {
build_configuration: {
build_packs_attributes: [
{
namespace: 'paketo-buildpacks',
name: 'ruby',
version: '0.47.7',
reference_type: 'registry'
},
{
namespace: 'paketo-buildpacks',
name: 'nodejs',
version: '',
reference_type: 'registry'
}
]
}
}
})
end
subject { described_class.execute(build_configuration: build_configuration, params: params) }
it 'builds build packs from attributes' do
expect { subject }.to change { build_configuration.build_packs.size }.from(0).to(2)
first_pack = build_configuration.build_packs[0]
expect(first_pack.namespace).to eq('paketo-buildpacks')
expect(first_pack.name).to eq('ruby')
expect(first_pack.version).to eq('0.47.7')
expect(first_pack.reference_type).to eq('registry')
second_pack = build_configuration.build_packs[1]
expect(second_pack.namespace).to eq('paketo-buildpacks')
expect(second_pack.name).to eq('nodejs')
expect(second_pack.version).to eq('')
expect(second_pack.reference_type).to eq('registry')
end
end

View File

@@ -0,0 +1,76 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Projects::UpdateBuildPacks do
include_context 'buildpack details stubbing'
let(:provider) { create(:provider, :github) }
let(:project) { create(:project) }
let(:build_configuration) do
create(:build_configuration,
project: project,
provider: provider,
build_type: :buildpacks,
buildpack_base_builder: 'paketobuildpacks/builder:full')
end
let!(:existing_ruby) do
create(
:build_pack,
build_configuration: build_configuration,
namespace: 'paketo-buildpacks',
name: 'ruby',
reference_type: 'registry',
build_order: 0,
)
end
let!(:existing_nodejs) do
create(
:build_pack,
build_configuration: build_configuration,
namespace: 'paketo-buildpacks',
name: 'nodejs',
reference_type: 'registry',
build_order: 1,
)
end
let(:params) do
ActionController::Parameters.new({
project: {
build_configuration: {
build_packs_attributes: [
{
namespace: 'paketo-buildpacks',
name: 'go',
version: '',
reference_type: 'registry'
},
{
namespace: 'paketo-buildpacks',
name: 'ruby',
version: '',
reference_type: 'registry'
}
]
}
}
})
end
let(:context) do
{
build_configuration: build_configuration,
params: params
}
end
it 'keeps existing build packs, creates new ones, and deletes missing ones' do
expect(build_configuration.build_packs.map(&:key)).to eq([ 'paketo-buildpacks/ruby', 'paketo-buildpacks/nodejs' ])
described_class.execute(context)
build_configuration.build_packs.reload
expect(build_configuration.build_packs.map(&:key)).to eq([ 'paketo-buildpacks/go', 'paketo-buildpacks/ruby' ])
end
end

View File

@@ -9,9 +9,7 @@ RSpec.describe Projects::Update do
name: 'original-name',
branch: 'main',
cluster: cluster,
docker_build_context_directory: '.',
repository_url: 'original/repo',
dockerfile_path: 'Dockerfile',
)
end
@@ -23,9 +21,11 @@ RSpec.describe Projects::Update do
name: 'updated-name',
branch: 'develop',
cluster_id: cluster.id,
docker_build_context_directory: './app',
repository_url: 'updated/repo',
dockerfile_path: 'docker/Dockerfile'
build_configuration: {
context_directory: './app',
dockerfile_path: 'docker/Dockerfile'
}
}
})
end
@@ -37,9 +37,10 @@ RSpec.describe Projects::Update do
expect(result).to be_success
expect(result.project.name).to eq('updated-name')
expect(result.project.branch).to eq('develop')
expect(result.project.docker_build_context_directory).to eq('./app')
expect(result.project.build_configuration.context_directory).to eq('./app')
expect(result.project.repository_url).to eq('updated/repo')
expect(result.project.dockerfile_path).to eq('docker/Dockerfile')
expect(result.project.docker_command).to eq('bundle exec rails s')
expect(result.project.build_configuration.dockerfile_path).to eq('docker/Dockerfile')
end
it 'strips and downcases repository_url' do
@@ -61,7 +62,9 @@ RSpec.describe Projects::Update do
driver: 'k8s',
build_cloud_id: build_cloud.id,
provider_id: build_provider.id,
image_repository: 'updated/repo'
image_repository: 'updated/repo',
context_directory: './app',
dockerfile_path: 'docker/Dockerfile'
}
}
})
@@ -189,5 +192,72 @@ RSpec.describe Projects::Update do
expect(project.reload.name).to eq(original_name)
end
end
context 'with buildpacks' do
include_context 'buildpack details stubbing'
let(:build_provider) { create(:provider, :container_registry, user:) }
let!(:existing_build_config) do
create(:build_configuration,
project: project,
driver: 'docker',
provider: build_provider,
build_type: 'buildpacks',
buildpack_base_builder: 'paketobuildpacks/builder:full'
)
end
let!(:old_build_pack) do
create(:build_pack,
build_configuration: existing_build_config,
namespace: 'paketo-buildpacks',
name: 'python',
version: '1.0.0',
build_order: 0
)
end
let(:params) do
ActionController::Parameters.new({
project: {
name: 'updated-name',
build_configuration: {
build_type: 'buildpacks',
buildpack_base_builder: 'paketobuildpacks/builder:full',
build_packs_attributes: [
{
namespace: 'paketo-buildpacks',
name: 'ruby',
version: '0.47.7',
reference_type: 'registry'
},
{
namespace: 'paketo-buildpacks',
name: 'nodejs',
version: '1.2.3',
reference_type: 'registry'
}
]
}
}
})
end
subject { described_class.call(project, params) }
it 'updates build packs with correct order and removes old packs' do
expect { subject }.to change { BuildPack.count }.by(1)
result = subject
expect(result).to be_success
build_packs = project.build_configuration.reload.build_packs
expect(build_packs.count).to eq(2)
expect(build_packs.map(&:name)).not_to include('python')
expect(build_packs.first.name).to eq('ruby')
expect(build_packs.first.build_order).to eq(0)
expect(build_packs.second.name).to eq('nodejs')
expect(build_packs.second.build_order).to eq(1)
end
end
end
end

View File

@@ -32,6 +32,9 @@ FactoryBot.define do
provider
project
driver { :docker }
build_type { :dockerfile }
image_repository { "CanineHQ/canine" }
context_directory { "." }
dockerfile_path { "./Dockerfile" }
end
end

View File

@@ -0,0 +1,62 @@
# == Schema Information
#
# Table name: build_packs
#
# id :bigint not null, primary key
# build_order :integer not null
# details :jsonb
# name :string
# namespace :string
# reference_type :integer not null
# uri :text
# version :string
# created_at :datetime not null
# updated_at :datetime not null
# build_configuration_id :bigint not null
#
# Indexes
#
# index_build_packs_on_build_configuration_id (build_configuration_id)
# index_build_packs_on_config_type_namespace_name (build_configuration_id,reference_type,namespace,name)
# index_build_packs_on_config_uri (build_configuration_id,uri)
#
# Foreign Keys
#
# fk_rails_... (build_configuration_id => build_configurations.id)
#
FactoryBot.define do
factory :build_pack do
build_configuration
reference_type { "registry" }
namespace { "paketo-buildpacks" }
name { "ruby" }
version { "0.47.7" }
sequence(:build_order) { |n| n }
details do
{
"description" => "A language family buildpack for building Ruby apps",
"homepage" => "https://github.com/paketo-buildpacks/ruby",
"licenses" => [ "Apache-2.0" ],
"stacks" => [ "io.buildpacks.stacks.bionic", "io.buildpacks.stacks.jammy" ]
}
end
trait :git do
reference_type { "git" }
namespace { nil }
name { nil }
version { nil }
uri { "https://github.com/DataDog/heroku-buildpack-datadog.git" }
details { {} }
end
trait :url do
reference_type { "url" }
namespace { nil }
name { nil }
version { nil }
uri { "https://github.com/heroku/buildpacks-ruby/releases/download/v0.1.0/buildpack.tgz" }
details { {} }
end
end
end

View File

@@ -0,0 +1,96 @@
# == Schema Information
#
# Table name: build_packs
#
# id :bigint not null, primary key
# build_order :integer not null
# details :jsonb
# name :string
# namespace :string
# reference_type :integer not null
# uri :text
# version :string
# created_at :datetime not null
# updated_at :datetime not null
# build_configuration_id :bigint not null
#
# Indexes
#
# index_build_packs_on_build_configuration_id (build_configuration_id)
# index_build_packs_on_config_type_namespace_name (build_configuration_id,reference_type,namespace,name)
# index_build_packs_on_config_uri (build_configuration_id,uri)
#
# Foreign Keys
#
# fk_rails_... (build_configuration_id => build_configurations.id)
#
require 'rails_helper'
RSpec.describe BuildPack, type: :model do
describe '#reference' do
context 'registry buildpack' do
let(:build_pack) { create(:build_pack) }
context 'when version is present' do
before { build_pack.update(version: '0.47.7') }
it 'returns namespace/name:version' do
expect(build_pack.reference).to eq('paketo-buildpacks/ruby:0.47.7')
end
end
context 'when version is not present' do
before { build_pack.update(version: nil) }
it 'returns namespace/name' do
expect(build_pack.reference).to eq('paketo-buildpacks/ruby')
end
end
end
context 'git buildpack' do
let(:build_pack) { create(:build_pack, :git) }
it 'returns the git URL' do
expect(build_pack.reference).to eq('https://github.com/DataDog/heroku-buildpack-datadog.git')
end
end
context 'url buildpack' do
let(:build_pack) { create(:build_pack, :url) }
it 'returns the URL' do
expect(build_pack.reference).to eq('https://github.com/heroku/buildpacks-ruby/releases/download/v0.1.0/buildpack.tgz')
end
end
end
describe '#verified?' do
it 'returns true for registry buildpacks with verified namespaces' do
build_pack = create(:build_pack, namespace: 'paketo-buildpacks')
expect(build_pack.verified?).to be true
end
it 'returns false for registry buildpacks with unverified namespaces' do
build_pack = create(:build_pack, namespace: 'custom-buildpacks')
expect(build_pack.verified?).to be false
end
it 'returns false for git buildpacks' do
build_pack = create(:build_pack, :git)
expect(build_pack.verified?).to be false
end
end
describe '#display_name' do
it 'returns namespace/name for registry buildpacks' do
build_pack = create(:build_pack)
expect(build_pack.display_name).to eq('paketo-buildpacks/ruby')
end
it 'returns repo name for git buildpacks' do
build_pack = create(:build_pack, :git)
expect(build_pack.display_name).to eq('heroku-buildpack-datadog')
end
end
end

View File

@@ -24,7 +24,7 @@ require 'support/webmock'
# directory. Alternatively, in the individual `*_spec.rb` files, manually
# require only the support files necessary.
#
# Rails.root.glob('spec/support/**/*.rb').sort_by(&:to_s).each { |f| require f }
Rails.root.glob('spec/support/**/*.rb').sort_by(&:to_s).each { |f| require f }
# Checks for pending migrations and applies them before tests are run.
# If you are not using ActiveRecord, you can remove these lines.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,21 @@
# frozen_string_literal: true
RSpec.shared_context 'buildpack details stubbing' do
let(:buildpack_details_result) do
double(
result: Buildpacks::Details::BuildpackDetailsResult.new(
latest: {
version: '0.47.7',
namespace: 'paketo-buildpacks',
name: 'go',
description: 'Go buildpack'
},
versions: []
)
)
end
before do
allow(Buildpacks::Details).to receive(:execute).and_return(buildpack_details_result)
end
end

252
yarn.lock
View File

@@ -97,11 +97,126 @@
style-mod "^4.1.0"
w3c-keyname "^2.2.4"
"@esbuild/aix-ppc64@0.24.0":
version "0.24.0"
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.24.0.tgz#b57697945b50e99007b4c2521507dc613d4a648c"
integrity sha512-WtKdFM7ls47zkKHFVzMz8opM7LkcsIp9amDUBIAWirg70RM71WRSjdILPsY5Uv1D42ZpUfaPILDlfactHgsRkw==
"@esbuild/android-arm64@0.24.0":
version "0.24.0"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.24.0.tgz#1add7e0af67acefd556e407f8497e81fddad79c0"
integrity sha512-Vsm497xFM7tTIPYK9bNTYJyF/lsP590Qc1WxJdlB6ljCbdZKU9SY8i7+Iin4kyhV/KV5J2rOKsBQbB77Ab7L/w==
"@esbuild/android-arm@0.24.0":
version "0.24.0"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.24.0.tgz#ab7263045fa8e090833a8e3c393b60d59a789810"
integrity sha512-arAtTPo76fJ/ICkXWetLCc9EwEHKaeya4vMrReVlEIUCAUncH7M4bhMQ+M9Vf+FFOZJdTNMXNBrWwW+OXWpSew==
"@esbuild/android-x64@0.24.0":
version "0.24.0"
resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.24.0.tgz#e8f8b196cfdfdd5aeaebbdb0110983460440e705"
integrity sha512-t8GrvnFkiIY7pa7mMgJd7p8p8qqYIz1NYiAoKc75Zyv73L3DZW++oYMSHPRarcotTKuSs6m3hTOa5CKHaS02TQ==
"@esbuild/darwin-arm64@0.24.0":
version "0.24.0"
resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.0.tgz"
integrity sha512-CKyDpRbK1hXwv79soeTJNHb5EiG6ct3efd/FTPdzOWdbZZfGhpbcqIpiD0+vwmpu0wTIL97ZRPZu8vUt46nBSw==
"@esbuild/darwin-x64@0.24.0":
version "0.24.0"
resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.24.0.tgz#33087aab31a1eb64c89daf3d2cf8ce1775656107"
integrity sha512-rgtz6flkVkh58od4PwTRqxbKH9cOjaXCMZgWD905JOzjFKW+7EiUObfd/Kav+A6Gyud6WZk9w+xu6QLytdi2OA==
"@esbuild/freebsd-arm64@0.24.0":
version "0.24.0"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.0.tgz#bb76e5ea9e97fa3c753472f19421075d3a33e8a7"
integrity sha512-6Mtdq5nHggwfDNLAHkPlyLBpE5L6hwsuXZX8XNmHno9JuL2+bg2BX5tRkwjyfn6sKbxZTq68suOjgWqCicvPXA==
"@esbuild/freebsd-x64@0.24.0":
version "0.24.0"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.24.0.tgz#e0e2ce9249fdf6ee29e5dc3d420c7007fa579b93"
integrity sha512-D3H+xh3/zphoX8ck4S2RxKR6gHlHDXXzOf6f/9dbFt/NRBDIE33+cVa49Kil4WUjxMGW0ZIYBYtaGCa2+OsQwQ==
"@esbuild/linux-arm64@0.24.0":
version "0.24.0"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.24.0.tgz#d1b2aa58085f73ecf45533c07c82d81235388e75"
integrity sha512-TDijPXTOeE3eaMkRYpcy3LarIg13dS9wWHRdwYRnzlwlA370rNdZqbcp0WTyyV/k2zSxfko52+C7jU5F9Tfj1g==
"@esbuild/linux-arm@0.24.0":
version "0.24.0"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.24.0.tgz#8e4915df8ea3e12b690a057e77a47b1d5935ef6d"
integrity sha512-gJKIi2IjRo5G6Glxb8d3DzYXlxdEj2NlkixPsqePSZMhLudqPhtZ4BUrpIuTjJYXxvF9njql+vRjB2oaC9XpBw==
"@esbuild/linux-ia32@0.24.0":
version "0.24.0"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.24.0.tgz#8200b1110666c39ab316572324b7af63d82013fb"
integrity sha512-K40ip1LAcA0byL05TbCQ4yJ4swvnbzHscRmUilrmP9Am7//0UjPreh4lpYzvThT2Quw66MhjG//20mrufm40mA==
"@esbuild/linux-loong64@0.24.0":
version "0.24.0"
resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.24.0.tgz#6ff0c99cf647504df321d0640f0d32e557da745c"
integrity sha512-0mswrYP/9ai+CU0BzBfPMZ8RVm3RGAN/lmOMgW4aFUSOQBjA31UP8Mr6DDhWSuMwj7jaWOT0p0WoZ6jeHhrD7g==
"@esbuild/linux-mips64el@0.24.0":
version "0.24.0"
resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.24.0.tgz#3f720ccd4d59bfeb4c2ce276a46b77ad380fa1f3"
integrity sha512-hIKvXm0/3w/5+RDtCJeXqMZGkI2s4oMUGj3/jM0QzhgIASWrGO5/RlzAzm5nNh/awHE0A19h/CvHQe6FaBNrRA==
"@esbuild/linux-ppc64@0.24.0":
version "0.24.0"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.24.0.tgz#9d6b188b15c25afd2e213474bf5f31e42e3aa09e"
integrity sha512-HcZh5BNq0aC52UoocJxaKORfFODWXZxtBaaZNuN3PUX3MoDsChsZqopzi5UupRhPHSEHotoiptqikjN/B77mYQ==
"@esbuild/linux-riscv64@0.24.0":
version "0.24.0"
resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.24.0.tgz#f989fdc9752dfda286c9cd87c46248e4dfecbc25"
integrity sha512-bEh7dMn/h3QxeR2KTy1DUszQjUrIHPZKyO6aN1X4BCnhfYhuQqedHaa5MxSQA/06j3GpiIlFGSsy1c7Gf9padw==
"@esbuild/linux-s390x@0.24.0":
version "0.24.0"
resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.24.0.tgz#29ebf87e4132ea659c1489fce63cd8509d1c7319"
integrity sha512-ZcQ6+qRkw1UcZGPyrCiHHkmBaj9SiCD8Oqd556HldP+QlpUIe2Wgn3ehQGVoPOvZvtHm8HPx+bH20c9pvbkX3g==
"@esbuild/linux-x64@0.24.0":
version "0.24.0"
resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.24.0.tgz#4af48c5c0479569b1f359ffbce22d15f261c0cef"
integrity sha512-vbutsFqQ+foy3wSSbmjBXXIJ6PL3scghJoM8zCL142cGaZKAdCZHyf+Bpu/MmX9zT9Q0zFBVKb36Ma5Fzfa8xA==
"@esbuild/netbsd-x64@0.24.0":
version "0.24.0"
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.24.0.tgz#1ae73d23cc044a0ebd4f198334416fb26c31366c"
integrity sha512-hjQ0R/ulkO8fCYFsG0FZoH+pWgTTDreqpqY7UnQntnaKv95uP5iW3+dChxnx7C3trQQU40S+OgWhUVwCjVFLvg==
"@esbuild/openbsd-arm64@0.24.0":
version "0.24.0"
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.0.tgz#5d904a4f5158c89859fd902c427f96d6a9e632e2"
integrity sha512-MD9uzzkPQbYehwcN583yx3Tu5M8EIoTD+tUgKF982WYL9Pf5rKy9ltgD0eUgs8pvKnmizxjXZyLt0z6DC3rRXg==
"@esbuild/openbsd-x64@0.24.0":
version "0.24.0"
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.24.0.tgz#4c8aa88c49187c601bae2971e71c6dc5e0ad1cdf"
integrity sha512-4ir0aY1NGUhIC1hdoCzr1+5b43mw99uNwVzhIq1OY3QcEwPDO3B7WNXBzaKY5Nsf1+N11i1eOfFcq+D/gOS15Q==
"@esbuild/sunos-x64@0.24.0":
version "0.24.0"
resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.24.0.tgz#8ddc35a0ea38575fa44eda30a5ee01ae2fa54dd4"
integrity sha512-jVzdzsbM5xrotH+W5f1s+JtUy1UWgjU0Cf4wMvffTB8m6wP5/kx0KiaLHlbJO+dMgtxKV8RQ/JvtlFcdZ1zCPA==
"@esbuild/win32-arm64@0.24.0":
version "0.24.0"
resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.24.0.tgz#6e79c8543f282c4539db684a207ae0e174a9007b"
integrity sha512-iKc8GAslzRpBytO2/aN3d2yb2z8XTVfNV0PjGlCxKo5SgWmNXx82I/Q3aG1tFfS+A2igVCY97TJ8tnYwpUWLCA==
"@esbuild/win32-ia32@0.24.0":
version "0.24.0"
resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.24.0.tgz#057af345da256b7192d18b676a02e95d0fa39103"
integrity sha512-vQW36KZolfIudCcTnaTpmLQ24Ha1RjygBo39/aLkM2kmjkWmZGEJ5Gn9l5/7tzXA42QGIoWbICfg6KLLkIw6yw==
"@esbuild/win32-x64@0.24.0":
version "0.24.0"
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.24.0.tgz#168ab1c7e1c318b922637fad8f339d48b01e1244"
integrity sha512-7IAFPrjSQIJrGsK6flwg7NFmwBoSTyF3rl7If0hNUFQU4ilTsEPL6GuMuU9BfIWVVGuRnuIidkSMC+c0Otu8IA==
"@gorails/ninja-keys@^1.2.1":
version "1.2.1"
resolved "https://registry.npmjs.org/@gorails/ninja-keys/-/ninja-keys-1.2.1.tgz"
@@ -111,23 +226,23 @@
hotkeys-js "3.8.9"
lit "2.2.2"
"@hotwired/stimulus@^3.0.0", "@hotwired/stimulus@^3.2.1", "@hotwired/stimulus@^3.2.2", "@hotwired/stimulus@>= 3.0.0":
"@hotwired/stimulus@^3.0.0", "@hotwired/stimulus@^3.2.2":
version "3.2.2"
resolved "https://registry.npmjs.org/@hotwired/stimulus/-/stimulus-3.2.2.tgz"
integrity sha512-eGeIqNOQpXoPAIP7tC1+1Yc1yl1xnwYqg+3mzqxyrbE5pg5YFBZcA6YoTiByJB6DKAEsiWtl6tjTJS4IYtbB7A==
"@hotwired/turbo-rails@^8.0.10":
version "8.0.10"
resolved "https://registry.npmjs.org/@hotwired/turbo-rails/-/turbo-rails-8.0.10.tgz"
integrity sha512-BkERfjTbNwMb9/YQi0RL9+f9zkD+dZH2klEONtGwXrIE3O9BE1937Nn9++koZpDryD4XN3zE5U5ibyWoYJAWBg==
"@hotwired/turbo-rails@^8.0.20":
version "8.0.20"
resolved "https://registry.yarnpkg.com/@hotwired/turbo-rails/-/turbo-rails-8.0.20.tgz#a6f6f78591e9868ca1e5e67f4c7d453dbd49a475"
integrity sha512-4aYkYF9XMKL7ZZPfgElq15+60osZOwMwhztE4myKQYEzCPvaPUxwZH301tOrBNtWUwOD+TNOm1Hrpeaq22RX9A==
dependencies:
"@hotwired/turbo" "^8.0.6"
"@rails/actioncable" "^7.0"
"@hotwired/turbo" "^8.0.20"
"@rails/actioncable" ">=7.0"
"@hotwired/turbo@^8.0.6":
version "8.0.10"
resolved "https://registry.npmjs.org/@hotwired/turbo/-/turbo-8.0.10.tgz"
integrity sha512-xen1YhNQirAHlA8vr/444XsTNITC1Il2l/Vx4w8hAWPpI5nQO78mVHNsmFuayETodzPwh25ob2TgfCEV/Loiog==
"@hotwired/turbo@^8.0.20":
version "8.0.20"
resolved "https://registry.yarnpkg.com/@hotwired/turbo/-/turbo-8.0.20.tgz#068ede648c4db09fed4cf0ac0266788056673f2f"
integrity sha512-IilkH/+h92BRLeY/rMMR3MUh1gshIfdra/qZzp/Bl5FmiALD/6sQZK/ecxSbumeyOYiWr/JRI+Au1YQmkJGnoA==
"@iconify/tailwind@^1.1.3":
version "1.1.3"
@@ -251,7 +366,7 @@
"@nodelib/fs.stat" "2.0.5"
run-parallel "^1.1.9"
"@nodelib/fs.stat@^2.0.2", "@nodelib/fs.stat@2.0.5":
"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2":
version "2.0.5"
resolved "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz"
integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==
@@ -286,7 +401,12 @@
resolved "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz"
integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==
"@rails/actioncable@^7.0", "@rails/actioncable@^7.1.0":
"@rails/actioncable@>=7.0":
version "8.1.100"
resolved "https://registry.yarnpkg.com/@rails/actioncable/-/actioncable-8.1.100.tgz#b1f85f3482425fb91e5eba1deb55152ee0bb2f85"
integrity sha512-j4vJQqz51CDVYv2UafKRu4jiZi5/gTnm7NkyL+VMIgEw3s8jtVtmzu9uItUaZccUg9NJ6o05yVyBAHxNfTuCRA==
"@rails/actioncable@^7.1.0":
version "7.2.100"
resolved "https://registry.npmjs.org/@rails/actioncable/-/actioncable-7.2.100.tgz"
integrity sha512-7xtIENf0Yw59AFDM3+xqxPCZxev3QVAqjPmUzmgsB9eL8S/zTpB0IU9srNc7XknzJI4e09XKNnCaJRx3gfYzXA==
@@ -298,10 +418,10 @@
dependencies:
spark-md5 "^3.0.1"
"@rails/request.js@^0.0.11":
version "0.0.11"
resolved "https://registry.npmjs.org/@rails/request.js/-/request.js-0.0.11.tgz"
integrity sha512-2U3uYS0kbljt+pAstN+LIlZOl7xmOKig5N6FrvtUWO1wq0zR1Hf90fHfD2SYiyV8yH1nyKpoTmbLqWT0xe1zDg==
"@rails/request.js@^0.0.12":
version "0.0.12"
resolved "https://registry.yarnpkg.com/@rails/request.js/-/request.js-0.0.12.tgz#3d1f73e7585141d9c4c2149a34476d128eb900bc"
integrity sha512-g3//JBja1s04Zflj7IoMLQuXza9i4ZvtLmm0r0dMwh1QQUs6rL2iKUOGGyERfLsd81SnXC5ucfVV//rtsDlEEA==
"@rails/ujs@^7.1.3-4":
version "7.1.3-4"
@@ -313,6 +433,11 @@
resolved "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz"
integrity sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==
"@stimulus-components/sortable@^5.0.3":
version "5.0.3"
resolved "https://registry.yarnpkg.com/@stimulus-components/sortable/-/sortable-5.0.3.tgz#b8b64f954a05497fa6ca00f21557d466a2ebe47b"
integrity sha512-GSiu4CX2irR3gIuo3nsSNXlJsEvXVurC2caNTNshlGbsP0sHb70bLn7iiVib7iGSmLJdXdU6wCpD3AEnMKCt2Q==
"@tailwindcss/aspect-ratio@^0.4.0":
version "0.4.2"
resolved "https://registry.npmjs.org/@tailwindcss/aspect-ratio/-/aspect-ratio-0.4.2.tgz"
@@ -447,7 +572,7 @@ braces@^3.0.3, braces@~3.0.2:
dependencies:
fill-range "^7.1.1"
browserslist@^4.23.3, "browserslist@>= 4.21.0":
browserslist@^4.23.3:
version "4.24.0"
resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.24.0.tgz"
integrity sha512-Rmb62sR1Zpjql25eSanFGEhAxcFwfA1K0GuQcLoaJBAcENegrQut3hYdhXFF1obQfiDyqIW/cLM5HSJ/9k884A==
@@ -467,7 +592,7 @@ caniuse-lite@^1.0.30001646, caniuse-lite@^1.0.30001663:
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001663.tgz"
integrity sha512-o9C3X27GLKbLeTYZ6HBOLU1tsAcBZsLis28wrVzddShCS16RujjHp9GDHKZqrB3meE0YjhawvMFsGb/igqiPzA==
chart.js@^4.4.6, chart.js@>=2.8.0, chart.js@4:
chart.js@4, chart.js@^4.4.6:
version "4.4.6"
resolved "https://registry.npmjs.org/chart.js/-/chart.js-4.4.6.tgz"
integrity sha512-8Y406zevUPbbIBA/HRk33khEmQPk5+cxeflWE/2rx1NJsjVWMPw/9mSP9rxHP5eqi6LNoPBVMfZHxbwLSgldYA==
@@ -488,37 +613,7 @@ chartkick@^5.0.1:
chartjs-adapter-date-fns ">=3"
date-fns ">=2"
chokidar@^3.3.0:
version "3.6.0"
resolved "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz"
integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==
dependencies:
anymatch "~3.1.2"
braces "~3.0.2"
glob-parent "~5.1.2"
is-binary-path "~2.1.0"
is-glob "~4.0.1"
normalize-path "~3.0.0"
readdirp "~3.6.0"
optionalDependencies:
fsevents "~2.3.2"
chokidar@^3.5.2:
version "3.6.0"
resolved "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz"
integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==
dependencies:
anymatch "~3.1.2"
braces "~3.0.2"
glob-parent "~5.1.2"
is-binary-path "~2.1.0"
is-glob "~4.0.1"
normalize-path "~3.0.0"
readdirp "~3.6.0"
optionalDependencies:
fsevents "~2.3.2"
chokidar@^3.5.3:
chokidar@^3.3.0, chokidar@^3.5.2, chokidar@^3.5.3:
version "3.6.0"
resolved "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz"
integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==
@@ -635,7 +730,7 @@ daisyui@^4.12.10:
picocolors "^1"
postcss-js "^4"
date-fns@>=2, date-fns@>=2.0.0:
date-fns@>=2:
version "4.1.0"
resolved "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz"
integrity sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==
@@ -699,7 +794,7 @@ esbuild-rails@^1.0.7:
dependencies:
fast-glob "^3.2.12"
esbuild@*, esbuild@^0.24.0:
esbuild@^0.24.0:
version "0.24.0"
resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.24.0.tgz"
integrity sha512-FuLPevChGDshgSicjisSooU0cemp/sGXR841D5LHMB7mTVOmsEHcAxaH3irL53+8YDIeVNQEySh4DaYU/iuPqQ==
@@ -764,7 +859,7 @@ fill-range@^7.1.1:
dependencies:
to-regex-range "^5.0.1"
flatpickr@^4.6.10, flatpickr@>=4.6.2:
flatpickr@^4.6.10:
version "4.6.13"
resolved "https://registry.npmjs.org/flatpickr/-/flatpickr-4.6.13.tgz"
integrity sha512-97PMG/aywoYpB4IvbvUJi0RQi8vearvU0oov1WW3k0WZPBMrTQVqekSX5CjSG/M4Q3i6A/0FKXC7RyAoAUUSPw==
@@ -943,7 +1038,7 @@ jackspeak@^3.1.2:
optionalDependencies:
"@pkgjs/parseargs" "^0.11.0"
jiti@^1.21.0, jiti@>=1.21.0:
jiti@^1.21.0:
version "1.21.6"
resolved "https://registry.npmjs.org/jiti/-/jiti-1.21.6.tgz"
integrity sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==
@@ -1000,15 +1095,6 @@ lit-html@^2.2.0, lit-html@^2.8.0:
dependencies:
"@types/trusted-types" "^2.0.2"
lit@^2.0.0:
version "2.8.0"
resolved "https://registry.npmjs.org/lit/-/lit-2.8.0.tgz"
integrity sha512-4Sc3OFX9QHOJaHbmTMk28SYgVxLN3ePDjg7hofEft2zWlehFL3LiAuapWc4U/kYwMYJSh2hTCPZ6/LIC7ii0MA==
dependencies:
"@lit/reactive-element" "^1.6.0"
lit-element "^3.3.0"
lit-html "^2.8.0"
lit@2.2.2:
version "2.2.2"
resolved "https://registry.npmjs.org/lit/-/lit-2.2.2.tgz"
@@ -1018,6 +1104,15 @@ lit@2.2.2:
lit-element "^3.2.0"
lit-html "^2.2.0"
lit@^2.0.0:
version "2.8.0"
resolved "https://registry.npmjs.org/lit/-/lit-2.8.0.tgz"
integrity sha512-4Sc3OFX9QHOJaHbmTMk28SYgVxLN3ePDjg7hofEft2zWlehFL3LiAuapWc4U/kYwMYJSh2hTCPZ6/LIC7ii0MA==
dependencies:
"@lit/reactive-element" "^1.6.0"
lit-element "^3.3.0"
lit-html "^2.8.0"
local-time@^3.0.2:
version "3.0.2"
resolved "https://registry.npmjs.org/local-time/-/local-time-3.0.2.tgz"
@@ -1270,14 +1365,6 @@ postcss-reporter@^7.0.0:
picocolors "^1.0.0"
thenby "^1.3.4"
postcss-selector-parser@^6.0.11, postcss-selector-parser@^6.1.1:
version "6.1.2"
resolved "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz"
integrity sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==
dependencies:
cssesc "^3.0.0"
util-deprecate "^1.0.2"
postcss-selector-parser@6.0.10:
version "6.0.10"
resolved "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz"
@@ -1286,12 +1373,20 @@ postcss-selector-parser@6.0.10:
cssesc "^3.0.0"
util-deprecate "^1.0.2"
postcss-selector-parser@^6.0.11, postcss-selector-parser@^6.1.1:
version "6.1.2"
resolved "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz"
integrity sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==
dependencies:
cssesc "^3.0.0"
util-deprecate "^1.0.2"
postcss-value-parser@^4.0.0, postcss-value-parser@^4.2.0:
version "4.2.0"
resolved "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz"
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
postcss@^8.0.0, postcss@^8.1.0, postcss@^8.2.14, postcss@^8.4.21, postcss@^8.4.23, postcss@^8.4.47, postcss@>=8.0.9:
postcss@^8.4.23, postcss@^8.4.47:
version "8.4.47"
resolved "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz"
integrity sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==
@@ -1305,7 +1400,7 @@ prettier-plugin-tailwindcss@^0.6.8:
resolved "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.8.tgz"
integrity sha512-dGu3kdm7SXPkiW4nzeWKCl3uoImdd5CTZEJGxyypEPL37Wj0HT2pLqjrvSei1nTeuQfO4PUfjeW5cTUNRLZ4sA==
prettier@^3.0, prettier@^3.3.3:
prettier@^3.3.3:
version "3.3.3"
resolved "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz"
integrity sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==
@@ -1423,7 +1518,12 @@ slash@^5.0.0, slash@^5.1.0:
resolved "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz"
integrity sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==
source-map-js@^1.2.1, "source-map-js@>=0.6.2 <2.0.0":
sortablejs@^1.15.6:
version "1.15.6"
resolved "https://registry.yarnpkg.com/sortablejs/-/sortablejs-1.15.6.tgz#ff93699493f5b8ab8d828f933227b4988df1d393"
integrity sha512-aNfiuwMEpfBM/CN6LY0ibyhxPfPbyFeBTYJKCvzkJ2GkUpazIt3H+QIPAMHwqQ7tMKaHz1Qj+rJJCqljnf4p3A==
"source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.2.1:
version "1.2.1"
resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz"
integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==
@@ -1544,7 +1644,7 @@ svg.filter.js@^2.0.2:
dependencies:
svg.js "^2.2.5"
svg.js@^2.0.1, svg.js@^2.2.5, svg.js@^2.4.0, svg.js@^2.6.5, svg.js@>=2.3.x:
svg.js@>=2.3.x, svg.js@^2.0.1, svg.js@^2.2.5, svg.js@^2.4.0, svg.js@^2.6.5:
version "2.7.1"
resolved "https://registry.npmjs.org/svg.js/-/svg.js-2.7.1.tgz"
integrity sha512-ycbxpizEQktk3FYvn/8BH+6/EuWXg7ZpQREJvgacqn46gIddG24tNNe4Son6omdXCnSOaApnpZw6MPCBA1dODA==
@@ -1583,7 +1683,7 @@ tailwindcss-stimulus-components@^5.1.0:
resolved "https://registry.npmjs.org/tailwindcss-stimulus-components/-/tailwindcss-stimulus-components-5.1.1.tgz"
integrity sha512-9e3H9WLZoGzWQBVOdYvxqM42MsJ4dfkfdrs3NGV+HYvHbIcF9tukl2MPKVZpBv/pP+puZbJv0lFgdn79IGVApA==
tailwindcss@^3.4.1, "tailwindcss@>=2.0.0 || >=3.0.0 || >=3.0.0-alpha.1", "tailwindcss@>=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20", "tailwindcss@>=3.0.0 || insiders || >=4.0.0-alpha.20":
tailwindcss@^3.4.1:
version "3.4.13"
resolved "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.13.tgz"
integrity sha512-KqjHOJKogOUt5Bs752ykCeiwvi0fKVkr5oqsFNt/8px/tA8scFPIlkygsf6jXrfCqGHz7VflA6+yytWuM+XhFw==