custom container registry implementation

This commit is contained in:
Chris
2025-08-02 09:41:49 -07:00
parent 35de00f969
commit db9c838a03
26 changed files with 219 additions and 40 deletions

View File

@@ -5,15 +5,15 @@ module Providers
extend LightService::Organizer
def self.call(provider)
if provider.provider == Provider::GITHUB_PROVIDER
if provider.github?
with(provider:).reduce(
Providers::CreateGithubProvider,
)
elsif provider.provider == Provider::DOCKER_HUB_PROVIDER
elsif provider.container_registry?
with(provider:).reduce(
Providers::CreateDockerImageProvider,
)
elsif provider.provider == Provider::GITLAB_PROVIDER
elsif provider.gitlab?
with(provider:).reduce(
Providers::CreateGitlabProvider,
)

View File

@@ -5,11 +5,29 @@ class Providers::CreateDockerImageProvider
promises :provider
executed do |context|
provider = context.provider
# Test the container registry credentials
begin
DockerCli.with_registry_auth(
registry_url: provider.registry_url,
username: provider.username_param,
password: provider.access_token
) do
# If we get here, authentication was successful
Rails.logger.info("Container registry authentication successful")
end
rescue DockerCli::AuthenticationError => e
context.provider.errors.add(:access_token, "Invalid credentials: #{e.message}")
context.fail_and_return!(e.message)
end
context.provider.auth = {
info: {
username: context.provider.username_param
}
}.to_json
if context.provider.save
context.provider = context.provider
else

View File

@@ -26,6 +26,6 @@ class ProvidersController < ApplicationController
end
private
def provider_params
params.require(:provider).permit(:provider, :username_param, :access_token)
params.require(:provider).permit(:provider, :username_param, :access_token, :registry_url)
end
end

View File

@@ -0,0 +1,46 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["urlInput", "buttons"]
static values = {
registries: Object
}
connect() {
this.registriesValue = {
dockerhub: "https://index.docker.io/v1/",
ghcr: "ghcr.io",
gcr: "gcr.io",
ecr: "ecr.amazonaws.com",
acr: "azurecr.io"
}
// Disable URL field by default
this.urlInputTarget.disabled = true
this.urlInputTarget.classList.add('bg-base-200')
}
selectRegistry(event) {
const registry = event.currentTarget.dataset.registry
if (registry === "other") {
this.urlInputTarget.value = ""
this.urlInputTarget.disabled = false
this.urlInputTarget.classList.remove('bg-base-200')
this.urlInputTarget.focus()
} else if (this.registriesValue[registry]) {
this.urlInputTarget.value = this.registriesValue[registry]
this.urlInputTarget.disabled = true
this.urlInputTarget.classList.add('bg-base-200')
}
// Update active button state
this.element.querySelectorAll('[data-action="click->registry-selector#selectRegistry"]').forEach(btn => {
btn.classList.remove('btn-active', 'btn-primary')
btn.classList.add('btn-outline')
})
event.currentTarget.classList.remove('btn-outline')
event.currentTarget.classList.add('btn-active', 'btn-primary')
}
}

View File

@@ -9,7 +9,7 @@ class Projects::BuildJob < ApplicationJob
def perform(build)
project = build.project
# If its a dockerhub deploy, we don't need to build the docker image
if project.docker_hub?
if project.container_registry?
build.info("Skipping build for #{project.name} because it's a Docker Hub deploy")
else
project_credential_provider = project.project_credential_provider

View File

@@ -83,7 +83,7 @@ class Project < ApplicationRecord
manually_create: 1
}, prefix: :forks
delegate :git?, :github?, :gitlab?, to: :project_credential_provider
delegate :docker_hub?, to: :project_credential_provider
delegate :container_registry?, to: :project_credential_provider
def project_fork_cluster_id_is_owned_by_account
if project_fork_cluster_id.present? && !account.clusters.exists?(id: project_fork_cluster_id)
@@ -165,7 +165,7 @@ class Project < ApplicationRecord
def container_registry_url
container_registry = self.attributes["container_registry_url"].presence || repository_url
tag = docker_hub? ? 'latest' : branch.gsub('/', '-') # Docker Hub uses latest, others use branch name
tag = container_registry? ? 'latest' : branch.gsub('/', '-') # Docker Hub uses latest, others use branch name
if github?
"ghcr.io/#{container_registry}:#{tag}"

View File

@@ -28,7 +28,7 @@ class ProjectCredentialProvider < ApplicationRecord
delegate :username, to: :provider
delegate :access_token, to: :provider
delegate :github?, to: :provider
delegate :docker_hub?, to: :provider
delegate :container_registry?, to: :provider
delegate :gitlab?, to: :provider
delegate :git?, to: :provider

View File

@@ -10,6 +10,7 @@
# last_used_at :datetime
# provider :string
# refresh_token :string
# registry_url :string
# uid :string
# created_at :datetime not null
# updated_at :datetime not null
@@ -26,16 +27,16 @@
class Provider < ApplicationRecord
attr_accessor :username_param
GITHUB_PROVIDER = "github"
DOCKER_HUB_PROVIDER = "docker_hub"
CUSTOM_REGISTRY_PROVIDER = "container_registry"
GITLAB_PROVIDER = "gitlab"
GIT_TYPE = "git"
DOCKER_TYPE = "docker"
REGISTRY_TYPE = "registry"
PROVIDER_TYPES = {
GIT_TYPE => [ GITHUB_PROVIDER, GITLAB_PROVIDER ],
DOCKER_TYPE => [ DOCKER_HUB_PROVIDER ]
REGISTRY_TYPE => [ CUSTOM_REGISTRY_PROVIDER ]
}
AVAILABLE_PROVIDERS = [ GITHUB_PROVIDER, DOCKER_HUB_PROVIDER, GITLAB_PROVIDER ].freeze
AVAILABLE_PROVIDERS = [ GITHUB_PROVIDER, GITLAB_PROVIDER, CUSTOM_REGISTRY_PROVIDER ].freeze
belongs_to :user
@@ -83,8 +84,8 @@ class Provider < ApplicationRecord
end
end
def docker_hub?
provider == DOCKER_HUB_PROVIDER
def container_registry?
provider == CUSTOM_REGISTRY_PROVIDER
end
def github?

View File

@@ -0,0 +1,71 @@
class DockerCli
class AuthenticationError < StandardError; end
def self.with_registry_auth(registry_url:, username:, password:, &block)
normalized_registry = normalize_registry_url(registry_url)
# Login to registry
login_success = login(normalized_registry, username, password)
unless login_success
raise AuthenticationError, "Failed to authenticate with registry: #{normalized_registry}"
end
# Execute the block
begin
yield
ensure
# Always logout, even if block raises an exception
logout(normalized_registry)
end
end
private
def self.login(registry, username, password)
docker_login_command = [
"docker", "login", registry,
"--username", username,
"--password-stdin"
]
stdout, stderr, status = Open3.capture3(*docker_login_command, stdin_data: password)
if status.success?
Rails.logger.info("Successfully logged in to #{registry}")
true
else
Rails.logger.error("Docker login failed for #{registry}: #{stderr}")
false
end
rescue StandardError => e
Rails.logger.error("Docker login error: #{e.message}")
false
end
def self.logout(registry)
stdout, stderr, status = Open3.capture3("docker", "logout", registry)
if status.success?
Rails.logger.info("Successfully logged out from #{registry}")
else
Rails.logger.warn("Docker logout failed for #{registry}: #{stderr}")
end
rescue StandardError => e
Rails.logger.warn("Docker logout error: #{e.message}")
end
def self.normalize_registry_url(url)
return "docker.io" if url.blank?
# Remove protocol if present
url = url.sub(/^https?:\/\//, '')
# Remove trailing slashes and paths
url = url.sub(/\/.*$/, '')
# Handle Docker Hub special cases
if url.include?('docker.io') || url.include?('index.docker.io')
'docker.io'
else
url
end
end
end

View File

@@ -1,5 +1,5 @@
<% if project.docker_hub? %>
<%= render "projects/update/edit_form_docker_hub", project: %>
<% if project.container_registry? %>
<%= render "projects/update/edit_form_container_registry", project: %>
<% else %>
<%= render "projects/update/edit_form_git", project: %>
<% end %>

View File

@@ -1,9 +1,9 @@
<% if selected_provider_type == Provider::GIT_TYPE && selectable_providers.empty? %>
<%= render "projects/create/missing_git_credentials", project: %>
<% elsif selected_provider_type == Provider::DOCKER_TYPE && selectable_providers.empty? %>
<%= render "projects/create/missing_docker_hub_credentials", project: %>
<%= render "projects/create/missing_container_registry_credentials", project: %>
<% elsif selected_provider_type == Provider::DOCKER_TYPE %>
<%= render "projects/create/new_form_docker_hub", project:, provider:, selectable_providers: %>
<%= render "projects/create/new_form_container_registry", project:, provider:, selectable_providers: %>
<% else %>
<%= render "projects/create/new_form_git", project:, provider:, selectable_providers: %>
<% end %>

View File

@@ -19,8 +19,8 @@
</div>
<div class="mt-6">
<%= link_to(
"Deploy from Docker Hub instead →",
new_project_path(provider: Provider::DOCKER_HUB_PROVIDER),
"Deploy from container registry instead →",
new_project_path(provider_type: Provider::REGISTRY_TYPE),
class: "inline-block mt-2 underline underline-offset-4 text-blue-300 hover:text-blue-200 text-sm",
) %>
</div>

View File

@@ -1,6 +1,6 @@
<div data-controller="new-project">
<%= form_with(model: project, data: { turbo: false }) do |form| %>
<h2 class="text-2xl font-bold">Create a new project from Docker Hub</h2>
<h2 class="text-2xl font-bold">Create a new project from a container registry</h2>
<%= link_to(
"Deploy from Git repository instead →",
new_project_path(provider: Provider::GITHUB_PROVIDER),

View File

@@ -22,8 +22,6 @@
<% end %>
</div>
<div class="form-footer">
<%= form.button "Submit", class: "btn btn-primary" %>

View File

@@ -1,7 +1,42 @@
<%= form_with model: provider do |form| %>
<%= render "shared/error_messages", resource: form.object %>
<%= form.hidden_field :provider, value: params[:provider_type] %>
<% if params[:provider_type] == Provider::DOCKER_HUB_PROVIDER || form.object.provider == Provider::DOCKER_HUB_PROVIDER %>
<% if params[:provider_type] == Provider::CUSTOM_REGISTRY_PROVIDER || form.object.provider == Provider::CUSTOM_REGISTRY_PROVIDER %>
<div data-controller="registry-selector">
<div class="form-control mt-1 w-full max-w-sm">
<label class="label">
<span class="label-text">Select Registry</span>
</label>
<div class="flex flex-wrap gap-2 mb-3" data-registry-selector-target="buttons">
<button type="button" class="btn btn-sm btn-outline" data-action="click->registry-selector#selectRegistry" data-registry="dockerhub">
Docker Hub
</button>
<button type="button" class="btn btn-sm btn-outline" data-action="click->registry-selector#selectRegistry" data-registry="ghcr">
GitHub (ghcr.io)
</button>
<button type="button" class="btn btn-sm btn-outline" data-action="click->registry-selector#selectRegistry" data-registry="gcr">
Google (gcr.io)
</button>
<button type="button" class="btn btn-sm btn-outline" data-action="click->registry-selector#selectRegistry" data-registry="ecr">
AWS ECR
</button>
<button type="button" class="btn btn-sm btn-outline" data-action="click->registry-selector#selectRegistry" data-registry="acr">
Azure ACR
</button>
<button type="button" class="btn btn-sm btn-outline" data-action="click->registry-selector#selectRegistry" data-registry="other">
Other
</button>
</div>
</div>
<div class="form-control mt-1 w-full max-w-sm">
<label class="label">
<span class="label-text">Registry URL</span>
</label>
<%= form.text_field :registry_url, class: "input input-bordered", required: true, data: { "registry-selector-target": "urlInput" } %>
</div>
</div>
<div class="form-control mt-1 w-full max-w-sm">
<label class="label">
<span class="label-text">Username</span>

View File

@@ -3,8 +3,8 @@
<hr class="mt-3 mb-4 border-t border-base-300" />
<%= turbo_frame_tag "provider" do %>
<%= render "providers/index" %>
<%= link_to "+ Github Credentials", new_provider_path(provider_type: "github"), class: "btn btn-primary btn-sm" %>
<%= link_to "+ Dockerhub Credentials", new_provider_path(provider_type: "docker_hub"), class: "btn btn-primary btn-sm" %>
<%= link_to "+ Gitlab Credentials", new_provider_path(provider_type: "gitlab"), class: "btn btn-primary btn-sm" %>
<%= link_to "+ Github Credentials", new_provider_path(provider_type: Provider::GITHUB_PROVIDER), class: "btn btn-primary btn-sm" %>
<%= link_to "+ Gitlab Credentials", new_provider_path(provider_type: Provider::GITLAB_PROVIDER), class: "btn btn-primary btn-sm" %>
<%= link_to "+ Container Registry Credentials", new_provider_path(provider_type: Provider::CUSTOM_REGISTRY_PROVIDER), class: "btn btn-primary btn-sm" %>
<% end %>
<% end %>

View File

@@ -6,10 +6,12 @@
</div>
<div>
<div class="mt-2 mb-4">
<% if params[:provider_type] == Provider::DOCKER_HUB_PROVIDER %>
<%= link_to "Find your Docker Hub token →", "https://app.docker.com/settings/personal-access-tokens", target: "_blank", class: "text-sm text-gray-500 underline" %>
<div class=" mt-2">
Make sure you select the <code class="mx-2 bg-white text-black px-2 py-1 rounded-md">Read & Write</code> permission if you want to deploy a private project.
<% if params[:provider_type] == Provider::CUSTOM_REGISTRY_PROVIDER %>
<div class="text-sm text-gray-500">
Select your container registry provider or enter a custom URL.
</div>
<div class="mt-2">
Make sure you have the <code class="mx-2 bg-white text-black px-2 py-1 rounded-md">Read & Write</code> permission if you want to deploy a private project.
</div>
<% elsif params[:provider_type] == Provider::GITHUB_PROVIDER %>
<%= link_to "Find your Github token →", "https://github.com/settings/tokens", target: "_blank", class: "text-sm text-gray-500 " %>

View File

@@ -0,0 +1,5 @@
class AddRegistryUrlToProviders < ActiveRecord::Migration[7.2]
def change
add_column :providers, :registry_url, :string
end
end

3
db/schema.rb generated
View File

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2025_07_19_160150) do
ActiveRecord::Schema[7.2].define(version: 2025_07_29_215525) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -280,6 +280,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_07_19_160150) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.datetime "last_used_at"
t.string "registry_url"
t.index ["user_id"], name: "index_providers_on_user_id"
end

View File

@@ -38,7 +38,7 @@ RSpec.describe Projects::Create do
end
context 'for docker hub' do
let(:provider) { create(:provider, :docker_hub, user:) }
let(:provider) { create(:provider, :container_registry, user:) }
it 'creates a project with project_credential_provider' do
expect(subject).to be_success

View File

@@ -5,7 +5,7 @@ RSpec.describe Providers::Create do
describe '.call' do
context 'when the provider is dockerhub' do
let(:provider) { build(:provider, :docker_hub) }
let(:provider) { build(:provider, :container_registry) }
it 'creates the provider' do
subject
expect(subject).to be_success

View File

@@ -1,7 +1,7 @@
require 'rails_helper'
RSpec.describe Providers::GenerateConfigJson do
let(:provider) { build(:provider, :docker_hub) }
let(:provider) { build(:provider, :container_registry) }
subject(:context) { described_class.execute(provider: provider) }

View File

@@ -56,9 +56,9 @@ FactoryBot.define do
end
end
trait :docker_hub do
trait :container_registry do
after(:build) do |project|
provider = create(:provider, :docker_hub)
provider = create(:provider, :container_registry)
project.project_credential_provider = build(:project_credential_provider, project: project, provider: provider)
end
end

View File

@@ -10,6 +10,7 @@
# last_used_at :datetime
# provider :string
# refresh_token :string
# registry_url :string
# uid :string
# created_at :datetime not null
# updated_at :datetime not null
@@ -32,9 +33,10 @@ FactoryBot.define do
last_used_at { nil }
provider { "github" }
uid { "sample_uid" }
trait :docker_hub do
provider { Provider::DOCKER_HUB_PROVIDER }
trait :container_registry do
provider { Provider::CUSTOM_REGISTRY_PROVIDER }
auth { { "info" => { "username" => "test_user" } }.to_json }
registry_url { "docker.io" }
end
trait :github do

View File

@@ -156,7 +156,7 @@ RSpec.describe Project, type: :model do
end
it 'uses latest tag for Docker Hub' do
docker_project = create(:project, :docker_hub, repository_url: 'owner/repo', branch: 'feature/test')
docker_project = create(:project, :container_registry, repository_url: 'owner/repo', branch: 'feature/test')
expect(docker_project.container_registry_url).to eq('docker.io/owner/repo:latest')
end
end