mirror of
https://github.com/czhu12/canine.git
synced 2025-12-16 16:35:10 -06:00
merged
This commit is contained in:
9
.github/workflows/ci.yml
vendored
9
.github/workflows/ci.yml
vendored
@@ -53,10 +53,19 @@ jobs:
|
||||
env:
|
||||
RAILS_ENV: test
|
||||
DATABASE_URL: postgres://postgres:postgres@localhost:5432
|
||||
CI: true
|
||||
run: |
|
||||
bin/rails db:test:prepare
|
||||
bin/bundle exec rspec --exclude-pattern spec/system/local/**/*_spec.rb
|
||||
|
||||
- name: Upload coverage report
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: coverage-report
|
||||
path: coverage/
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Keep screenshots from failed system tests
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
|
||||
26
.github/workflows/docker-build.yml
vendored
26
.github/workflows/docker-build.yml
vendored
@@ -3,13 +3,16 @@ name: Docker Build and Push
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
@@ -19,3 +22,20 @@ jobs:
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ghcr.io/caninehq/canine:latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
6
Gemfile
6
Gemfile
@@ -80,8 +80,8 @@ gem "noticed", "~> 2.9"
|
||||
gem "octokit", "~> 10.0"
|
||||
gem "oj", "~> 3.16"
|
||||
gem "omniauth", "~> 2.1"
|
||||
gem "omniauth-digitalocean", "~> 0.3.2"
|
||||
gem "omniauth-github", "~> 2.0"
|
||||
gem "omniauth_openid_connect", "~> 0.8"
|
||||
gem "omniauth-gitlab", "~> 4.1"
|
||||
gem "omniauth-rails_csrf_protection", "~> 1.0"
|
||||
gem "pagy", "~> 9.4"
|
||||
@@ -101,3 +101,7 @@ gem "tailwindcss-rails", "~> 2.7"
|
||||
gem 'flipper', '~> 1.2.2'
|
||||
gem 'flipper-active_record', '~> 1.2.2'
|
||||
gem 'flipper-ui', '~> 1.2.2'
|
||||
|
||||
gem "net-ldap", "~> 0.20.0"
|
||||
|
||||
gem "jwt", "~> 2.9"
|
||||
|
||||
60
Gemfile.lock
60
Gemfile.lock
@@ -97,10 +97,12 @@ GEM
|
||||
tzinfo (~> 2.0, >= 2.0.5)
|
||||
addressable (2.8.7)
|
||||
public_suffix (>= 2.0.2, < 7.0)
|
||||
aes_key_wrap (1.1.0)
|
||||
annotate (3.2.0)
|
||||
activerecord (>= 3.2, < 8.0)
|
||||
rake (>= 10.4, < 14.0)
|
||||
ast (2.4.3)
|
||||
attr_required (1.0.2)
|
||||
avo (3.25.3)
|
||||
actionview (>= 6.1)
|
||||
active_link_to
|
||||
@@ -122,6 +124,7 @@ GEM
|
||||
bcrypt (3.1.20)
|
||||
benchmark (0.5.0)
|
||||
bigdecimal (3.3.1)
|
||||
bindata (2.5.1)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.18.6)
|
||||
msgpack (~> 1.2)
|
||||
@@ -192,6 +195,8 @@ GEM
|
||||
dry-inflector (~> 1.0)
|
||||
dry-logic (~> 1.4)
|
||||
zeitwerk (~> 2.6)
|
||||
email_validator (2.2.4)
|
||||
activemodel
|
||||
erb (5.1.3)
|
||||
erubi (1.13.1)
|
||||
et-orbi (1.4.0)
|
||||
@@ -208,6 +213,8 @@ GEM
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
logger
|
||||
faraday-follow_redirects (0.4.0)
|
||||
faraday (>= 1, < 3)
|
||||
faraday-net_http (3.4.1)
|
||||
net-http (>= 0.5.0)
|
||||
ffi (1.17.0-aarch64-linux-gnu)
|
||||
@@ -284,6 +291,13 @@ GEM
|
||||
jsbundling-rails (1.3.1)
|
||||
railties (>= 6.0.0)
|
||||
json (2.13.2)
|
||||
json-jwt (1.17.0)
|
||||
activesupport (>= 4.2)
|
||||
aes_key_wrap
|
||||
base64
|
||||
bindata
|
||||
faraday (~> 2.0)
|
||||
faraday-follow_redirects
|
||||
json-schema (5.2.2)
|
||||
addressable (~> 2.8)
|
||||
bigdecimal (~> 3.1)
|
||||
@@ -344,6 +358,9 @@ GEM
|
||||
net-imap (0.5.12)
|
||||
date
|
||||
net-protocol
|
||||
net-ldap (0.20.0)
|
||||
base64
|
||||
ostruct
|
||||
net-pop (0.1.2)
|
||||
net-protocol
|
||||
net-protocol (0.2.2)
|
||||
@@ -385,10 +402,6 @@ GEM
|
||||
logger
|
||||
rack (>= 2.2.3)
|
||||
rack-protection
|
||||
omniauth-digitalocean (0.3.2)
|
||||
multi_json (~> 1.15)
|
||||
omniauth (~> 2.0)
|
||||
omniauth-oauth2 (~> 1.0)
|
||||
omniauth-github (2.0.1)
|
||||
omniauth (~> 2.0)
|
||||
omniauth-oauth2 (~> 1.8)
|
||||
@@ -401,6 +414,22 @@ GEM
|
||||
omniauth-rails_csrf_protection (1.0.2)
|
||||
actionpack (>= 4.2)
|
||||
omniauth (~> 2.0)
|
||||
omniauth_openid_connect (0.8.0)
|
||||
omniauth (>= 1.9, < 3)
|
||||
openid_connect (~> 2.2)
|
||||
openid_connect (2.3.1)
|
||||
activemodel
|
||||
attr_required (>= 1.0.0)
|
||||
email_validator
|
||||
faraday (~> 2.0)
|
||||
faraday-follow_redirects
|
||||
json-jwt (>= 1.16)
|
||||
mail
|
||||
rack-oauth2 (~> 2.2)
|
||||
swd (~> 2.0)
|
||||
tzinfo
|
||||
validate_url
|
||||
webfinger (~> 2.0)
|
||||
orm_adapter (0.5.0)
|
||||
ostruct (0.6.2)
|
||||
pagy (9.4.0)
|
||||
@@ -435,6 +464,13 @@ GEM
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.1.18)
|
||||
rack-oauth2 (2.3.0)
|
||||
activesupport
|
||||
attr_required
|
||||
faraday (~> 2.0)
|
||||
faraday-follow_redirects
|
||||
json-jwt (>= 1.11.0)
|
||||
rack (>= 2.1.0)
|
||||
rack-protection (4.0.0)
|
||||
base64 (>= 0.1.0)
|
||||
rack (>= 3.0.0, < 4)
|
||||
@@ -594,6 +630,11 @@ GEM
|
||||
stimulus-rails (1.3.4)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.1.7)
|
||||
swd (2.0.3)
|
||||
activesupport (>= 3)
|
||||
attr_required (>= 0.0.5)
|
||||
faraday (~> 2.0)
|
||||
faraday-follow_redirects
|
||||
sys-proctable (1.3.0)
|
||||
ffi (~> 1.1)
|
||||
tailwindcss-rails (2.7.6)
|
||||
@@ -624,6 +665,9 @@ GEM
|
||||
unicode-emoji (4.0.4)
|
||||
uri (1.0.3)
|
||||
useragent (0.16.11)
|
||||
validate_url (1.0.15)
|
||||
activemodel (>= 3.0.0)
|
||||
public_suffix
|
||||
version_gem (1.1.4)
|
||||
view_component (4.1.0)
|
||||
activesupport (>= 7.1.0, < 8.2)
|
||||
@@ -635,6 +679,10 @@ GEM
|
||||
activemodel (>= 6.0.0)
|
||||
bindex (>= 0.4.0)
|
||||
railties (>= 6.0.0)
|
||||
webfinger (2.1.3)
|
||||
activesupport
|
||||
faraday (~> 2.0)
|
||||
faraday-follow_redirects
|
||||
webmock (3.26.1)
|
||||
addressable (>= 2.8.0)
|
||||
crack (>= 0.3.2)
|
||||
@@ -682,18 +730,20 @@ DEPENDENCIES
|
||||
importmap-rails
|
||||
jbuilder
|
||||
jsbundling-rails
|
||||
jwt (~> 2.9)
|
||||
k8s-ruby (~> 0.17.2)
|
||||
kubeclient (~> 4.12)
|
||||
light-service (~> 0.20.0)
|
||||
name_of_person!
|
||||
net-ldap (~> 0.20.0)
|
||||
noticed (~> 2.9)
|
||||
octokit (~> 10.0)
|
||||
oj (~> 3.16)
|
||||
omniauth (~> 2.1)
|
||||
omniauth-digitalocean (~> 0.3.2)
|
||||
omniauth-github (~> 2.0)
|
||||
omniauth-gitlab (~> 4.1)
|
||||
omniauth-rails_csrf_protection (~> 1.0)
|
||||
omniauth_openid_connect (~> 0.8)
|
||||
pagy (~> 9.4)
|
||||
pg (~> 1.6)
|
||||
pretender (~> 0.6.0)
|
||||
|
||||
25
app/actions/add_ons/apply_template_to_values.rb
Normal file
25
app/actions/add_ons/apply_template_to_values.rb
Normal file
@@ -0,0 +1,25 @@
|
||||
class AddOns::ApplyTemplateToValues
|
||||
extend LightService::Action
|
||||
expects :add_on
|
||||
|
||||
executed do |context|
|
||||
add_on = context.add_on
|
||||
add_on.values.extend(DotSettable)
|
||||
|
||||
variables = add_on.metadata['template'] || {}
|
||||
variables.keys.each do |key|
|
||||
variable = variables[key]
|
||||
|
||||
if variable.is_a?(Hash) && variable['type'] == 'size'
|
||||
add_on.values.dotset(key, "#{variable['value']}#{variable['unit']}")
|
||||
else
|
||||
variable_definition = add_on.chart_definition['template'].find { |t| t['key'] == key }
|
||||
if variable_definition['type'] == 'integer'
|
||||
add_on.values.dotset(key, variable.to_i)
|
||||
else
|
||||
add_on.values.dotset(key, variable)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,47 +1,40 @@
|
||||
class AddOns::Create
|
||||
extend LightService::Action
|
||||
expects :add_on
|
||||
promises :add_on
|
||||
def self.parse_params(params)
|
||||
if params[:add_on][:values_yaml].present?
|
||||
params[:add_on][:values] = YAML.safe_load(params[:add_on][:values_yaml])
|
||||
end
|
||||
if params[:add_on][:metadata].present?
|
||||
params[:add_on][:metadata] = params[:add_on][:metadata][params[:add_on][:chart_type]]
|
||||
end
|
||||
params.require(:add_on).permit(
|
||||
:cluster_id,
|
||||
:chart_type,
|
||||
:chart_url,
|
||||
:name,
|
||||
metadata: {},
|
||||
values: {}
|
||||
)
|
||||
end
|
||||
|
||||
executed do |context|
|
||||
add_on = context.add_on
|
||||
apply_template_to_values(add_on)
|
||||
fetch_package_details(context, add_on)
|
||||
unless add_on.save
|
||||
context.fail_and_return!("Failed to create add on")
|
||||
class ToNamespaced
|
||||
extend LightService::Action
|
||||
expects :add_on
|
||||
promises :namespaced
|
||||
executed do |context|
|
||||
context.namespaced = context.add_on
|
||||
end
|
||||
end
|
||||
|
||||
def self.fetch_package_details(context, add_on)
|
||||
result = AddOns::HelmChartDetails.execute(chart_url: add_on.chart_url)
|
||||
extend LightService::Organizer
|
||||
|
||||
if result.failure?
|
||||
add_on.errors.add(:base, "Failed to fetch package details")
|
||||
context.fail_and_return!("Failed to fetch package details")
|
||||
end
|
||||
|
||||
result.response.delete('readme')
|
||||
add_on.metadata['package_details'] = result.response
|
||||
end
|
||||
|
||||
def self.apply_template_to_values(add_on)
|
||||
# Merge the values from the form with the values.yaml object and create a new values.yaml file
|
||||
add_on.values.extend(DotSettable)
|
||||
|
||||
variables = add_on.metadata['template'] || {}
|
||||
variables.keys.each do |key|
|
||||
variable = variables[key]
|
||||
|
||||
if variable.is_a?(Hash) && variable['type'] == 'size'
|
||||
add_on.values.dotset(key, "#{variable['value']}#{variable['unit']}")
|
||||
else
|
||||
variable_definition = add_on.chart_definition['template'].find { |t| t['key'] == key }
|
||||
if variable_definition['type'] == 'integer'
|
||||
add_on.values.dotset(key, variable.to_i)
|
||||
else
|
||||
add_on.values.dotset(key, variable)
|
||||
end
|
||||
end
|
||||
end
|
||||
def self.call(add_on, user)
|
||||
with(add_on:, user:).reduce(
|
||||
AddOns::Create::ToNamespaced,
|
||||
Namespaced::SetUpNamespace,
|
||||
Namespaced::ValidateNamespace,
|
||||
AddOns::ApplyTemplateToValues,
|
||||
AddOns::SetPackageDetails,
|
||||
AddOns::Save
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
18
app/actions/add_ons/filter.rb
Normal file
18
app/actions/add_ons/filter.rb
Normal file
@@ -0,0 +1,18 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module AddOns
|
||||
class Filter
|
||||
extend LightService::Action
|
||||
|
||||
expects :params, :add_ons
|
||||
promises :add_ons
|
||||
|
||||
executed do |context|
|
||||
query = context.params[:q].to_s.strip
|
||||
|
||||
if query.present?
|
||||
context.add_ons = context.add_ons.where("add_ons.name ILIKE ?", "%#{query}%")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
14
app/actions/add_ons/list.rb
Normal file
14
app/actions/add_ons/list.rb
Normal file
@@ -0,0 +1,14 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module AddOns
|
||||
class List
|
||||
extend LightService::Organizer
|
||||
|
||||
def self.call(account_user:, params: {})
|
||||
with(account_user: account_user, params: params).reduce(
|
||||
AddOns::VisibleToUser,
|
||||
AddOns::Filter
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
10
app/actions/add_ons/save.rb
Normal file
10
app/actions/add_ons/save.rb
Normal file
@@ -0,0 +1,10 @@
|
||||
class AddOns::Save
|
||||
extend LightService::Action
|
||||
expects :add_on
|
||||
|
||||
executed do |context|
|
||||
unless context.add_on.save
|
||||
context.fail_and_return!("Failed to create add on")
|
||||
end
|
||||
end
|
||||
end
|
||||
18
app/actions/add_ons/set_package_details.rb
Normal file
18
app/actions/add_ons/set_package_details.rb
Normal file
@@ -0,0 +1,18 @@
|
||||
class AddOns::SetPackageDetails
|
||||
extend LightService::Action
|
||||
expects :add_on
|
||||
|
||||
executed do |context|
|
||||
add_on = context.add_on
|
||||
result = AddOns::HelmChartDetails.execute(chart_url: add_on.chart_url)
|
||||
|
||||
if result.failure?
|
||||
add_on.errors.add(:base, "Failed to fetch package details")
|
||||
context.fail_and_return!("Failed to fetch package details")
|
||||
end
|
||||
|
||||
# Readme is too large
|
||||
result.response.delete('readme')
|
||||
add_on.metadata['package_details'] = result.response
|
||||
end
|
||||
end
|
||||
@@ -12,7 +12,7 @@ class AddOns::UninstallHelmChart
|
||||
end
|
||||
|
||||
client = K8::Client.new(connection)
|
||||
if (namespace = client.get_namespaces.find { |n| n.metadata.name == add_on.name }).present?
|
||||
if add_on.managed_namespace? && (namespace = client.get_namespaces.find { |n| n.metadata.name == add_on.namespace }).present?
|
||||
client.delete_namespace(namespace.metadata.name)
|
||||
end
|
||||
|
||||
|
||||
54
app/actions/add_ons/visible_to_user.rb
Normal file
54
app/actions/add_ons/visible_to_user.rb
Normal file
@@ -0,0 +1,54 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module AddOns
|
||||
class VisibleToUser
|
||||
extend LightService::Action
|
||||
|
||||
expects :account_user
|
||||
promises :add_ons
|
||||
|
||||
executed do |context|
|
||||
account_user = context.account_user
|
||||
user = account_user.user
|
||||
account = account_user.account
|
||||
|
||||
# Admins can see all add_ons in the account
|
||||
if account_user.admin?
|
||||
context.add_ons = AddOn.joins(:cluster).where(clusters: { account_id: account.id })
|
||||
next context
|
||||
end
|
||||
|
||||
# If account has no teams, user can see all add_ons
|
||||
if account.teams.empty?
|
||||
context.add_ons = AddOn.joins(:cluster).where(clusters: { account_id: account.id })
|
||||
next context
|
||||
end
|
||||
|
||||
# Get user's teams in this account
|
||||
user_teams = user.teams.where(account: account)
|
||||
|
||||
# If user is not in any teams, they can't see any resources
|
||||
if user_teams.empty?
|
||||
context.add_ons = AddOn.none
|
||||
next context
|
||||
end
|
||||
|
||||
# Find add_ons accessible via:
|
||||
# 1. Direct add_on access via team_resources
|
||||
# 2. Cluster access via team_resources (includes all add_ons in that cluster)
|
||||
direct_add_on_ids = TeamResource.where(
|
||||
team: user_teams,
|
||||
resourceable_type: 'AddOn'
|
||||
).pluck(:resourceable_id)
|
||||
|
||||
cluster_ids_via_teams = TeamResource.where(
|
||||
team: user_teams,
|
||||
resourceable_type: 'Cluster'
|
||||
).pluck(:resourceable_id)
|
||||
|
||||
context.add_ons = AddOn.where(id: direct_add_on_ids)
|
||||
.or(AddOn.where(cluster_id: cluster_ids_via_teams))
|
||||
.distinct
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -4,6 +4,10 @@ class Clusters::CreateNamespace
|
||||
expects :kubectl
|
||||
|
||||
executed do |context|
|
||||
context.kubectl.apply_yaml(K8::Namespace.new(Struct.new(:name).new(Clusters::Install::DEFAULT_NAMESPACE)).to_yaml)
|
||||
context.kubectl.apply_yaml(
|
||||
K8::Namespace.new(
|
||||
Struct.new(:namespace).new(Clusters::Install::DEFAULT_NAMESPACE)
|
||||
).to_yaml
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
18
app/actions/clusters/filter.rb
Normal file
18
app/actions/clusters/filter.rb
Normal file
@@ -0,0 +1,18 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Clusters
|
||||
class Filter
|
||||
extend LightService::Action
|
||||
|
||||
expects :params, :clusters
|
||||
promises :clusters
|
||||
|
||||
executed do |context|
|
||||
query = context.params[:q].to_s.strip
|
||||
|
||||
if query.present?
|
||||
context.clusters = context.clusters.where("clusters.name ILIKE ?", "%#{query}%")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
14
app/actions/clusters/list.rb
Normal file
14
app/actions/clusters/list.rb
Normal file
@@ -0,0 +1,14 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Clusters
|
||||
class List
|
||||
extend LightService::Organizer
|
||||
|
||||
def self.call(account_user:, params: {})
|
||||
with(account_user: account_user, params: params).reduce(
|
||||
Clusters::VisibleToUser,
|
||||
Clusters::Filter
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
45
app/actions/clusters/visible_to_user.rb
Normal file
45
app/actions/clusters/visible_to_user.rb
Normal file
@@ -0,0 +1,45 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Clusters
|
||||
class VisibleToUser
|
||||
extend LightService::Action
|
||||
|
||||
expects :account_user
|
||||
promises :clusters
|
||||
|
||||
executed do |context|
|
||||
account_user = context.account_user
|
||||
user = account_user.user
|
||||
account = account_user.account
|
||||
|
||||
# Admins can see all clusters in the account
|
||||
if account_user.admin?
|
||||
context.clusters = Cluster.where(account_id: account.id)
|
||||
next context
|
||||
end
|
||||
|
||||
# If account has no teams, user can see all clusters
|
||||
if account.teams.empty?
|
||||
context.clusters = Cluster.where(account_id: account.id)
|
||||
next context
|
||||
end
|
||||
|
||||
# Get user's teams in this account
|
||||
user_teams = user.teams.where(account: account)
|
||||
|
||||
# If user is not in any teams, they can't see any resources
|
||||
if user_teams.empty?
|
||||
context.clusters = Cluster.none
|
||||
next context
|
||||
end
|
||||
|
||||
# Find clusters accessible via direct cluster access via team_resources
|
||||
cluster_ids = TeamResource.where(
|
||||
team: user_teams,
|
||||
resourceable_type: 'Cluster'
|
||||
).pluck(:resourceable_id)
|
||||
|
||||
context.clusters = Cluster.where(id: cluster_ids).distinct
|
||||
end
|
||||
end
|
||||
end
|
||||
16
app/actions/namespaced/set_up_namespace.rb
Normal file
16
app/actions/namespaced/set_up_namespace.rb
Normal file
@@ -0,0 +1,16 @@
|
||||
class Namespaced::SetUpNamespace
|
||||
extend LightService::Action
|
||||
expects :namespaced
|
||||
promises :namespaced
|
||||
|
||||
executed do |context|
|
||||
namespaced = context.namespaced
|
||||
if namespaced.namespace.blank? && namespaced.managed_namespace
|
||||
# autoset the namespace to the namespaced name
|
||||
namespaced.namespace = namespaced.name
|
||||
elsif namespaced.namespace.blank? && !namespaced.managed_namespace
|
||||
namespaced.errors.add(:base, "A namespace must be provided if it is not managed by Canine")
|
||||
context.fail_and_return!("Failed to set up namespace")
|
||||
end
|
||||
end
|
||||
end
|
||||
58
app/actions/namespaced/validate_namespace.rb
Normal file
58
app/actions/namespaced/validate_namespace.rb
Normal file
@@ -0,0 +1,58 @@
|
||||
module Namespaced
|
||||
class ValidateNamespace
|
||||
extend LightService::Action
|
||||
|
||||
expects :namespaced, :user
|
||||
|
||||
def self.validate_namespace_does_not_exist_or_is_managed(
|
||||
context,
|
||||
namespaced,
|
||||
client,
|
||||
existing_namespaces
|
||||
)
|
||||
namespace_exists = existing_namespaces.any? do |ns|
|
||||
ns.metadata.name == namespaced.namespace && ns.metadata&.labels&.caninemanaged != "true"
|
||||
end
|
||||
if namespace_exists
|
||||
error_message = "Namespace `#{namespaced.name}` already exists in the Kubernetes cluster. Please delete the existing namespace, or try a different name."
|
||||
namespaced.errors.add(:name, error_message)
|
||||
context.fail_and_return!(error_message)
|
||||
end
|
||||
end
|
||||
|
||||
def self.validate_namespace_exists(
|
||||
context,
|
||||
namespaced,
|
||||
client,
|
||||
existing_namespaces
|
||||
)
|
||||
existing_namespace = existing_namespaces.any? do |ns|
|
||||
ns.metadata.name == namespaced.namespace
|
||||
end
|
||||
unless existing_namespace
|
||||
error_message = "`#{namespaced.name}` does not exist in the cluster. If you want Canine to automaticaly create it, enable <b>auto create namespace</b>"
|
||||
namespaced.errors.add(:base, error_message)
|
||||
context.fail_and_return!(error_message)
|
||||
end
|
||||
end
|
||||
|
||||
executed do |context|
|
||||
namespaced = context.namespaced
|
||||
cluster = namespaced.cluster
|
||||
|
||||
begin
|
||||
client = K8::Client.new(K8::Connection.new(cluster, context.user))
|
||||
existing_namespaces = client.get_namespaces
|
||||
|
||||
if namespaced.managed_namespace
|
||||
validate_namespace_does_not_exist_or_is_managed(context, namespaced, client, existing_namespaces)
|
||||
else
|
||||
validate_namespace_exists(context, namespaced, client, existing_namespaces)
|
||||
end
|
||||
rescue StandardError => e
|
||||
# If we can't connect to check, we'll let it proceed and fail later if needed
|
||||
Rails.logger.warn("Could not check namespace availability: #{e.message}")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -3,15 +3,20 @@ class Networks::CheckDns
|
||||
expects :ingress, :connection
|
||||
|
||||
class << self
|
||||
def infer_expected_ip(ingress, connection)
|
||||
def infer_expected_dns(ingress, connection)
|
||||
ingress.connect(connection)
|
||||
ip = ingress.ip_address
|
||||
dns_record = ingress.hostname
|
||||
|
||||
if is_private_ip?(ip)
|
||||
if dns_record[:type] == :ip_address && is_private_ip?(dns_record[:value])
|
||||
cluster = ingress.service.project.cluster
|
||||
ip = infer_public_ip_from_cluster(connection)
|
||||
# This only works if it is a single node cluster like k3s
|
||||
public_ip = infer_public_ip_from_cluster(connection)
|
||||
dns_record = {
|
||||
value: public_ip,
|
||||
type: :ip_address
|
||||
}
|
||||
end
|
||||
ip
|
||||
dns_record
|
||||
end
|
||||
|
||||
def is_private_ip?(ip)
|
||||
@@ -40,18 +45,31 @@ class Networks::CheckDns
|
||||
|
||||
executed do |context|
|
||||
# TODO
|
||||
expected_ip = infer_expected_ip(context.ingress, context.connection)
|
||||
expected_dns = infer_expected_dns(context.ingress, context.connection)
|
||||
context.ingress.service.domains.each do |domain|
|
||||
ip_addresses = Resolv::DNS.open do |dns|
|
||||
dns.getresources(domain.domain_name, Resolv::DNS::Resource::IN::A).map do |resource|
|
||||
resource.address
|
||||
if expected_dns[:type] == :ip_address
|
||||
ip_addresses = Resolv::DNS.open do |dns|
|
||||
dns.getresources(domain.domain_name, Resolv::DNS::Resource::IN::A).map do |resource|
|
||||
resource.address
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if ip_addresses.any? && ip_addresses.first.to_s == expected_ip
|
||||
domain.update(status: :dns_verified)
|
||||
if ip_addresses.any? && ip_addresses.first.to_s == expected_dns[:value]
|
||||
domain.update(status: :dns_verified)
|
||||
else
|
||||
domain.update(status: :dns_incorrect, status_reason: "DNS record (#{ip_addresses.first || "empty"}) does not match expected IP address (#{expected_dns[:value]})")
|
||||
end
|
||||
else
|
||||
domain.update(status: :dns_incorrect, status_reason: "DNS record (#{ip_addresses.first}) does not match expected IP address (#{expected_ip})")
|
||||
hostnames = Resolv::DNS.open do |dns|
|
||||
dns.getresources(domain.domain_name, Resolv::DNS::Resource::IN::CNAME).map do |resource|
|
||||
resource.name
|
||||
end
|
||||
end
|
||||
if hostnames.any? && hostnames.first.to_s == expected_dns[:value]
|
||||
domain.update(status: :dns_verified)
|
||||
else
|
||||
domain.update(status: :dns_incorrect, status_reason: "DNS record (#{hostnames.first || "empty"}) does not match expected hostname (#{expected_dns[:value]})")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -10,6 +10,8 @@ class ProjectForks::ForkProject
|
||||
child_project = parent_project.dup
|
||||
child_project.branch = pull_request.branch
|
||||
child_project.name = "#{parent_project.name}-#{pull_request.number}"
|
||||
child_project.namespace = child_project.name
|
||||
child_project.managed_namespace = parent_project.managed_namespace
|
||||
child_project.cluster_id = parent_project.project_fork_cluster_id
|
||||
# Duplicate the project_credential_provider
|
||||
child_project_credential_provider = parent_project.project_credential_provider.dup
|
||||
|
||||
@@ -1,90 +1,101 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Projects
|
||||
class Create
|
||||
extend LightService::Organizer
|
||||
def self.create_params(params)
|
||||
params.require(:project).permit(
|
||||
:name,
|
||||
:repository_url,
|
||||
:branch,
|
||||
:cluster_id,
|
||||
:container_registry_url,
|
||||
:predeploy_command,
|
||||
:project_fork_status,
|
||||
:project_fork_cluster_id
|
||||
)
|
||||
end
|
||||
|
||||
def self.call(
|
||||
params,
|
||||
user
|
||||
)
|
||||
project = Project.new(create_params(params))
|
||||
provider = find_provider(user, params)
|
||||
project_credential_provider = build_project_credential_provider(project, provider)
|
||||
build_configuration = build_build_configuration(project, params)
|
||||
|
||||
steps = create_steps(provider)
|
||||
with(
|
||||
project:,
|
||||
project_credential_provider:,
|
||||
build_configuration:,
|
||||
params:,
|
||||
user:
|
||||
).reduce(*steps)
|
||||
end
|
||||
|
||||
def self.build_project_credential_provider(project, provider)
|
||||
ProjectCredentialProvider.new(
|
||||
project:,
|
||||
provider:,
|
||||
)
|
||||
end
|
||||
|
||||
def self.build_build_configuration(project, params)
|
||||
return unless project.git?
|
||||
build_config_params = params[:project][:build_configuration] || ActionController::Parameters.new
|
||||
default_params = build_default_build_configuration(project)
|
||||
merged_params = default_params.merge(BuildConfiguration.permit_params(build_config_params).compact_blank)
|
||||
build_configuration = project.build_build_configuration(merged_params)
|
||||
build_configuration
|
||||
end
|
||||
|
||||
def self.build_default_build_configuration(project)
|
||||
{
|
||||
provider: project.project_credential_provider.provider,
|
||||
driver: BuildConfiguration::DEFAULT_BUILDER,
|
||||
build_type: :dockerfile,
|
||||
image_repository: project.repository_url,
|
||||
context_directory: ".",
|
||||
dockerfile_path: "./Dockerfile"
|
||||
}
|
||||
end
|
||||
|
||||
def self.create_steps(provider)
|
||||
steps = []
|
||||
if provider.git?
|
||||
steps << Projects::ValidateGitRepository
|
||||
end
|
||||
|
||||
steps << Projects::ValidateNamespaceAvailability
|
||||
steps << Projects::InitializeBuildPacks
|
||||
steps << Projects::Save
|
||||
|
||||
# Only register webhook in cloud mode
|
||||
if Rails.application.config.cloud_mode && provider.git?
|
||||
steps << Projects::RegisterGitWebhook
|
||||
end
|
||||
|
||||
steps
|
||||
end
|
||||
|
||||
def self.find_provider(user, params)
|
||||
provider_id = params[:project][:project_credential_provider][:provider_id]
|
||||
user.providers.find(provider_id)
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
raise "Provider #{provider_id} not found"
|
||||
class Projects::Create
|
||||
class ToNamespaced
|
||||
extend LightService::Action
|
||||
expects :project
|
||||
promises :namespaced
|
||||
executed do |context|
|
||||
context.namespaced = context.project
|
||||
end
|
||||
end
|
||||
|
||||
extend LightService::Organizer
|
||||
def self.create_params(params)
|
||||
params.require(:project).permit(
|
||||
:name,
|
||||
:namespace,
|
||||
:managed_namespace,
|
||||
:repository_url,
|
||||
:branch,
|
||||
:cluster_id,
|
||||
:container_registry_url,
|
||||
:predeploy_command,
|
||||
:project_fork_status,
|
||||
:project_fork_cluster_id
|
||||
)
|
||||
end
|
||||
|
||||
def self.call(
|
||||
params,
|
||||
user
|
||||
)
|
||||
project = Project.new(create_params(params))
|
||||
provider = find_provider(user, params)
|
||||
project_credential_provider = build_project_credential_provider(project, provider)
|
||||
build_configuration = build_build_configuration(project, params)
|
||||
|
||||
steps = create_steps(provider)
|
||||
with(
|
||||
project:,
|
||||
project_credential_provider:,
|
||||
build_configuration:,
|
||||
params:,
|
||||
user:
|
||||
).reduce(*steps)
|
||||
end
|
||||
|
||||
def self.build_project_credential_provider(project, provider)
|
||||
ProjectCredentialProvider.new(
|
||||
project:,
|
||||
provider:,
|
||||
)
|
||||
end
|
||||
|
||||
def self.build_build_configuration(project, params)
|
||||
return unless project.git?
|
||||
build_config_params = params[:project][:build_configuration] || ActionController::Parameters.new
|
||||
default_params = build_default_build_configuration(project)
|
||||
merged_params = default_params.merge(BuildConfiguration.permit_params(build_config_params).compact_blank)
|
||||
build_configuration = project.build_build_configuration(merged_params)
|
||||
build_configuration
|
||||
end
|
||||
|
||||
def self.build_default_build_configuration(project)
|
||||
{
|
||||
provider: project.project_credential_provider.provider,
|
||||
driver: BuildConfiguration::DEFAULT_BUILDER,
|
||||
build_type: :dockerfile,
|
||||
image_repository: project.repository_url,
|
||||
context_directory: ".",
|
||||
dockerfile_path: "./Dockerfile"
|
||||
}
|
||||
end
|
||||
|
||||
def self.create_steps(provider)
|
||||
steps = []
|
||||
if provider.git?
|
||||
steps << Projects::ValidateGitRepository
|
||||
end
|
||||
|
||||
steps << Projects::Create::ToNamespaced
|
||||
steps << Namespaced::SetUpNamespace
|
||||
steps << Namespaced::ValidateNamespace
|
||||
steps << Projects::InitializeBuildPacks
|
||||
steps << Projects::Save
|
||||
|
||||
# Only register webhook in cloud mode
|
||||
if Rails.application.config.cloud_mode && provider.git?
|
||||
steps << Projects::RegisterGitWebhook
|
||||
end
|
||||
|
||||
steps
|
||||
end
|
||||
|
||||
def self.find_provider(user, params)
|
||||
provider_id = params[:project][:project_credential_provider][:provider_id]
|
||||
user.providers.find(provider_id)
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
raise "Provider #{provider_id} not found"
|
||||
end
|
||||
end
|
||||
|
||||
18
app/actions/projects/filter.rb
Normal file
18
app/actions/projects/filter.rb
Normal file
@@ -0,0 +1,18 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Projects
|
||||
class Filter
|
||||
extend LightService::Action
|
||||
|
||||
expects :params, :projects
|
||||
promises :projects
|
||||
|
||||
executed do |context|
|
||||
query = context.params[:q].to_s.strip
|
||||
|
||||
if query.present?
|
||||
context.projects = context.projects.where("projects.name ILIKE ?", "%#{query}%")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
14
app/actions/projects/list.rb
Normal file
14
app/actions/projects/list.rb
Normal file
@@ -0,0 +1,14 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Projects
|
||||
class List
|
||||
extend LightService::Organizer
|
||||
|
||||
def self.call(account_user:, params: {})
|
||||
with(account_user: account_user, params: params).reduce(
|
||||
Projects::VisibleToUser,
|
||||
Projects::Filter
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,31 +0,0 @@
|
||||
module Projects
|
||||
class ValidateNamespaceAvailability
|
||||
extend LightService::Action
|
||||
|
||||
expects :project, :user
|
||||
|
||||
executed do |context|
|
||||
project = context.project
|
||||
cluster = project.cluster
|
||||
|
||||
begin
|
||||
client = K8::Client.new(K8::Connection.new(cluster, context.user))
|
||||
existing_namespaces = client.get_namespaces
|
||||
|
||||
# Check if namespace already exists in Kubernetes
|
||||
namespace_exists = existing_namespaces.any? do |ns|
|
||||
ns.metadata.name == project.name && ns.metadata&.labels&.caninemanaged != "true"
|
||||
end
|
||||
|
||||
if namespace_exists
|
||||
error_message = "'#{project.name}' already exists in the Kubernetes cluster. Please delete the existing namespace, or try a different name."
|
||||
project.errors.add(:name, error_message)
|
||||
context.fail_and_return!(error_message)
|
||||
end
|
||||
rescue StandardError => e
|
||||
# If we can't connect to check, we'll let it proceed and fail later if needed
|
||||
Rails.logger.warn("Could not check namespace availability: #{e.message}")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
54
app/actions/projects/visible_to_user.rb
Normal file
54
app/actions/projects/visible_to_user.rb
Normal file
@@ -0,0 +1,54 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Projects
|
||||
class VisibleToUser
|
||||
extend LightService::Action
|
||||
|
||||
expects :account_user
|
||||
promises :projects
|
||||
|
||||
executed do |context|
|
||||
account_user = context.account_user
|
||||
user = account_user.user
|
||||
account = account_user.account
|
||||
|
||||
# Admins can see all projects in the account
|
||||
if account_user.admin?
|
||||
context.projects = Project.joins(:cluster).where(clusters: { account_id: account.id })
|
||||
next context
|
||||
end
|
||||
|
||||
# If account has no teams, user can see all projects
|
||||
if account.teams.empty?
|
||||
context.projects = Project.joins(:cluster).where(clusters: { account_id: account.id })
|
||||
next context
|
||||
end
|
||||
|
||||
# Get user's teams in this account
|
||||
user_teams = user.teams.where(account: account)
|
||||
|
||||
# If user is not in any teams, they can't see any resources
|
||||
if user_teams.empty?
|
||||
context.projects = Project.none
|
||||
next context
|
||||
end
|
||||
|
||||
# Find projects accessible via:
|
||||
# 1. Direct project access via team_resources
|
||||
# 2. Cluster access via team_resources (includes all projects in that cluster)
|
||||
direct_project_ids = TeamResource.where(
|
||||
team: user_teams,
|
||||
resourceable_type: 'Project'
|
||||
).pluck(:resourceable_id)
|
||||
|
||||
cluster_ids_via_teams = TeamResource.where(
|
||||
team: user_teams,
|
||||
resourceable_type: 'Cluster'
|
||||
).pluck(:resourceable_id)
|
||||
|
||||
context.projects = Project.where(id: direct_project_ids)
|
||||
.or(Project.where(cluster_id: cluster_ids_via_teams))
|
||||
.distinct
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -6,7 +6,10 @@ class Providers::CreateGithubProvider
|
||||
promises :provider
|
||||
|
||||
executed do |context|
|
||||
client = Octokit::Client.new(access_token: context.provider.access_token)
|
||||
client = Git::Github::Client.build_client(
|
||||
access_token: context.provider.access_token,
|
||||
api_base_url: context.provider.api_base_url
|
||||
)
|
||||
username = client.user[:login]
|
||||
context.provider.auth = {
|
||||
info: {
|
||||
@@ -14,16 +17,23 @@ class Providers::CreateGithubProvider
|
||||
}
|
||||
}.to_json
|
||||
|
||||
if (client.scopes & EXPECTED_SCOPES).sort != EXPECTED_SCOPES.sort
|
||||
message = "Invalid scopes. Please check that your personal access token has the following scopes: #{EXPECTED_SCOPES.join(", ")}"
|
||||
context.fail_and_return!(message)
|
||||
context.provider.errors.add(:access_token, message)
|
||||
next
|
||||
# Skip scope validation for enterprise (some GHE instances don't expose scopes)
|
||||
unless context.provider.enterprise?
|
||||
if (client.scopes & EXPECTED_SCOPES).sort != EXPECTED_SCOPES.sort
|
||||
message = "Invalid scopes. Please check that your personal access token has the following scopes: #{EXPECTED_SCOPES.join(", ")}"
|
||||
context.fail_and_return!(message)
|
||||
context.provider.errors.add(:access_token, message)
|
||||
next
|
||||
end
|
||||
end
|
||||
context.provider.save!
|
||||
rescue Octokit::Unauthorized
|
||||
message = "Invalid access token"
|
||||
context.provider.errors.add(:access_token, message)
|
||||
context.fail_and_return!(message)
|
||||
rescue Faraday::ConnectionFailed => e
|
||||
message = "Could not connect to GitHub server: #{e.message}"
|
||||
context.provider.errors.add(:registry_url, message)
|
||||
context.fail_and_return!(message)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
class Providers::CreateGitlabProvider
|
||||
EXPECTED_SCOPES = %w[ api read_repository read_registry write_registry ]
|
||||
GITLAB_PAT_API_URL = "https://gitlab.com/api/v4/personal_access_tokens/self"
|
||||
GITLAB_USER_API_URL = "https://gitlab.com/api/v4/user"
|
||||
extend LightService::Action
|
||||
|
||||
expects :provider
|
||||
promises :provider
|
||||
|
||||
executed do |context|
|
||||
response = HTTParty.get(GITLAB_PAT_API_URL,
|
||||
base_url = context.provider.api_base_url
|
||||
pat_api_url = "#{base_url}/api/v4/personal_access_tokens/self"
|
||||
user_api_url = "#{base_url}/api/v4/user"
|
||||
|
||||
response = HTTParty.get(pat_api_url,
|
||||
headers: {
|
||||
"Authorization" => "Bearer #{context.provider.access_token}"
|
||||
},
|
||||
@@ -20,15 +22,18 @@ class Providers::CreateGitlabProvider
|
||||
next
|
||||
end
|
||||
|
||||
if (response["scopes"] & EXPECTED_SCOPES).sort != EXPECTED_SCOPES.sort
|
||||
message = "Invalid scopes. Please check that your personal access token has the following scopes: #{EXPECTED_SCOPES.join(", ")}"
|
||||
context.provider.errors.add(:access_token, message)
|
||||
context.fail_and_return!(message)
|
||||
next
|
||||
# Skip scope validation for enterprise (some instances may have different scope requirements)
|
||||
unless context.provider.enterprise?
|
||||
if (response["scopes"] & EXPECTED_SCOPES).sort != EXPECTED_SCOPES.sort
|
||||
message = "Invalid scopes. Please check that your personal access token has the following scopes: #{EXPECTED_SCOPES.join(", ")}"
|
||||
context.provider.errors.add(:access_token, message)
|
||||
context.fail_and_return!(message)
|
||||
next
|
||||
end
|
||||
end
|
||||
# Get username data
|
||||
|
||||
response = HTTParty.get(GITLAB_USER_API_URL,
|
||||
response = HTTParty.get(user_api_url,
|
||||
headers: {
|
||||
"Authorization" => "Bearer #{context.provider.access_token}"
|
||||
},
|
||||
@@ -43,5 +48,9 @@ class Providers::CreateGitlabProvider
|
||||
context.provider.auth = body
|
||||
|
||||
context.provider.save!
|
||||
rescue Errno::ECONNREFUSED, SocketError => e
|
||||
message = "Could not connect to GitLab server: #{e.message}"
|
||||
context.provider.errors.add(:registry_url, message)
|
||||
context.fail_and_return!(message)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,8 +6,9 @@ class Services::Update
|
||||
|
||||
executed do |context|
|
||||
context.service.update(Service.permitted_params(context.params))
|
||||
if context.service.cron_job?
|
||||
context.service.cron_schedule.update(context.params[:service][:cron_schedule].permit(:schedule))
|
||||
if context.service.cron_job? && context.params[:service][:cron_schedule].present?
|
||||
context.service.cron_schedule.update(
|
||||
context.params[:service][:cron_schedule].permit(:schedule))
|
||||
end
|
||||
context.service.updated!
|
||||
end
|
||||
|
||||
11
app/actions/sso/create_teams_in_account.rb
Normal file
11
app/actions/sso/create_teams_in_account.rb
Normal file
@@ -0,0 +1,11 @@
|
||||
class SSO::CreateTeamsInAccount
|
||||
extend LightService::Action
|
||||
expects :account, :team_names
|
||||
promises :teams
|
||||
|
||||
executed do |context|
|
||||
context.teams = context.team_names.map do |team_hash|
|
||||
context.account.teams.find_or_create_by!(name: team_hash[:name])
|
||||
end
|
||||
end
|
||||
end
|
||||
50
app/actions/sso/create_user_in_account.rb
Normal file
50
app/actions/sso/create_user_in_account.rb
Normal file
@@ -0,0 +1,50 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module SSO
|
||||
class CreateUserInAccount
|
||||
extend LightService::Action
|
||||
expects :email, :account, :sso_provider, :uid
|
||||
expects :name, default: nil
|
||||
promises :user
|
||||
|
||||
executed do |context|
|
||||
# First try to find user by SSO provider identity
|
||||
provider = Provider.find_by(sso_provider: context.sso_provider, uid: context.uid)
|
||||
|
||||
if provider
|
||||
user = provider.user
|
||||
else
|
||||
# Fall back to finding by email, or create new user
|
||||
user = User.find_or_initialize_by(email: context.email.downcase)
|
||||
|
||||
if user.new_record?
|
||||
password = SecureRandom.hex(32)
|
||||
user.password = password
|
||||
user.password_confirmation = password
|
||||
|
||||
unless user.save
|
||||
context.fail_and_return!("Failed to create user", errors: user.errors)
|
||||
end
|
||||
end
|
||||
|
||||
# Create provider record to link user to SSO provider
|
||||
Provider.create!(
|
||||
user: user,
|
||||
sso_provider: context.sso_provider,
|
||||
uid: context.uid,
|
||||
provider: context.sso_provider.name
|
||||
)
|
||||
end
|
||||
|
||||
# Update name from SSO provider on every login
|
||||
if context.name.present?
|
||||
name_parts = context.name.split(" ", 2)
|
||||
user.update(first_name: name_parts.first, last_name: name_parts.second)
|
||||
end
|
||||
|
||||
AccountUser.find_or_create_by!(account: context.account, user: user)
|
||||
|
||||
context.user = user
|
||||
end
|
||||
end
|
||||
end
|
||||
25
app/actions/sso/sync_teams.rb
Normal file
25
app/actions/sso/sync_teams.rb
Normal file
@@ -0,0 +1,25 @@
|
||||
class SSO::SyncTeams
|
||||
extend LightService::Action
|
||||
expects :user, :teams, :account
|
||||
promises :team_memberships
|
||||
|
||||
executed do |context|
|
||||
user = context.user
|
||||
account = context.account
|
||||
remote_teams = context.teams
|
||||
|
||||
# Add user to teams from remote source
|
||||
context.team_memberships = remote_teams.map do |team|
|
||||
TeamMembership.find_or_create_by!(user:, team:)
|
||||
end
|
||||
|
||||
# Remove user from account teams they're no longer part of on the remote source
|
||||
remote_team_ids = remote_teams.map(&:id)
|
||||
stale_memberships = user.team_memberships
|
||||
.joins(:team)
|
||||
.where(teams: { account_id: account.id })
|
||||
.where.not(team_id: remote_team_ids)
|
||||
|
||||
stale_memberships.destroy_all
|
||||
end
|
||||
end
|
||||
11
app/actions/sso/sync_user_teams.rb
Normal file
11
app/actions/sso/sync_user_teams.rb
Normal file
@@ -0,0 +1,11 @@
|
||||
class SSO::SyncUserTeams
|
||||
extend LightService::Organizer
|
||||
def self.call(email:, team_names:, account:, sso_provider:, uid:, name: nil, create_teams: false)
|
||||
actions = []
|
||||
actions << SSO::CreateTeamsInAccount if create_teams
|
||||
actions << SSO::CreateUserInAccount
|
||||
actions << SSO::SyncTeams if create_teams
|
||||
|
||||
with(email:, team_names:, account:, sso_provider:, uid:, name:).reduce(actions)
|
||||
end
|
||||
end
|
||||
20
app/actions/sso_providers/build_sso_configuration.rb
Normal file
20
app/actions/sso_providers/build_sso_configuration.rb
Normal file
@@ -0,0 +1,20 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module SSOProviders
|
||||
class BuildSSOConfiguration
|
||||
extend LightService::Action
|
||||
expects :provider_type, :configuration_params
|
||||
promises :configuration
|
||||
|
||||
executed do |context|
|
||||
context.configuration = case context.provider_type
|
||||
when "ldap"
|
||||
LDAPConfiguration.new(context.configuration_params)
|
||||
when "oidc"
|
||||
OIDCConfiguration.new(context.configuration_params)
|
||||
else
|
||||
context.fail_and_return!("Unknown provider type: #{context.provider_type}")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
20
app/actions/sso_providers/create.rb
Normal file
20
app/actions/sso_providers/create.rb
Normal file
@@ -0,0 +1,20 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module SSOProviders
|
||||
class Create
|
||||
extend LightService::Organizer
|
||||
|
||||
def self.call(account:, sso_provider_params:, configuration_params:, provider_type:)
|
||||
sso_provider = account.build_sso_provider(sso_provider_params)
|
||||
|
||||
with(
|
||||
sso_provider: sso_provider,
|
||||
provider_type: provider_type,
|
||||
configuration_params: configuration_params
|
||||
).reduce(
|
||||
SSOProviders::BuildSSOConfiguration,
|
||||
SSOProviders::SaveConfiguration
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
20
app/actions/sso_providers/save_configuration.rb
Normal file
20
app/actions/sso_providers/save_configuration.rb
Normal file
@@ -0,0 +1,20 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module SSOProviders
|
||||
class SaveConfiguration
|
||||
extend LightService::Action
|
||||
expects :sso_provider, :configuration
|
||||
|
||||
executed do |context|
|
||||
context.sso_provider.configuration = context.configuration
|
||||
|
||||
unless context.configuration.save
|
||||
context.fail_and_return!("Failed to save configuration", errors: context.configuration.errors)
|
||||
end
|
||||
|
||||
unless context.sso_provider.save
|
||||
context.fail_and_return!("Failed to save SSO provider", errors: context.sso_provider.errors)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
18
app/actions/sso_providers/update.rb
Normal file
18
app/actions/sso_providers/update.rb
Normal file
@@ -0,0 +1,18 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module SSOProviders
|
||||
class Update
|
||||
extend LightService::Organizer
|
||||
|
||||
def self.call(sso_provider:, sso_provider_params:, configuration_params:)
|
||||
sso_provider.assign_attributes(sso_provider_params)
|
||||
|
||||
with(
|
||||
sso_provider: sso_provider,
|
||||
configuration_params: configuration_params
|
||||
).reduce(
|
||||
SSOProviders::UpdateConfiguration
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
20
app/actions/sso_providers/update_configuration.rb
Normal file
20
app/actions/sso_providers/update_configuration.rb
Normal file
@@ -0,0 +1,20 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module SSOProviders
|
||||
class UpdateConfiguration
|
||||
extend LightService::Action
|
||||
expects :sso_provider, :configuration_params
|
||||
|
||||
executed do |context|
|
||||
configuration = context.sso_provider.configuration
|
||||
|
||||
unless configuration.update(context.configuration_params)
|
||||
context.fail_and_return!("Failed to update configuration", errors: configuration.errors)
|
||||
end
|
||||
|
||||
unless context.sso_provider.save
|
||||
context.fail_and_return!("Failed to save SSO provider", errors: context.sso_provider.errors)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
24
app/avo/resources/ldap_configuration.rb
Normal file
24
app/avo/resources/ldap_configuration.rb
Normal file
@@ -0,0 +1,24 @@
|
||||
class Avo::Resources::LDAPConfiguration < Avo::BaseResource
|
||||
self.includes = []
|
||||
|
||||
def fields
|
||||
field :id, as: :id
|
||||
field :host, as: :text, required: true, help: "LDAP server hostname (e.g., ldap.example.com)"
|
||||
field :port, as: :number, required: true, help: "LDAP server port (default: 389 for plain/STARTTLS, 636 for LDAPS)"
|
||||
field :encryption, as: :select, required: true, help: "Encryption method", enum: {
|
||||
plain: "No encryption",
|
||||
simple_tls: "LDAPS (SSL/TLS)",
|
||||
start_tls: "STARTTLS"
|
||||
}, default: "plain"
|
||||
field :base_dn, as: :text, required: true, help: "Base DN for user searches (e.g., ou=users,dc=example,dc=com)"
|
||||
field :allow_anonymous_reads, as: :boolean, help: "Allow anonymous LDAP reads (if disabled, bind credentials are required for group lookups)"
|
||||
field :bind_dn, as: :text, help: "Bind DN for authentication (required if anonymous reads disabled)"
|
||||
field :bind_password, as: :password, help: "Bind password (required if anonymous reads disabled)"
|
||||
field :uid_attribute, as: :text, required: true, help: "Attribute for username (e.g., uid, sAMAccountName)", default: "uid"
|
||||
field :email_attribute, as: :text, help: "Attribute for email address", default: "mail"
|
||||
field :name_attribute, as: :text, help: "Attribute for full name", default: "cn"
|
||||
field :filter, as: :textarea, help: "Additional LDAP filter (optional)"
|
||||
|
||||
field :sso_provider, as: :has_one
|
||||
end
|
||||
end
|
||||
20
app/avo/resources/oidc_configuration.rb
Normal file
20
app/avo/resources/oidc_configuration.rb
Normal file
@@ -0,0 +1,20 @@
|
||||
class Avo::Resources::OIDCConfiguration < Avo::BaseResource
|
||||
self.includes = []
|
||||
|
||||
def fields
|
||||
field :id, as: :id
|
||||
field :issuer, as: :text, required: true, help: "OIDC provider issuer URL (e.g., https://auth.example.com)"
|
||||
field :client_id, as: :text, required: true, help: "OAuth 2.0 client ID"
|
||||
field :client_secret, as: :password, required: true, help: "OAuth 2.0 client secret"
|
||||
field :authorization_endpoint, as: :text, help: "Authorization endpoint URL (leave blank to use discovery)"
|
||||
field :token_endpoint, as: :text, help: "Token endpoint URL (leave blank to use discovery)"
|
||||
field :userinfo_endpoint, as: :text, help: "UserInfo endpoint URL (leave blank to use discovery)"
|
||||
field :jwks_uri, as: :text, help: "JWKS URI for token verification (leave blank to use discovery)"
|
||||
field :scopes, as: :text, help: "Space-separated scopes to request", default: "openid email profile"
|
||||
field :uid_claim, as: :text, required: true, help: "Claim to use as user identifier", default: "sub"
|
||||
field :email_claim, as: :text, help: "Claim for email address", default: "email"
|
||||
field :name_claim, as: :text, help: "Claim for full name", default: "name"
|
||||
|
||||
field :sso_provider, as: :has_one
|
||||
end
|
||||
end
|
||||
15
app/avo/resources/sso_provider.rb
Normal file
15
app/avo/resources/sso_provider.rb
Normal file
@@ -0,0 +1,15 @@
|
||||
class Avo::Resources::SSOProvider < 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 :account, as: :belongs_to
|
||||
field :configuration, as: :text
|
||||
field :name, as: :text
|
||||
field :enabled, as: :boolean
|
||||
end
|
||||
end
|
||||
13
app/avo/resources/team.rb
Normal file
13
app/avo/resources/team.rb
Normal file
@@ -0,0 +1,13 @@
|
||||
class Avo::Resources::Team < 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 :name, as: :text
|
||||
field :account, as: :belongs_to
|
||||
end
|
||||
end
|
||||
13
app/avo/resources/team_membership.rb
Normal file
13
app/avo/resources/team_membership.rb
Normal file
@@ -0,0 +1,13 @@
|
||||
class Avo::Resources::TeamMembership < 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 :user, as: :belongs_to
|
||||
field :team, as: :belongs_to
|
||||
end
|
||||
end
|
||||
13
app/avo/resources/team_resource.rb
Normal file
13
app/avo/resources/team_resource.rb
Normal file
@@ -0,0 +1,13 @@
|
||||
class Avo::Resources::TeamResource < 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 :team, as: :belongs_to
|
||||
field :resourceable, as: :text
|
||||
end
|
||||
end
|
||||
110
app/controllers/accounts/oidc_controller.rb
Normal file
110
app/controllers/accounts/oidc_controller.rb
Normal file
@@ -0,0 +1,110 @@
|
||||
module Accounts
|
||||
class OIDCController < ApplicationController
|
||||
skip_before_action :authenticate_user!
|
||||
before_action :load_account
|
||||
|
||||
def authorize
|
||||
oidc_configuration = @account.sso_provider&.configuration
|
||||
unless oidc_configuration.is_a?(OIDCConfiguration)
|
||||
redirect_to account_sign_in_path(@account.slug), alert: "OIDC is not configured for this account"
|
||||
return
|
||||
end
|
||||
|
||||
# Store state in session for CSRF protection
|
||||
state = SecureRandom.hex(32)
|
||||
session[:oidc_state] = state
|
||||
|
||||
# Build authorization URL
|
||||
authorization_url = build_authorization_url(oidc_configuration, state)
|
||||
redirect_to authorization_url, allow_other_host: true
|
||||
end
|
||||
|
||||
def callback
|
||||
# Verify state for CSRF protection
|
||||
unless params[:state].present? && params[:state] == session[:oidc_state]
|
||||
redirect_to root_path, alert: "Invalid state parameter"
|
||||
return
|
||||
end
|
||||
|
||||
oidc_configuration = @account.sso_provider&.configuration
|
||||
|
||||
unless oidc_configuration.is_a?(OIDCConfiguration)
|
||||
redirect_to root_path, alert: "OIDC is not configured"
|
||||
return
|
||||
end
|
||||
|
||||
if params[:error].present?
|
||||
redirect_to account_sign_in_path(@account.slug), alert: "Authentication failed: #{params[:error_description] || params[:error]}"
|
||||
return
|
||||
end
|
||||
|
||||
# Exchange code for tokens
|
||||
result = OIDC::Authenticator.new(oidc_configuration).authenticate(
|
||||
code: params[:code],
|
||||
redirect_uri: oidc_callback_url(slug: @account.slug)
|
||||
)
|
||||
|
||||
unless result.success?
|
||||
redirect_to account_sign_in_path(@account.slug), alert: result.error_message
|
||||
return
|
||||
end
|
||||
|
||||
# Create or find user
|
||||
sso_provider = @account.sso_provider
|
||||
ar_result = SSO::SyncUserTeams.call(
|
||||
email: result.email,
|
||||
team_names: result.groups || [],
|
||||
account: @account,
|
||||
sso_provider: sso_provider,
|
||||
uid: result.uid,
|
||||
name: result.name,
|
||||
create_teams: sso_provider.just_in_time_team_provisioning_mode?
|
||||
)
|
||||
|
||||
if ar_result.failure?
|
||||
redirect_to account_sign_in_path(@account.slug), alert: "Failed to create user account"
|
||||
return
|
||||
end
|
||||
|
||||
# Clear session state
|
||||
session.delete(:oidc_state)
|
||||
|
||||
# Sign in user
|
||||
sign_in(ar_result.user)
|
||||
session[:account_id] = @account.id
|
||||
redirect_to after_sign_in_path_for(ar_result.user), notice: "Signed in successfully"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_account
|
||||
@account = Account.friendly.find(params[:slug])
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
redirect_to root_path, alert: "Account not found"
|
||||
end
|
||||
|
||||
def build_authorization_url(oidc_configuration, state)
|
||||
params = {
|
||||
response_type: "code",
|
||||
client_id: oidc_configuration.client_id,
|
||||
redirect_uri: oidc_callback_url(slug: @account.slug),
|
||||
scope: oidc_configuration.scopes,
|
||||
state: state
|
||||
}
|
||||
|
||||
auth_endpoint = oidc_configuration.authorization_endpoint.presence || discover_authorization_endpoint(oidc_configuration)
|
||||
"#{auth_endpoint}?#{params.to_query}"
|
||||
end
|
||||
|
||||
def discover_authorization_endpoint(oidc_configuration)
|
||||
# Fetch from OIDC discovery document
|
||||
discovery_url = oidc_configuration.discovery_url
|
||||
response = HTTP.get(discovery_url)
|
||||
if response.status.success?
|
||||
JSON.parse(response.body.to_s)["authorization_endpoint"]
|
||||
else
|
||||
raise "Failed to discover OIDC endpoints"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
140
app/controllers/accounts/sso_providers_controller.rb
Normal file
140
app/controllers/accounts/sso_providers_controller.rb
Normal file
@@ -0,0 +1,140 @@
|
||||
module Accounts
|
||||
class SSOProvidersController < ApplicationController
|
||||
def show
|
||||
@sso_provider = current_account.sso_provider
|
||||
@configuration = @sso_provider&.configuration
|
||||
end
|
||||
|
||||
def new
|
||||
@provider_type = params[:provider_type] || "ldap"
|
||||
@sso_provider = current_account.build_sso_provider
|
||||
@ldap_configuration = LDAPConfiguration.new
|
||||
@oidc_configuration = OIDCConfiguration.new
|
||||
end
|
||||
|
||||
def create
|
||||
provider_type = params[:provider_type] || "ldap"
|
||||
|
||||
result = SSOProviders::Create.call(
|
||||
account: current_account,
|
||||
sso_provider_params: sso_provider_params,
|
||||
configuration_params: configuration_params_for(provider_type),
|
||||
provider_type: provider_type
|
||||
)
|
||||
|
||||
if result.success?
|
||||
redirect_to sso_provider_path, notice: "SSO provider created successfully"
|
||||
else
|
||||
@provider_type = provider_type
|
||||
@sso_provider = result.sso_provider
|
||||
@ldap_configuration = provider_type == "ldap" ? result.configuration : LDAPConfiguration.new
|
||||
@oidc_configuration = provider_type == "oidc" ? result.configuration : OIDCConfiguration.new
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
@sso_provider = current_account.sso_provider
|
||||
redirect_to new_sso_provider_path, alert: "No SSO provider configured" unless @sso_provider
|
||||
@provider_type = @sso_provider&.oidc? ? "oidc" : "ldap"
|
||||
@ldap_configuration = @sso_provider&.ldap? ? @sso_provider.configuration : LDAPConfiguration.new
|
||||
@oidc_configuration = @sso_provider&.oidc? ? @sso_provider.configuration : OIDCConfiguration.new
|
||||
end
|
||||
|
||||
def update
|
||||
@sso_provider = current_account.sso_provider
|
||||
provider_type = @sso_provider.oidc? ? "oidc" : "ldap"
|
||||
|
||||
result = SSOProviders::Update.call(
|
||||
sso_provider: @sso_provider,
|
||||
sso_provider_params: sso_provider_params,
|
||||
configuration_params: configuration_params_for(provider_type)
|
||||
)
|
||||
|
||||
if result.success?
|
||||
redirect_to sso_provider_path, notice: "SSO provider updated successfully"
|
||||
else
|
||||
@provider_type = provider_type
|
||||
@ldap_configuration = @sso_provider.ldap? ? @sso_provider.configuration : LDAPConfiguration.new
|
||||
@oidc_configuration = @sso_provider.oidc? ? @sso_provider.configuration : OIDCConfiguration.new
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@sso_provider = current_account.sso_provider
|
||||
|
||||
if @sso_provider&.destroy
|
||||
redirect_to sso_provider_path, notice: "SSO provider deleted"
|
||||
else
|
||||
redirect_to sso_provider_path, alert: "Failed to delete SSO provider"
|
||||
end
|
||||
end
|
||||
|
||||
def test_connection
|
||||
ldap_configuration = LDAPConfiguration.new(ldap_configuration_params)
|
||||
result = LDAP::Authenticator.new(ldap_configuration).test_connection
|
||||
|
||||
if result.success?
|
||||
render turbo_stream: turbo_stream.replace(
|
||||
"ldap_test_connection_result",
|
||||
partial: "accounts/sso_providers/ldap/connection_success"
|
||||
)
|
||||
else
|
||||
render turbo_stream: turbo_stream.replace(
|
||||
"ldap_test_connection_result",
|
||||
partial: "accounts/sso_providers/ldap/connection_failed",
|
||||
locals: { error_message: result.error_message }
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def sso_provider_params
|
||||
params.require(:sso_provider).permit(:name, :enabled, :team_provisioning_mode)
|
||||
end
|
||||
|
||||
def configuration_params_for(provider_type)
|
||||
case provider_type
|
||||
when "ldap"
|
||||
ldap_configuration_params
|
||||
when "oidc"
|
||||
oidc_configuration_params
|
||||
else
|
||||
{}
|
||||
end
|
||||
end
|
||||
|
||||
def ldap_configuration_params
|
||||
params.require(:ldap_configuration).permit(
|
||||
:host,
|
||||
:port,
|
||||
:base_dn,
|
||||
:bind_dn,
|
||||
:bind_password,
|
||||
:uid_attribute,
|
||||
:email_attribute,
|
||||
:name_attribute,
|
||||
:filter,
|
||||
:encryption
|
||||
)
|
||||
end
|
||||
|
||||
def oidc_configuration_params
|
||||
params.require(:oidc_configuration).permit(
|
||||
:issuer,
|
||||
:client_id,
|
||||
:client_secret,
|
||||
:authorization_endpoint,
|
||||
:token_endpoint,
|
||||
:userinfo_endpoint,
|
||||
:jwks_uri,
|
||||
:scopes,
|
||||
:uid_claim,
|
||||
:email_claim,
|
||||
:name_claim
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,34 +1,11 @@
|
||||
module Accounts
|
||||
class StackManagersController < ApplicationController
|
||||
before_action :authenticate_user!
|
||||
before_action :authorize_account_admin, only: [ :show, :new, :create, :edit, :update, :destroy, :sync_clusters, :sync_registries ]
|
||||
before_action :set_stack_manager, only: [ :show, :edit, :update, :destroy, :sync_clusters, :sync_registries ]
|
||||
before_action :set_stack, only: [ :sync_clusters, :sync_registries ]
|
||||
skip_before_action :authenticate_user!, only: [ :verify_url, :check_reachable ]
|
||||
|
||||
def _verify_stack(stack)
|
||||
if stack.authenticated?
|
||||
head :ok
|
||||
else
|
||||
head :unauthorized
|
||||
end
|
||||
end
|
||||
|
||||
def verify_login
|
||||
stack_manager = current_account.stack_manager
|
||||
if stack_manager.nil?
|
||||
head :not_found
|
||||
end
|
||||
|
||||
# If the user is not having an email domain end in the
|
||||
# portainer stack url, don't log them out, just return a different unauthorized.
|
||||
if !stack_manager.is_user?(current_user)
|
||||
head :method_not_allowed
|
||||
return
|
||||
end
|
||||
|
||||
stack = stack_manager.stack.connect(current_user, allow_anonymous: false)
|
||||
_verify_stack(stack)
|
||||
end
|
||||
|
||||
def check_reachable
|
||||
url = params[:stack_manager][:url]
|
||||
unless Portainer::Client.reachable?(url)
|
||||
@@ -38,6 +15,30 @@ module Accounts
|
||||
head :ok
|
||||
end
|
||||
|
||||
def verify_connectivity
|
||||
stack_manager = current_account.stack_manager
|
||||
if stack_manager.nil?
|
||||
head :not_found
|
||||
return
|
||||
end
|
||||
|
||||
if current_user.portainer_access_token.blank?
|
||||
head :unauthorized
|
||||
return
|
||||
end
|
||||
|
||||
stack = stack_manager.stack.connect(current_user, allow_anonymous: false)
|
||||
if stack.authenticated?
|
||||
head :ok
|
||||
else
|
||||
head :unauthorized
|
||||
end
|
||||
rescue Portainer::Client::MissingCredentialError, Portainer::Client::UnauthorizedError
|
||||
head :unauthorized
|
||||
rescue Portainer::Client::ConnectionError
|
||||
head :bad_gateway
|
||||
end
|
||||
|
||||
def verify_url
|
||||
url = params[:stack_manager][:url]
|
||||
access_token = params[:stack_manager][:access_token]
|
||||
@@ -59,7 +60,6 @@ module Accounts
|
||||
end
|
||||
|
||||
def show
|
||||
@stack_manager = current_account.stack_manager
|
||||
end
|
||||
|
||||
def new
|
||||
@@ -77,13 +77,10 @@ module Accounts
|
||||
end
|
||||
|
||||
def edit
|
||||
@stack_manager = current_account.stack_manager
|
||||
redirect_to new_stack_manager_path unless @stack_manager
|
||||
end
|
||||
|
||||
def update
|
||||
@stack_manager = current_account.stack_manager
|
||||
|
||||
if @stack_manager.update(stack_manager_params)
|
||||
redirect_to stack_manager_path, notice: "Stack manager was successfully updated."
|
||||
else
|
||||
@@ -92,7 +89,6 @@ module Accounts
|
||||
end
|
||||
|
||||
def destroy
|
||||
@stack_manager = current_account.stack_manager
|
||||
@stack_manager.destroy!
|
||||
redirect_to stack_manager_path, notice: "Stack manager was successfully removed."
|
||||
end
|
||||
@@ -135,8 +131,24 @@ module Accounts
|
||||
)
|
||||
end
|
||||
|
||||
def set_stack_manager
|
||||
@stack_manager = current_account.stack_manager
|
||||
end
|
||||
|
||||
def authorize_account_admin
|
||||
authorize current_account, :manage_stack_manager?
|
||||
end
|
||||
|
||||
def set_stack
|
||||
@stack ||= current_account.stack_manager&.stack&.connect(current_user)
|
||||
@stack ||= @stack_manager&.stack&.connect(current_user)
|
||||
end
|
||||
|
||||
def _verify_stack(stack)
|
||||
if stack.authenticated?
|
||||
head :ok
|
||||
else
|
||||
head :unauthorized
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
class Accounts::Teams::TeamMembersSearchController < ApplicationController
|
||||
before_action :set_team
|
||||
|
||||
def index
|
||||
query = params[:q].to_s.strip
|
||||
|
||||
# Get users in the account who are NOT already in this team
|
||||
users = if query.present?
|
||||
current_account.users
|
||||
.where.not(id: @team.users.pluck(:id))
|
||||
.where("email ILIKE ? OR first_name ILIKE ? OR last_name ILIKE ?",
|
||||
"%#{query}%", "%#{query}%", "%#{query}%")
|
||||
.limit(10)
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
||||
render json: users.map { |user|
|
||||
{
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
first_name: user.first_name,
|
||||
last_name: user.last_name,
|
||||
avatar_url: helpers.avatar_path(user, size: 64)
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_team
|
||||
@team = current_account.teams.friendly.find(params[:team_id])
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,31 @@
|
||||
class Accounts::Teams::TeamMembershipsController < ApplicationController
|
||||
before_action :set_team
|
||||
|
||||
def create
|
||||
user = User.find(team_membership_params[:user_id])
|
||||
@team_membership = @team.team_memberships.new(user: user)
|
||||
|
||||
if @team_membership.save
|
||||
redirect_to team_path(@team), notice: "Member was successfully added to team."
|
||||
else
|
||||
redirect_to team_path(@team), alert: "Failed to add member to team."
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@team_membership = @team.team_memberships.find(params[:id])
|
||||
@team_membership.destroy
|
||||
|
||||
redirect_to team_path(@team), notice: "Member was successfully removed from team."
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_team
|
||||
@team = current_account.teams.friendly.find(params[:team_id])
|
||||
end
|
||||
|
||||
def team_membership_params
|
||||
params.permit(:user_id)
|
||||
end
|
||||
end
|
||||
44
app/controllers/accounts/teams/team_resources_controller.rb
Normal file
44
app/controllers/accounts/teams/team_resources_controller.rb
Normal file
@@ -0,0 +1,44 @@
|
||||
class Accounts::Teams::TeamResourcesController < ApplicationController
|
||||
before_action :set_team
|
||||
|
||||
def create
|
||||
resourceable = find_resourceable(team_resource_params[:resourceable_type], team_resource_params[:resourceable_id])
|
||||
@team_resource = @team.team_resources.new(resourceable: resourceable)
|
||||
|
||||
if @team_resource.save
|
||||
redirect_to team_path(@team), notice: "Resource access was successfully granted."
|
||||
else
|
||||
redirect_to team_path(@team), alert: "Failed to grant resource access."
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@team_resource = @team.team_resources.find(params[:id])
|
||||
@team_resource.destroy
|
||||
|
||||
redirect_to team_path(@team), notice: "Resource access was successfully revoked."
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_team
|
||||
@team = current_account.teams.friendly.find(params[:team_id])
|
||||
end
|
||||
|
||||
def team_resource_params
|
||||
params.permit(:resourceable_type, :resourceable_id)
|
||||
end
|
||||
|
||||
def find_resourceable(type, id)
|
||||
case type
|
||||
when "Cluster"
|
||||
current_account.clusters.find(id)
|
||||
when "Project"
|
||||
current_account.projects.find(id)
|
||||
when "AddOn"
|
||||
current_account.add_ons.find(id)
|
||||
else
|
||||
raise "Invalid resourceable type"
|
||||
end
|
||||
end
|
||||
end
|
||||
54
app/controllers/accounts/teams_controller.rb
Normal file
54
app/controllers/accounts/teams_controller.rb
Normal file
@@ -0,0 +1,54 @@
|
||||
class Accounts::TeamsController < ApplicationController
|
||||
include SettingsHelper
|
||||
before_action :set_team, only: %i[show edit update destroy]
|
||||
|
||||
def index
|
||||
@pagy, @teams = pagy(current_account.teams)
|
||||
end
|
||||
|
||||
def show
|
||||
@pagy, @team_memberships = pagy(@team.team_memberships)
|
||||
@tab = params[:tab] || "clusters"
|
||||
end
|
||||
|
||||
def new
|
||||
@team = current_account.teams.new
|
||||
end
|
||||
|
||||
def create
|
||||
@team = current_account.teams.new(team_params)
|
||||
|
||||
if @team.save
|
||||
redirect_to teams_path, notice: "Team was successfully created."
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
if @team.update(team_params)
|
||||
redirect_to team_path(@team), notice: "Team was successfully updated."
|
||||
else
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@team.destroy
|
||||
|
||||
redirect_to teams_path, notice: "Team was successfully destroyed."
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_team
|
||||
@team = current_account.teams.friendly.find(params[:id])
|
||||
end
|
||||
|
||||
def team_params
|
||||
params.require(:team).permit(:name)
|
||||
end
|
||||
end
|
||||
@@ -4,7 +4,13 @@ class AddOnsController < ApplicationController
|
||||
|
||||
# GET /add_ons
|
||||
def index
|
||||
@pagy, @add_ons = pagy(current_account.add_ons)
|
||||
add_ons = AddOns::List.call(account_user: current_account_user, params: params).add_ons
|
||||
@pagy, @add_ons = pagy(add_ons)
|
||||
|
||||
respond_to do |format|
|
||||
format.html
|
||||
format.json { render json: @add_ons.map { |a| { id: a.id, name: a.name } } }
|
||||
end
|
||||
|
||||
# Uncomment to authorize with Pundit
|
||||
# authorize @add_ons
|
||||
@@ -39,7 +45,8 @@ class AddOnsController < ApplicationController
|
||||
|
||||
# POST /add_ons or /add_ons.json
|
||||
def create
|
||||
result = AddOns::Create.execute(add_on: AddOn.new(add_on_params))
|
||||
add_on_params = AddOns::Create.parse_params(params)
|
||||
result = AddOns::Create.call(AddOn.new(add_on_params), current_user)
|
||||
@add_on = result.add_on
|
||||
# Uncomment to authorize with Pundit
|
||||
# authorize @add_on
|
||||
@@ -58,7 +65,7 @@ class AddOnsController < ApplicationController
|
||||
|
||||
# PATCH/PUT /add_ons/1 or /add_ons/1.json
|
||||
def update
|
||||
@add_on.assign_attributes(add_on_params)
|
||||
@add_on.assign_attributes(AddOns::Create.parse_params(params))
|
||||
result = AddOns::Update.execute(add_on: @add_on)
|
||||
|
||||
respond_to do |format|
|
||||
@@ -118,30 +125,10 @@ class AddOnsController < ApplicationController
|
||||
|
||||
# Use callbacks to share common setup or constraints between actions.
|
||||
def set_add_on
|
||||
@add_on = current_account.add_ons.find(params[:id])
|
||||
add_ons = AddOns::VisibleToUser.execute(account_user: current_account_user).add_ons
|
||||
@add_on = add_ons.find(params[:id])
|
||||
@service = K8::Helm::Service.create_from_add_on(K8::Connection.new(@add_on, current_user))
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
redirect_to add_ons_path
|
||||
end
|
||||
|
||||
# Only allow a list of trusted parameters through.
|
||||
def add_on_params
|
||||
if params[:add_on][:values_yaml].present?
|
||||
params[:add_on][:values] = YAML.safe_load(params[:add_on][:values_yaml])
|
||||
end
|
||||
if params[:add_on][:metadata].present?
|
||||
params[:add_on][:metadata] = params[:add_on][:metadata][params[:add_on][:chart_type]]
|
||||
end
|
||||
params.require(:add_on).permit(
|
||||
:cluster_id,
|
||||
:chart_type,
|
||||
:chart_url,
|
||||
:name,
|
||||
metadata: {},
|
||||
values: {}
|
||||
)
|
||||
|
||||
# Uncomment to use Pundit permitted attributes
|
||||
# params.require(:add_on).permit(policy(@add_on).permitted_attributes)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -13,6 +13,7 @@ class ApplicationController < ActionController::Base
|
||||
layout :determine_layout
|
||||
|
||||
rescue_from ActiveRecord::RecordNotFound, with: :record_not_found
|
||||
rescue_from Portainer::Client::MissingCredentialError, with: :missing_portainer_credential
|
||||
|
||||
def authenticate_user!(opts = {})
|
||||
if request.headers["X-API-Key"].present?
|
||||
@@ -27,6 +28,8 @@ class ApplicationController < ActionController::Base
|
||||
api_token = ApiToken.find_by(access_token: token)
|
||||
if api_token.present?
|
||||
@current_user = api_token.user
|
||||
else
|
||||
render json: { error: "Invalid API token" }, status: :unauthorized
|
||||
end
|
||||
end
|
||||
|
||||
@@ -39,6 +42,12 @@ class ApplicationController < ActionController::Base
|
||||
end
|
||||
helper_method :current_account
|
||||
|
||||
def current_account_user
|
||||
return nil unless user_signed_in? && current_account
|
||||
@current_account_user ||= AccountUser.find_by(user: current_user, account: current_account)
|
||||
end
|
||||
helper_method :current_account_user
|
||||
|
||||
def time_ago(t)
|
||||
if t.present?
|
||||
"#{time_ago_in_words(t)} ago"
|
||||
@@ -62,4 +71,8 @@ class ApplicationController < ActionController::Base
|
||||
flash[:alert] = "The requested resource could not be found."
|
||||
redirect_to root_path
|
||||
end
|
||||
|
||||
def missing_portainer_credential
|
||||
redirect_to providers_path, alert: "Please add your Portainer API token to continue."
|
||||
end
|
||||
end
|
||||
|
||||
4
app/controllers/avo/ldap_configurations_controller.rb
Normal file
4
app/controllers/avo/ldap_configurations_controller.rb
Normal 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::LDAPConfigurationsController < Avo::ResourcesController
|
||||
end
|
||||
4
app/controllers/avo/oidc_configurations_controller.rb
Normal file
4
app/controllers/avo/oidc_configurations_controller.rb
Normal 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::OIDCConfigurationsController < Avo::ResourcesController
|
||||
end
|
||||
4
app/controllers/avo/sso_providers_controller.rb
Normal file
4
app/controllers/avo/sso_providers_controller.rb
Normal 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::SSOProvidersController < Avo::ResourcesController
|
||||
end
|
||||
4
app/controllers/avo/team_memberships_controller.rb
Normal file
4
app/controllers/avo/team_memberships_controller.rb
Normal 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::TeamMembershipsController < Avo::ResourcesController
|
||||
end
|
||||
4
app/controllers/avo/team_resources_controller.rb
Normal file
4
app/controllers/avo/team_resources_controller.rb
Normal 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::TeamResourcesController < Avo::ResourcesController
|
||||
end
|
||||
4
app/controllers/avo/teams_controller.rb
Normal file
4
app/controllers/avo/teams_controller.rb
Normal 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::TeamsController < Avo::ResourcesController
|
||||
end
|
||||
@@ -8,7 +8,13 @@ class ClustersController < ApplicationController
|
||||
# GET /clusters
|
||||
def index
|
||||
sortable_column = params[:sort] || "created_at"
|
||||
@pagy, @clusters = pagy(current_account.clusters.order(sortable_column => "asc"))
|
||||
clusters = Clusters::List.call(account_user: current_account_user, params: params).clusters
|
||||
@pagy, @clusters = pagy(clusters.order(sortable_column => "asc"))
|
||||
|
||||
respond_to do |format|
|
||||
format.html
|
||||
format.json { render json: @clusters.map { |c| { id: c.id, name: c.name } } }
|
||||
end
|
||||
|
||||
# Uncomment to authorize with Pundit
|
||||
# authorize @clusters
|
||||
@@ -89,8 +95,8 @@ class ClustersController < ApplicationController
|
||||
%w[services deployments ingress cronjobs].each do |resource|
|
||||
yaml_content = K8::Kubectl.new(
|
||||
K8::Connection.new(@cluster, current_user)
|
||||
).call("get #{resource} -n #{project.name} -o yaml")
|
||||
export(@cluster.name, project.name, yaml_content, zio)
|
||||
).call("get #{resource} -n #{project.namespace} -o yaml")
|
||||
export(@cluster.name, project.namespace, yaml_content, zio)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -171,7 +177,8 @@ class ClustersController < ApplicationController
|
||||
|
||||
# Use callbacks to share common setup or constraints between actions.
|
||||
def set_cluster
|
||||
@cluster = current_account.clusters.find(params[:id])
|
||||
clusters = Clusters::VisibleToUser.execute(account_user: current_account_user).clusters
|
||||
@cluster = clusters.find(params[:id])
|
||||
|
||||
# Uncomment to authorize with Pundit
|
||||
# authorize @cluster
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
class DocsController < ApplicationController
|
||||
skip_before_action :authenticate_user!, only: [ :swagger, :docs ]
|
||||
|
||||
layout false
|
||||
|
||||
def swagger
|
||||
render plain: File.read(Rails.root.join('swagger', 'v1', 'swagger.yaml'))
|
||||
end
|
||||
|
||||
def index
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,10 @@
|
||||
class Integrations::Github::RepositoriesController < ApplicationController
|
||||
def index
|
||||
client = Octokit::Client.new(access_token: current_account.github_provider.access_token)
|
||||
provider = current_account.github_provider
|
||||
client = Git::Github::Client.build_client(
|
||||
access_token: provider.access_token,
|
||||
api_base_url: provider.api_base_url
|
||||
)
|
||||
if params[:q].present?
|
||||
client.auto_paginate = true
|
||||
@repositories = client.repos(current_account.github_username)
|
||||
|
||||
@@ -5,6 +5,12 @@ class Local::OnboardingController < ApplicationController
|
||||
def index
|
||||
end
|
||||
|
||||
def account_select
|
||||
redirect_to new_user_session_path unless Rails.application.config.account_sign_in_only
|
||||
|
||||
@accounts = Account.all.includes(:stack_manager)
|
||||
end
|
||||
|
||||
def create
|
||||
result = Portainer::Onboarding::Create.call(params)
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ class Projects::EnvironmentVariablesController < Projects::BaseController
|
||||
@project.updated!
|
||||
|
||||
if @project.current_deployment.present?
|
||||
Projects::DeploymentJob.perform_later(@project.current_deployment)
|
||||
Projects::DeploymentJob.perform_later(@project.current_deployment, current_user)
|
||||
@project.events.create(user: current_user, eventable: @project.last_build, event_action: :update)
|
||||
redirect_to project_environment_variables_path(@project),
|
||||
notice: "Restarting services with new environment variables."
|
||||
@@ -47,7 +47,7 @@ class Projects::EnvironmentVariablesController < Projects::BaseController
|
||||
@environment_variable = @project.environment_variables.find(params[:id])
|
||||
@environment_variable.destroy
|
||||
if @project.current_deployment.present?
|
||||
Projects::DeploymentJob.perform_later(@project.current_deployment)
|
||||
Projects::DeploymentJob.perform_later(@project.current_deployment, current_user)
|
||||
end
|
||||
render turbo_stream: turbo_stream.remove("environment_variable_#{@environment_variable.id}")
|
||||
end
|
||||
|
||||
@@ -13,8 +13,8 @@ class Projects::ProcessesController < Projects::BaseController
|
||||
|
||||
def show
|
||||
client = K8::Client.new(active_connection)
|
||||
@logs = client.get_pod_log(params[:id], @project.name)
|
||||
@pod_events = client.get_pod_events(params[:id], @project.name)
|
||||
@logs = client.get_pod_log(params[:id], @project.namespace)
|
||||
@pod_events = client.get_pod_events(params[:id], @project.namespace)
|
||||
|
||||
respond_to do |format|
|
||||
format.html
|
||||
@@ -30,7 +30,7 @@ class Projects::ProcessesController < Projects::BaseController
|
||||
|
||||
def destroy
|
||||
client = K8::Client.new(active_connection)
|
||||
client.delete_pod(params[:id], @project.name)
|
||||
client.delete_pod(params[:id], @project.namespace)
|
||||
redirect_to project_processes_path(@project), notice: "Pod #{params[:id]} terminating..."
|
||||
end
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ class Projects::Services::JobsController < Projects::Services::BaseController
|
||||
job_name = "#{@service.name}-manual-#{timestamp}"
|
||||
kubectl = K8::Kubectl.new(active_connection)
|
||||
kubectl.call(
|
||||
"-n #{@project.name} create job #{job_name} --from=cronjob/#{@service.name}"
|
||||
"-n #{@project.namespace} create job #{job_name} --from=cronjob/#{@service.name}"
|
||||
)
|
||||
render partial: "projects/services/show", locals: { service: @service, tab: "cron-jobs" }, layout: false
|
||||
end
|
||||
@@ -16,7 +16,7 @@ class Projects::Services::JobsController < Projects::Services::BaseController
|
||||
job_name = params[:id]
|
||||
kubectl = K8::Kubectl.new(active_connection)
|
||||
kubectl.call(
|
||||
"-n #{@project.name} delete job #{job_name}"
|
||||
"-n #{@project.namespace} delete job #{job_name}"
|
||||
)
|
||||
|
||||
render partial: "projects/services/show", locals: { service: @service, tab: "cron-jobs" }, layout: false
|
||||
|
||||
@@ -6,7 +6,13 @@ class ProjectsController < ApplicationController
|
||||
# GET /projects
|
||||
def index
|
||||
sortable_column = params[:sort] || "created_at"
|
||||
@pagy, @projects = pagy(current_account.projects.order(sortable_column => "asc"))
|
||||
projects = Projects::List.call(account_user: current_account_user, params: params).projects
|
||||
@pagy, @projects = pagy(projects.order(sortable_column => "asc"))
|
||||
|
||||
respond_to do |format|
|
||||
format.html
|
||||
format.json { render json: @projects.map { |p| { id: p.id, name: p.name } } }
|
||||
end
|
||||
|
||||
# Uncomment to authorize with Pundit
|
||||
# authorize @projects
|
||||
@@ -91,7 +97,8 @@ class ProjectsController < ApplicationController
|
||||
|
||||
# Use callbacks to share common setup or constraints between actions.
|
||||
def set_project
|
||||
@project = current_account.projects.find(params[:id])
|
||||
projects = Projects::VisibleToUser.execute(account_user: current_account_user).projects
|
||||
@project = projects.find(params[:id])
|
||||
|
||||
# Uncomment to authorize with Pundit
|
||||
# authorize @project
|
||||
|
||||
44
app/controllers/providers/portainer_tokens_controller.rb
Normal file
44
app/controllers/providers/portainer_tokens_controller.rb
Normal file
@@ -0,0 +1,44 @@
|
||||
module Providers
|
||||
class PortainerTokensController < ApplicationController
|
||||
before_action :authenticate_user!
|
||||
before_action :require_stack_manager
|
||||
|
||||
def update
|
||||
token = params[:portainer_token]
|
||||
|
||||
if token.blank?
|
||||
redirect_to providers_path, alert: "Please provide a Portainer API token"
|
||||
return
|
||||
end
|
||||
|
||||
provider = current_user.providers.find_or_initialize_by(provider: Provider::PORTAINER_PROVIDER)
|
||||
provider.access_token = token
|
||||
provider.save!
|
||||
|
||||
# Clear the cached portainer_access_token on the user
|
||||
current_user.instance_variable_set(:@portainer_access_token, nil)
|
||||
|
||||
redirect_to providers_path, notice: "Portainer API token saved successfully"
|
||||
end
|
||||
|
||||
def destroy
|
||||
provider = current_user.providers.find_by(provider: Provider::PORTAINER_PROVIDER)
|
||||
|
||||
if provider&.destroy
|
||||
current_user.instance_variable_set(:@portainer_access_token, nil)
|
||||
redirect_to providers_path, notice: "Portainer API token removed"
|
||||
else
|
||||
redirect_to providers_path, alert: "No Portainer API token found"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def require_stack_manager
|
||||
stack_manager = current_account.stack_manager
|
||||
unless stack_manager&.portainer? && stack_manager.enable_role_based_access_control?
|
||||
redirect_to providers_path, alert: "This account does not require individual Portainer credentials"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -62,4 +62,11 @@ class StaticController < ApplicationController
|
||||
def calculator
|
||||
@prices = JSON.parse(File.read(File.join(Rails.root, 'public', 'resources', 'prices.json')))
|
||||
end
|
||||
|
||||
def docs
|
||||
end
|
||||
|
||||
def swagger
|
||||
render plain: File.read(Rails.root.join('swagger', 'v1', 'swagger.yaml')), layout: false
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
module Users
|
||||
class OmniauthCallbacksController < Devise::OmniauthCallbacksController
|
||||
before_action :set_provider, except: [ :failure ]
|
||||
before_action :set_user, except: [ :failure ]
|
||||
before_action :set_provider
|
||||
before_action :set_user
|
||||
|
||||
attr_reader :provider, :user
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
class Users::SessionsController < Devise::SessionsController
|
||||
layout 'homepage', only: [ :new, :create, :account_login, :account_create, :account_select ]
|
||||
before_action :require_no_authentication, only: [ :account_login, :account_select ]
|
||||
layout 'homepage', only: [ :new, :create, :account_login, :account_create ]
|
||||
before_action :require_no_authentication, only: [ :account_login ]
|
||||
before_action :load_account_from_slug, only: [ :account_login, :account_create ]
|
||||
|
||||
before_action :check_if_default_sign_in_allowed, only: [ :new ]
|
||||
before_action :check_if_account_select_allowed, only: [ :account_select ]
|
||||
|
||||
def new
|
||||
super
|
||||
@@ -19,7 +18,7 @@ class Users::SessionsController < Devise::SessionsController
|
||||
super do
|
||||
# If the account has a stack manager that provides authentication,
|
||||
# redirect to the custom account login URL after logout
|
||||
redirect_url = if account&.stack_manager&.stack&.provides_authentication?
|
||||
redirect_url = if account.custom_login?
|
||||
account_sign_in_path(account.slug)
|
||||
else
|
||||
root_path
|
||||
@@ -33,41 +32,39 @@ class Users::SessionsController < Devise::SessionsController
|
||||
end
|
||||
end
|
||||
|
||||
def account_select
|
||||
@accounts = Account.all.includes(:stack_manager)
|
||||
end
|
||||
|
||||
def account_login
|
||||
self.resource = resource_class.new(sign_in_params)
|
||||
clean_up_passwords(resource)
|
||||
if @account.stack_manager&.portainer?
|
||||
render "devise/sessions/portainer"
|
||||
@sso_provider = @account.sso_provider if @account.sso_enabled?
|
||||
|
||||
if @account.sso_provider&.ldap?
|
||||
render "devise/sessions/ldap"
|
||||
elsif @account.sso_provider&.oidc?
|
||||
render "devise/sessions/oidc"
|
||||
else
|
||||
render :new
|
||||
end
|
||||
end
|
||||
|
||||
def account_create
|
||||
# If account has a stack manager, use Portainer authentication
|
||||
if @account.stack_manager.present?
|
||||
result = Portainer::Login.execute(
|
||||
username: params[:user][:username],
|
||||
password: params[:user][:password],
|
||||
account: @account,
|
||||
)
|
||||
# If account has SSO provider with LDAP, use LDAP authentication
|
||||
if @account.sso_provider&.ldap?
|
||||
session[:ldap_account_id] = @account.id
|
||||
resource = warden.authenticate(:ldap_authenticatable, scope: :user)
|
||||
|
||||
if result.success?
|
||||
sign_in(result.user)
|
||||
# Auto-associate user with account if they sign in through account URL
|
||||
session[:account_id] = result.account.id
|
||||
|
||||
redirect_to after_sign_in_path_for(result.user), notice: "Logged in successfully"
|
||||
if resource
|
||||
sign_in(resource)
|
||||
session[:account_id] = @account.id
|
||||
redirect_to after_sign_in_path_for(resource), notice: "Logged in successfully"
|
||||
else
|
||||
flash[:alert] = result.message
|
||||
self.resource = result.user || resource_class.new(sign_in_params)
|
||||
clean_up_passwords(resource)
|
||||
render 'devise/sessions/portainer'
|
||||
flash[:alert] = "Invalid email or password"
|
||||
self.resource = resource_class.new(sign_in_params)
|
||||
clean_up_passwords(self.resource)
|
||||
render "devise/sessions/ldap"
|
||||
end
|
||||
elsif @account.sso_provider&.oidc?
|
||||
# OIDC uses a redirect flow, so this shouldn't be called directly
|
||||
redirect_to account_sign_in_path(@account.slug)
|
||||
else
|
||||
redirect_to new_user_session_path
|
||||
end
|
||||
@@ -77,13 +74,7 @@ class Users::SessionsController < Devise::SessionsController
|
||||
|
||||
def check_if_default_sign_in_allowed
|
||||
if Rails.application.config.account_sign_in_only
|
||||
redirect_to accounts_select_url
|
||||
end
|
||||
end
|
||||
|
||||
def check_if_account_select_allowed
|
||||
unless Rails.application.config.account_sign_in_only
|
||||
redirect_to new_user_session_path
|
||||
redirect_to account_select_local_onboarding_index_path
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
import { computePosition, autoUpdate, flip, shift, offset, size } from "@floating-ui/dom"
|
||||
import { debounce } from "../../utils"
|
||||
|
||||
/**
|
||||
@@ -26,9 +27,9 @@ export default class extends Controller {
|
||||
// Disable browser autocomplete
|
||||
this.input.setAttribute('autocomplete', 'off')
|
||||
|
||||
// Create dropdown
|
||||
// Create dropdown and append to the appropriate container
|
||||
this.dropdown = this.createDropdown()
|
||||
this.element.appendChild(this.dropdown)
|
||||
this.getDropdownContainer().appendChild(this.dropdown)
|
||||
|
||||
// Bind search handler with debounce
|
||||
this.searchHandler = debounce(this.performSearch.bind(this), this.getDebounceDelay())
|
||||
@@ -37,6 +38,8 @@ export default class extends Controller {
|
||||
// Handle click outside to close dropdown
|
||||
this.clickOutsideHandler = this.handleClickOutside.bind(this)
|
||||
document.addEventListener('click', this.clickOutsideHandler)
|
||||
|
||||
this.cleanupAutoUpdate = null
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
@@ -44,14 +47,52 @@ export default class extends Controller {
|
||||
this.input.removeEventListener('input', this.searchHandler)
|
||||
}
|
||||
document.removeEventListener('click', this.clickOutsideHandler)
|
||||
if (this.cleanupAutoUpdate) {
|
||||
this.cleanupAutoUpdate()
|
||||
}
|
||||
if (this.dropdown && this.dropdown.parentNode) {
|
||||
this.dropdown.parentNode.removeChild(this.dropdown)
|
||||
}
|
||||
}
|
||||
|
||||
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'
|
||||
dropdown.className = 'hidden z-50 bg-neutral rounded-box shadow-lg max-h-[300px] overflow-y-auto'
|
||||
dropdown.style.position = 'absolute'
|
||||
dropdown.style.top = '0'
|
||||
dropdown.style.left = '0'
|
||||
return dropdown
|
||||
}
|
||||
|
||||
getDropdownContainer() {
|
||||
// Check if we're inside a modal and append there to maintain stacking context
|
||||
const modal = this.element.closest('.modal, [role="dialog"], dialog')
|
||||
return modal || document.body
|
||||
}
|
||||
|
||||
updatePosition() {
|
||||
computePosition(this.input, this.dropdown, {
|
||||
placement: 'bottom-start',
|
||||
middleware: [
|
||||
offset(4),
|
||||
flip({ fallbackPlacements: ['top-start'] }),
|
||||
shift({ padding: 8 }),
|
||||
size({
|
||||
apply({ rects, elements }) {
|
||||
Object.assign(elements.floating.style, {
|
||||
minWidth: `${rects.reference.width}px`
|
||||
})
|
||||
}
|
||||
})
|
||||
]
|
||||
}).then(({ x, y }) => {
|
||||
Object.assign(this.dropdown.style, {
|
||||
left: `${x}px`,
|
||||
top: `${y}px`
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
getInputElement() {
|
||||
return this.element.querySelector('input')
|
||||
}
|
||||
@@ -89,7 +130,7 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
this.dropdown.innerHTML = results.map((item, index) => `
|
||||
<li class="cursor-pointer hover:bg-base-300 p-2" data-index="${index}">
|
||||
<li class="cursor-pointer p-2 hover:bg-base-300" data-index="${index}">
|
||||
${this.renderItem(item)}
|
||||
</li>
|
||||
`).join('')
|
||||
@@ -121,11 +162,26 @@ export default class extends Controller {
|
||||
|
||||
showDropdown() {
|
||||
this.dropdown.classList.remove('hidden')
|
||||
this.updatePosition()
|
||||
|
||||
// Set up auto-update to keep position in sync during scroll/resize
|
||||
if (this.cleanupAutoUpdate) {
|
||||
this.cleanupAutoUpdate()
|
||||
}
|
||||
this.cleanupAutoUpdate = autoUpdate(this.input, this.dropdown, () => {
|
||||
this.updatePosition()
|
||||
})
|
||||
}
|
||||
|
||||
hideDropdown() {
|
||||
this.dropdown.classList.add('hidden')
|
||||
this.dropdown.innerHTML = ''
|
||||
|
||||
// Clean up auto-update listener
|
||||
if (this.cleanupAutoUpdate) {
|
||||
this.cleanupAutoUpdate()
|
||||
this.cleanupAutoUpdate = null
|
||||
}
|
||||
}
|
||||
|
||||
showLoading() {
|
||||
@@ -157,7 +213,7 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
handleClickOutside(event) {
|
||||
if (!this.element.contains(event.target)) {
|
||||
if (!this.element.contains(event.target) && !this.dropdown.contains(event.target)) {
|
||||
this.hideDropdown()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["container"]
|
||||
|
||||
connect() {
|
||||
// Initial setup: hidden + transparent + transition classes
|
||||
this.containerTarget.classList.add(
|
||||
"hidden", // prevent layout
|
||||
"opacity-0", // starting transparency
|
||||
"transition-all",
|
||||
"duration-500" // control speed
|
||||
)
|
||||
}
|
||||
|
||||
show(e) {
|
||||
e.preventDefault()
|
||||
|
||||
// Hide the button
|
||||
e.target.classList.add("hidden")
|
||||
|
||||
// Step 1: unhide but keep opacity-0
|
||||
this.containerTarget.classList.remove("hidden")
|
||||
|
||||
// Step 2: allow browser to register this initial state
|
||||
requestAnimationFrame(() => {
|
||||
// Step 3: now fade to opacity-100 → animation occurs
|
||||
this.containerTarget.classList.add("opacity-100")
|
||||
this.containerTarget.classList.remove("opacity-0")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["button", "result"]
|
||||
|
||||
async test(event) {
|
||||
event.preventDefault()
|
||||
|
||||
const form = this.element.closest("form")
|
||||
const formData = new FormData(form)
|
||||
|
||||
// Clear previous result
|
||||
this.resultTarget.innerHTML = ""
|
||||
|
||||
// Update button to show loading state
|
||||
const button = this.buttonTarget
|
||||
const originalContent = button.innerHTML
|
||||
button.disabled = true
|
||||
button.innerHTML = '<span class="loading loading-spinner loading-xs"></span> Testing...'
|
||||
|
||||
try {
|
||||
const response = await fetch("/accounts/sso_provider/test_connection", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content,
|
||||
"Accept": "text/vnd.turbo-stream.html"
|
||||
},
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const html = await response.text()
|
||||
Turbo.renderStreamMessage(html)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("LDAP test connection failed:", error)
|
||||
} finally {
|
||||
// Always re-enable the button so it can be clicked again
|
||||
button.disabled = false
|
||||
button.innerHTML = originalContent
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
connect() {
|
||||
console.log("NamespaceInputGroupController connected")
|
||||
}
|
||||
|
||||
toggleManagedNamespace() {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,14 @@ export default class extends Controller {
|
||||
|
||||
toggle() {
|
||||
const selectedRadio = this.radioTargets.find(radio => radio.checked)
|
||||
console.log(selectedRadio)
|
||||
|
||||
if (selectedRadio) {
|
||||
const selectedValue = selectedRadio.value
|
||||
|
||||
this.partialTargets.forEach(partial => {
|
||||
if (partial.dataset.value === selectedValue) {
|
||||
console.log(partial)
|
||||
partial.classList.remove('hidden')
|
||||
} else {
|
||||
partial.classList.add('hidden')
|
||||
|
||||
@@ -1,59 +1,39 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
import { PortainerChecker } from "../../utils/portainer"
|
||||
|
||||
const AUTHENTICATION_VERIFICATION_METHOD = "authentication";
|
||||
const URL_VERIFICATION_METHOD = "url";
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = [ "message", "verifyUrlSuccess", "verifyUrlError", "verifyUrlLoading", "verifyUrlNotAllowed" ]
|
||||
static targets = [ "verifyUrlSuccess", "verifyUrlError", "verifyUrlLoading", "verifyUrlUnauthorized" ]
|
||||
|
||||
static values = {
|
||||
verificationMethod: String,
|
||||
verifyUrl: String,
|
||||
credentialsPath: { type: String, default: "/providers" },
|
||||
rbacEnabled: { type: Boolean, default: false }
|
||||
}
|
||||
|
||||
async connect() {
|
||||
this.verifyUrlLoadingTarget.classList.remove('hidden')
|
||||
const portainerChecker = new PortainerChecker();
|
||||
let result = null;
|
||||
if (this.verificationMethodValue === AUTHENTICATION_VERIFICATION_METHOD) {
|
||||
result = await portainerChecker.verifyPortainerAuthentication();
|
||||
} else if (this.verificationMethodValue === URL_VERIFICATION_METHOD) {
|
||||
const url = this.verifyUrlValue;
|
||||
result = await portainerChecker.checkReachable(url);
|
||||
}
|
||||
|
||||
if (result === PortainerChecker.STATUS_UNAUTHORIZED) {
|
||||
this.logout();
|
||||
} else if (result === PortainerChecker.STATUS_OK) {
|
||||
// Only verify user connectivity if RBAC is enabled, otherwise just check URL reachability
|
||||
const result = this.rbacEnabledValue
|
||||
? await portainerChecker.verifyConnectivity()
|
||||
: await portainerChecker.checkReachable(this.verifyUrlValue);
|
||||
|
||||
if (result === PortainerChecker.STATUS_OK) {
|
||||
this.verifyUrlSuccessTarget.classList.remove('hidden')
|
||||
} else if (result === PortainerChecker.STATUS_NOT_ALLOWED) {
|
||||
this.verifyUrlNotAllowedTarget.classList.remove('hidden')
|
||||
} else if (result === PortainerChecker.STATUS_UNAUTHORIZED && this.rbacEnabledValue) {
|
||||
if (this.hasVerifyUrlUnauthorizedTarget) {
|
||||
this.verifyUrlUnauthorizedTarget.classList.remove('hidden')
|
||||
} else {
|
||||
this.verifyUrlErrorTarget.classList.remove('hidden')
|
||||
}
|
||||
} else {
|
||||
this.verifyUrlErrorTarget.classList.remove('hidden')
|
||||
}
|
||||
this.verifyUrlLoadingTarget.classList.add('hidden')
|
||||
}
|
||||
|
||||
async logout() {
|
||||
try {
|
||||
const response = await fetch('/users/sign_out', {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content,
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
credentials: 'same-origin'
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
window.location.href = data.redirect_url
|
||||
} else {
|
||||
window.location.href = '/users/sign_in'
|
||||
}
|
||||
} catch (error) {
|
||||
window.location.href = '/users/sign_in'
|
||||
}
|
||||
navigateToCredentials() {
|
||||
window.location.href = this.credentialsPathValue
|
||||
}
|
||||
}
|
||||
|
||||
86
app/javascript/controllers/team_member_search_controller.js
Normal file
86
app/javascript/controllers/team_member_search_controller.js
Normal file
@@ -0,0 +1,86 @@
|
||||
import AsyncSearchDropdownController from "./components/async_search_dropdown_controller"
|
||||
|
||||
export default class extends AsyncSearchDropdownController {
|
||||
static values = {
|
||||
teamId: String,
|
||||
addUrl: String
|
||||
}
|
||||
|
||||
async fetchResults(query) {
|
||||
const url = `/accounts/teams/${this.teamIdValue}/team_members_search?q=${encodeURIComponent(query)}`
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to search team members')
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
renderItem(user) {
|
||||
const displayName = user.name || user.first_name || user.email.split('@')[0]
|
||||
const showEmail = user.name || user.first_name
|
||||
|
||||
return `
|
||||
<div class="flex items-center gap-3 px-2 py-2">
|
||||
<div class="avatar">
|
||||
<div class="w-8 h-8 rounded">
|
||||
<img src="${this.escapeHtml(user.avatar_url)}" alt="${this.escapeHtml(displayName)}" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium truncate">${this.escapeHtml(displayName)}</div>
|
||||
${showEmail ? `<div class="text-sm text-base-content/60 truncate">${this.escapeHtml(user.email)}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
async onItemSelect(user, itemElement) {
|
||||
try {
|
||||
// Add loading state
|
||||
itemElement.classList.add('opacity-50')
|
||||
itemElement.innerHTML = `
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
<span>Adding ${this.escapeHtml(user.name || user.email)}...</span>
|
||||
</div>
|
||||
`
|
||||
|
||||
// Send POST request to add team member
|
||||
const response = await fetch(this.addUrlValue, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': this.getCsrfToken()
|
||||
},
|
||||
body: JSON.stringify({ user_id: user.id })
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to add team member')
|
||||
}
|
||||
|
||||
// Redirect to reload page (or you could use Turbo Stream)
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
console.error('Error adding team member:', error)
|
||||
alert('Failed to add team member. Please try again.')
|
||||
itemElement.classList.remove('opacity-50')
|
||||
}
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div')
|
||||
div.textContent = text
|
||||
return div.innerHTML
|
||||
}
|
||||
|
||||
getCsrfToken() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.content || ''
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import AsyncSearchDropdownController from "./components/async_search_dropdown_controller"
|
||||
|
||||
export default class extends AsyncSearchDropdownController {
|
||||
static values = {
|
||||
url: String,
|
||||
addUrl: String,
|
||||
resourceType: String,
|
||||
turboFrame: String
|
||||
}
|
||||
|
||||
async fetchResults(query) {
|
||||
const url = `${this.urlValue}.json?q=${encodeURIComponent(query)}`
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to search resources')
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
renderItem(resource) {
|
||||
return `
|
||||
<div class="flex items-center gap-3 px-2 py-2">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium truncate">${this.escapeHtml(resource.name)}</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
async onItemSelect(resource, itemElement) {
|
||||
try {
|
||||
itemElement.classList.add('opacity-50')
|
||||
itemElement.innerHTML = `
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
<span>Adding ${this.escapeHtml(resource.name)}...</span>
|
||||
</div>
|
||||
`
|
||||
|
||||
const response = await fetch(this.addUrlValue, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': this.getCsrfToken()
|
||||
},
|
||||
body: JSON.stringify({
|
||||
resourceable_type: this.resourceTypeValue,
|
||||
resourceable_id: resource.id
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to add resource')
|
||||
}
|
||||
|
||||
// Close the modal
|
||||
const modal = this.element.closest('dialog')
|
||||
if (modal) {
|
||||
modal.close()
|
||||
}
|
||||
|
||||
// Reload the turbo frame
|
||||
if (this.hasTurboFrameValue) {
|
||||
const frame = document.getElementById(this.turboFrameValue)
|
||||
if (frame) {
|
||||
frame.reload()
|
||||
}
|
||||
} else {
|
||||
window.location.reload()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error adding resource:', error)
|
||||
alert('Failed to add resource. Please try again.')
|
||||
itemElement.classList.remove('opacity-50')
|
||||
}
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div')
|
||||
div.textContent = text
|
||||
return div.innerHTML
|
||||
}
|
||||
|
||||
getCsrfToken() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.content || ''
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ export async function getDefaultValues(
|
||||
export function helmChartHeader(packageData) {
|
||||
const logoImageUrl = getLogoImageUrl(packageData);
|
||||
return `
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-4 max-w-lg">
|
||||
<img src="${logoImageUrl}" alt="${packageData.name}" class="h-16 w-16">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
@@ -41,7 +41,7 @@ export function helmChartHeader(packageData) {
|
||||
${packageData.stars}
|
||||
</span>` : ''}
|
||||
</div>
|
||||
<p class="text-sm text-base-content/70 mb-2">${packageData.description}</p>
|
||||
<p class="text-sm text-base-content/70 mb-2 line-clamp-2">${packageData.description}</p>
|
||||
<div class="flex gap-4 text-xs text-base-content/70">
|
||||
<div class="flex items-center gap-1">
|
||||
<iconify-icon icon="lucide:globe"></iconify-icon>
|
||||
|
||||
@@ -1,34 +1,17 @@
|
||||
export class PortainerChecker {
|
||||
static STATUS_OK = "ok";
|
||||
static STATUS_UNAUTHORIZED = "unauthorized";
|
||||
static STATUS_NOT_ALLOWED = "not_allowed";
|
||||
static STATUS_ERROR = "error";
|
||||
|
||||
csrfToken() {
|
||||
return document.querySelector('meta[name="csrf-token"]').content
|
||||
}
|
||||
|
||||
async verifyPortainerAuthentication() {
|
||||
const response = await fetch('/stack_manager/verify_login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': this.csrfToken()
|
||||
}
|
||||
})
|
||||
|
||||
return this.toResult(response);
|
||||
}
|
||||
|
||||
toResult(response) {
|
||||
if (response.status === 401) {
|
||||
return PortainerChecker.STATUS_UNAUTHORIZED;
|
||||
}
|
||||
|
||||
if (response.status === 405) {
|
||||
return PortainerChecker.STATUS_NOT_ALLOWED;
|
||||
}
|
||||
|
||||
if (response.status === 502) {
|
||||
return PortainerChecker.STATUS_ERROR;
|
||||
}
|
||||
@@ -52,6 +35,17 @@ export class PortainerChecker {
|
||||
return this.toResult(response);
|
||||
}
|
||||
|
||||
async verifyConnectivity() {
|
||||
const response = await fetch('/stack_manager/verify_connectivity', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': this.csrfToken()
|
||||
}
|
||||
})
|
||||
return this.toResult(response);
|
||||
}
|
||||
|
||||
async verifyPortainerUrl(url, accessToken) {
|
||||
const response = await fetch('/stack_manager/verify_url', {
|
||||
method: 'POST',
|
||||
@@ -64,4 +58,4 @@ export class PortainerChecker {
|
||||
|
||||
return this.toResult(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,12 +11,14 @@ class Projects::DeploymentJob < ApplicationJob
|
||||
project = deployment.project
|
||||
connection = K8::Connection.new(project, user, allow_anonymous: true)
|
||||
kubectl = create_kubectl(deployment, connection)
|
||||
kubectl.register_after_apply do |yaml_content|
|
||||
kubectl.register_before_apply do |yaml_content|
|
||||
deployment.add_manifest(yaml_content)
|
||||
end
|
||||
|
||||
# Create namespace
|
||||
apply_namespace(project, kubectl)
|
||||
if project.managed_namespace?
|
||||
apply_namespace(project, kubectl)
|
||||
end
|
||||
|
||||
# Upload container registry secrets
|
||||
upload_registry_secrets(kubectl, deployment)
|
||||
@@ -108,11 +110,11 @@ class Projects::DeploymentJob < ApplicationJob
|
||||
end
|
||||
|
||||
def kill_one_off_containers(project, kubectl)
|
||||
kubectl.call("-n #{project.name} delete pods -l oneoff=true")
|
||||
kubectl.call("-n #{project.namespace} delete pods -l oneoff=true")
|
||||
end
|
||||
|
||||
def apply_namespace(project, kubectl)
|
||||
@logger.info("Creating namespace: #{project.name}", color: :yellow)
|
||||
@logger.info("Creating namespace: #{project.namespace}", color: :yellow)
|
||||
namespace_yaml = K8::Namespace.new(project).to_yaml
|
||||
kubectl.apply_yaml(namespace_yaml)
|
||||
end
|
||||
@@ -124,7 +126,7 @@ class Projects::DeploymentJob < ApplicationJob
|
||||
kubectl = K8::Kubectl.new(connection)
|
||||
|
||||
resources_to_sweep.each do |resource_type|
|
||||
results = YAML.safe_load(kubectl.call("get #{resource_type.downcase} -o yaml -n #{project.name}"))
|
||||
results = YAML.safe_load(kubectl.call("get #{resource_type.downcase} -o yaml -n #{project.namespace}"))
|
||||
results['items'].each do |resource|
|
||||
if @marked_resources.select do |r|
|
||||
r.is_a?(K8::Stateless.const_get(resource_type))
|
||||
@@ -132,7 +134,7 @@ class Projects::DeploymentJob < ApplicationJob
|
||||
applied_resource.name == resource['metadata']['name']
|
||||
end && resource.dig('metadata', 'labels', 'caninemanaged') == 'true'
|
||||
@logger.info("Deleting #{resource_type}: #{resource['metadata']['name']}", color: :yellow)
|
||||
kubectl.call("delete #{resource_type.downcase} #{resource['metadata']['name']} -n #{project.name}")
|
||||
kubectl.call("delete #{resource_type.downcase} #{resource['metadata']['name']} -n #{project.namespace}")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -150,7 +152,7 @@ class Projects::DeploymentJob < ApplicationJob
|
||||
|
||||
def restart_deployment(service, kubectl)
|
||||
@logger.info("Restarting deployment: #{service.name}", color: :yellow)
|
||||
kubectl.call("-n #{service.project.name} rollout restart deployment/#{service.name}")
|
||||
kubectl.call("-n #{service.project.namespace} rollout restart deployment/#{service.name}")
|
||||
end
|
||||
|
||||
def upload_registry_secrets(kubectl, deployment)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
class Projects::DestroyJob < ApplicationJob
|
||||
def perform(project, user)
|
||||
project.destroying!
|
||||
delete_namespace(project, user)
|
||||
if project.managed_namespace
|
||||
delete_namespace(project, user)
|
||||
end
|
||||
|
||||
# Delete the github webhook for the project IF there are no more projects that refer to that repository
|
||||
# TODO: This might have overlapping repository urls across different providers.
|
||||
@@ -15,7 +17,7 @@ class Projects::DestroyJob < ApplicationJob
|
||||
|
||||
def delete_namespace(project, user)
|
||||
client = K8::Client.new(K8::Connection.new(project.cluster, user))
|
||||
if (namespace = client.get_namespaces.find { |n| n.metadata.name == project.name }).present?
|
||||
if (namespace = client.get_namespaces.find { |n| n.metadata.name == project.namespace }).present?
|
||||
client.delete_namespace(namespace.metadata.name)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,15 +3,20 @@ class Scheduled::CheckHealthJob < ApplicationJob
|
||||
|
||||
def perform
|
||||
Service.web_service.where('healthcheck_url IS NOT NULL').each do |service|
|
||||
# url = File.join("http://#{service.name}-service.#{service.project.name}.svc.cluster.local", service.healthcheck_url)
|
||||
# url = File.join("http://#{service.name}-service.#{service.project.namespace}.svc.cluster.local", service.healthcheck_url)
|
||||
# K8::Client.from_project(service.project).run_command("curl -s -o /dev/null -w '%{http_code}' #{url}")
|
||||
if service.domains.any?
|
||||
url = File.join("https://#{service.domains.first.domain_name}", service.healthcheck_url)
|
||||
Rails.logger.info("Checking health for #{service.name} at #{url}")
|
||||
response = HTTParty.get(url)
|
||||
if response.success?
|
||||
service.status = :healthy
|
||||
else
|
||||
begin
|
||||
response = HTTParty.get(url, timeout: 10)
|
||||
if response.success?
|
||||
service.status = :healthy
|
||||
else
|
||||
service.status = :unhealthy
|
||||
end
|
||||
rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, SocketError, HTTParty::Error, OpenSSL::SSL::SSLError => e
|
||||
Rails.logger.warn("Health check failed for #{service.name}: #{e.class} - #{e.message}")
|
||||
service.status = :unhealthy
|
||||
end
|
||||
service.last_health_checked_at = DateTime.current
|
||||
|
||||
@@ -26,6 +26,8 @@ class Account < ApplicationRecord
|
||||
has_many :account_users, dependent: :destroy
|
||||
has_many :users, through: :account_users
|
||||
has_one :stack_manager, dependent: :destroy
|
||||
has_one :sso_provider, dependent: :destroy
|
||||
has_many :teams, dependent: :destroy
|
||||
|
||||
has_many :clusters, dependent: :destroy
|
||||
has_many :build_clouds, through: :clusters
|
||||
@@ -47,4 +49,12 @@ class Account < ApplicationRecord
|
||||
def github_provider
|
||||
@_github_account ||= owner.providers.find_by(provider: "github")
|
||||
end
|
||||
|
||||
def sso_enabled?
|
||||
sso_provider&.enabled?
|
||||
end
|
||||
|
||||
def custom_login?
|
||||
sso_enabled?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -21,4 +21,8 @@
|
||||
class AccountUser < ApplicationRecord
|
||||
belongs_to :user
|
||||
belongs_to :account
|
||||
|
||||
def admin?
|
||||
account.owner_id == user_id
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,16 +2,18 @@
|
||||
#
|
||||
# Table name: add_ons
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# chart_type :string not null
|
||||
# chart_url :string
|
||||
# metadata :jsonb
|
||||
# name :string not null
|
||||
# status :integer default("installing"), not null
|
||||
# values :jsonb
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# cluster_id :bigint not null
|
||||
# id :bigint not null, primary key
|
||||
# chart_type :string not null
|
||||
# chart_url :string
|
||||
# managed_namespace :boolean default(TRUE)
|
||||
# metadata :jsonb
|
||||
# name :string not null
|
||||
# namespace :string not null
|
||||
# status :integer default("installing"), not null
|
||||
# values :jsonb
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# cluster_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
@@ -24,6 +26,8 @@
|
||||
#
|
||||
class AddOn < ApplicationRecord
|
||||
include Loggable
|
||||
include TeamAccessible
|
||||
include Namespaced
|
||||
belongs_to :cluster
|
||||
has_one :account, through: :cluster
|
||||
|
||||
@@ -39,9 +43,9 @@ class AddOn < ApplicationRecord
|
||||
validates :chart_type, presence: true
|
||||
validate :chart_type_exists
|
||||
validates :name, presence: true, format: { with: /\A[a-z0-9-]+\z/, message: "must be lowercase, numbers, and hyphens only" }
|
||||
validate :name_is_unique_to_cluster, on: :create
|
||||
validates_presence_of :chart_url
|
||||
validate :has_package_details, if: :helm_chart?
|
||||
validates_uniqueness_of :name, scope: :cluster_id
|
||||
|
||||
after_update_commit do
|
||||
broadcast_replace_later_to [ self, :install_stage ], target: dom_id(self, :install_stage), partial: "add_ons/install_stage", locals: { add_on: self }
|
||||
@@ -56,12 +60,6 @@ class AddOn < ApplicationRecord
|
||||
metadata['install_stage'] || 0
|
||||
end
|
||||
|
||||
def name_is_unique_to_cluster
|
||||
if cluster.namespaces.include?(name)
|
||||
errors.add(:name, "must be unique to this cluster")
|
||||
end
|
||||
end
|
||||
|
||||
def has_package_details
|
||||
if metadata['package_details'].blank?
|
||||
errors.add(:metadata, "is missing required keys: package_details")
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
#
|
||||
class Cluster < ApplicationRecord
|
||||
include Loggable
|
||||
include TeamAccessible
|
||||
broadcasts_refreshes
|
||||
belongs_to :account
|
||||
|
||||
@@ -58,7 +59,7 @@ class Cluster < ApplicationRecord
|
||||
]
|
||||
|
||||
def namespaces
|
||||
RESERVED_NAMESPACES + projects.pluck(:name) + add_ons.pluck(:name)
|
||||
RESERVED_NAMESPACES + projects.pluck(:namespace) + add_ons.pluck(:namespace)
|
||||
end
|
||||
|
||||
def create_build_cloud!(attributes = {})
|
||||
|
||||
15
app/models/concerns/namespaced.rb
Normal file
15
app/models/concerns/namespaced.rb
Normal file
@@ -0,0 +1,15 @@
|
||||
module Namespaced
|
||||
def name_is_unique_to_cluster
|
||||
if cluster.namespaces.include?(namespace)
|
||||
errors.add(:name, "must be unique to this cluster")
|
||||
end
|
||||
end
|
||||
|
||||
def self.included(base)
|
||||
base.class_eval do
|
||||
validates_presence_of :namespace
|
||||
|
||||
validate :name_is_unique_to_cluster, on: :create
|
||||
end
|
||||
end
|
||||
end
|
||||
10
app/models/concerns/team_accessible.rb
Normal file
10
app/models/concerns/team_accessible.rb
Normal file
@@ -0,0 +1,10 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module TeamAccessible
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
has_many :team_resources, as: :resourceable, dependent: :destroy
|
||||
has_many :teams, through: :team_resources
|
||||
end
|
||||
end
|
||||
44
app/models/ldap_configuration.rb
Normal file
44
app/models/ldap_configuration.rb
Normal file
@@ -0,0 +1,44 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: ldap_configurations
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# allow_anonymous_reads :boolean default(FALSE)
|
||||
# base_dn :string not null
|
||||
# bind_dn :string
|
||||
# bind_password :string
|
||||
# email_attribute :string default("mail")
|
||||
# encryption :integer not null
|
||||
# filter :string
|
||||
# host :string not null
|
||||
# name_attribute :string default("cn")
|
||||
# port :integer default(389), not null
|
||||
# uid_attribute :string default("uid"), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
class LDAPConfiguration < ApplicationRecord
|
||||
has_one :sso_provider, as: :configuration, dependent: :destroy
|
||||
has_one :account, through: :sso_provider
|
||||
|
||||
enum :encryption, { plain: 0, simple_tls: 1, start_tls: 2 }
|
||||
|
||||
validates :host, presence: true
|
||||
validates :port, presence: true, numericality: { only_integer: true, greater_than: 0 }
|
||||
validates :base_dn, presence: true
|
||||
validates :uid_attribute, presence: true
|
||||
validates_presence_of :bind_dn, if: :allow_anonymous_reads?
|
||||
validates_presence_of :bind_password, if: :allow_anonymous_reads?
|
||||
|
||||
# Returns encryption method as symbol for Net::LDAP
|
||||
# Encryption options: plain (no encryption), simple_tls (LDAPS), start_tls (STARTTLS)
|
||||
def encryption_method
|
||||
return nil if plain?
|
||||
|
||||
encryption.to_sym
|
||||
end
|
||||
|
||||
def requires_auth?
|
||||
bind_dn.present? && bind_password.present?
|
||||
end
|
||||
end
|
||||
36
app/models/oidc_configuration.rb
Normal file
36
app/models/oidc_configuration.rb
Normal file
@@ -0,0 +1,36 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: oidc_configurations
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# issuer :string not null
|
||||
# client_id :string not null
|
||||
# client_secret :string not null
|
||||
# authorization_endpoint :string
|
||||
# token_endpoint :string
|
||||
# userinfo_endpoint :string
|
||||
# jwks_uri :string
|
||||
# scopes :string default("openid email profile")
|
||||
# uid_claim :string default("sub"), not null
|
||||
# email_claim :string default("email")
|
||||
# name_claim :string default("name")
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
class OIDCConfiguration < ApplicationRecord
|
||||
has_one :sso_provider, as: :configuration, dependent: :destroy
|
||||
has_one :account, through: :sso_provider
|
||||
|
||||
validates :issuer, presence: true
|
||||
validates :client_id, presence: true
|
||||
validates :client_secret, presence: true
|
||||
validates :uid_claim, presence: true
|
||||
|
||||
def discovery_url
|
||||
URI.join(issuer, ".well-known/openid-configuration").to_s
|
||||
end
|
||||
|
||||
def uses_discovery?
|
||||
authorization_endpoint.blank? && token_endpoint.blank?
|
||||
end
|
||||
end
|
||||
@@ -9,7 +9,9 @@
|
||||
# container_registry_url :string
|
||||
# docker_build_context_directory :string default("."), not null
|
||||
# dockerfile_path :string default("./Dockerfile"), not null
|
||||
# managed_namespace :boolean default(TRUE)
|
||||
# name :string not null
|
||||
# namespace :string not null
|
||||
# postdeploy_command :text
|
||||
# postdestroy_command :text
|
||||
# predeploy_command :text
|
||||
@@ -32,6 +34,8 @@
|
||||
# fk_rails_... (project_fork_cluster_id => clusters.id)
|
||||
#
|
||||
class Project < ApplicationRecord
|
||||
include TeamAccessible
|
||||
include Namespaced
|
||||
broadcasts_refreshes
|
||||
belongs_to :cluster
|
||||
has_one :account, through: :cluster
|
||||
@@ -54,6 +58,8 @@ class Project < ApplicationRecord
|
||||
|
||||
validates :name, presence: true,
|
||||
format: { with: /\A[a-z0-9-]+\z/, message: "must be lowercase, numbers, and hyphens only" }
|
||||
validates :namespace, presence: true,
|
||||
format: { with: /\A[a-z0-9-]+\z/, message: "must be lowercase, numbers, and hyphens only" }
|
||||
validates :branch, presence: true
|
||||
validates :repository_url, presence: true,
|
||||
format: {
|
||||
@@ -64,8 +70,8 @@ class Project < ApplicationRecord
|
||||
validates_presence_of :project_fork_cluster_id, unless: :forks_disabled?
|
||||
validate :project_fork_cluster_id_is_owned_by_account
|
||||
validates_presence_of :build_configuration, if: :git?
|
||||
validates_uniqueness_of :name, scope: :cluster_id
|
||||
|
||||
validate :name_is_unique_to_cluster, on: :create
|
||||
after_save_commit do
|
||||
broadcast_replace_to [ self, :status ], target: dom_id(self, :status), partial: "projects/status", locals: { project: self }
|
||||
end
|
||||
@@ -92,12 +98,6 @@ class Project < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
def name_is_unique_to_cluster
|
||||
if cluster.namespaces.include?(name)
|
||||
errors.add(:name, "must be unique to this cluster")
|
||||
end
|
||||
end
|
||||
|
||||
def current_deployment
|
||||
deployments.order(created_at: :desc).where(status: :completed).first
|
||||
end
|
||||
|
||||
@@ -15,21 +15,27 @@
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# external_id :string
|
||||
# sso_provider_id :bigint
|
||||
# user_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_providers_on_user_id (user_id)
|
||||
# index_providers_on_sso_provider_id (sso_provider_id)
|
||||
# index_providers_on_sso_provider_id_and_uid (sso_provider_id,uid) UNIQUE WHERE (sso_provider_id IS NOT NULL)
|
||||
# index_providers_on_user_id (user_id)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (sso_provider_id => sso_providers.id)
|
||||
# fk_rails_... (user_id => users.id)
|
||||
#
|
||||
class Provider < ApplicationRecord
|
||||
attr_accessor :username_param
|
||||
GITHUB_PROVIDER = "github"
|
||||
GITHUB_API_BASE = "https://api.github.com"
|
||||
CUSTOM_REGISTRY_PROVIDER = "container_registry"
|
||||
GITLAB_PROVIDER = "gitlab"
|
||||
GITLAB_API_BASE = "https://gitlab.com"
|
||||
GIT_TYPE = "git"
|
||||
REGISTRY_TYPE = "registry"
|
||||
PROVIDER_TYPES = {
|
||||
@@ -42,8 +48,10 @@ class Provider < ApplicationRecord
|
||||
AVAILABLE_PROVIDERS = [ GITHUB_PROVIDER, GITLAB_PROVIDER, CUSTOM_REGISTRY_PROVIDER ].freeze
|
||||
validates :registry_url, presence: true, if: :container_registry?
|
||||
scope :has_container_registry, -> { where(provider: [ GITHUB_PROVIDER, GITLAB_PROVIDER, CUSTOM_REGISTRY_PROVIDER ]) }
|
||||
scope :non_sso, -> { where(sso_provider_id: nil) }
|
||||
|
||||
belongs_to :user
|
||||
belongs_to :sso_provider, class_name: "SSOProvider", optional: true
|
||||
|
||||
Devise.omniauth_configs.keys.each do |provider|
|
||||
scope provider, -> { where(provider: provider) }
|
||||
@@ -83,6 +91,20 @@ class Provider < ApplicationRecord
|
||||
provider == GITLAB_PROVIDER
|
||||
end
|
||||
|
||||
def enterprise?
|
||||
(github? || gitlab?) && registry_url.present?
|
||||
end
|
||||
|
||||
def api_base_url
|
||||
if registry_url.present?
|
||||
registry_url.chomp("/")
|
||||
elsif github?
|
||||
GITHUB_API_BASE
|
||||
elsif gitlab?
|
||||
GITLAB_API_BASE
|
||||
end
|
||||
end
|
||||
|
||||
def twitter_refresh_token!(token); end
|
||||
|
||||
def used!
|
||||
|
||||
48
app/models/sso_provider.rb
Normal file
48
app/models/sso_provider.rb
Normal file
@@ -0,0 +1,48 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: sso_providers
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# configuration_type :string not null
|
||||
# enabled :boolean default(TRUE), not null
|
||||
# name :string not null
|
||||
# team_provisioning_mode :integer not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# configuration_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_sso_providers_on_account_id (account_id) UNIQUE
|
||||
# index_sso_providers_on_configuration (configuration_type,configuration_id)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (account_id => accounts.id)
|
||||
#
|
||||
class SSOProvider < ApplicationRecord
|
||||
belongs_to :account
|
||||
belongs_to :configuration, polymorphic: true, dependent: :destroy
|
||||
has_many :providers, dependent: :nullify
|
||||
|
||||
validates :account_id, uniqueness: true
|
||||
|
||||
enum :team_provisioning_mode, {
|
||||
disabled: 0,
|
||||
just_in_time: 1
|
||||
}, suffix: true
|
||||
|
||||
|
||||
def ldap?
|
||||
configuration_type == "LDAPConfiguration"
|
||||
end
|
||||
|
||||
def oidc?
|
||||
configuration_type == "OIDCConfiguration"
|
||||
end
|
||||
|
||||
def sso_users_count
|
||||
providers.joins(:user).distinct.count(:user_id)
|
||||
end
|
||||
end
|
||||
32
app/models/team.rb
Normal file
32
app/models/team.rb
Normal file
@@ -0,0 +1,32 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: teams
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# name :string not null
|
||||
# slug :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_teams_on_account_id (account_id)
|
||||
# index_teams_on_account_id_and_name (account_id,name) UNIQUE
|
||||
# index_teams_on_slug (slug) UNIQUE
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (account_id => accounts.id)
|
||||
#
|
||||
class Team < ApplicationRecord
|
||||
extend FriendlyId
|
||||
friendly_id :name, use: :slugged
|
||||
|
||||
belongs_to :account
|
||||
has_many :team_memberships, dependent: :destroy
|
||||
has_many :users, through: :team_memberships
|
||||
has_many :team_resources, dependent: :destroy
|
||||
|
||||
validates :name, presence: true, uniqueness: { scope: :account_id }
|
||||
end
|
||||
27
app/models/team_membership.rb
Normal file
27
app/models/team_membership.rb
Normal file
@@ -0,0 +1,27 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: team_memberships
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# team_id :bigint not null
|
||||
# user_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_team_memberships_on_team_id (team_id)
|
||||
# index_team_memberships_on_user_id (user_id)
|
||||
# index_team_memberships_on_user_id_and_team_id (user_id,team_id) UNIQUE
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (team_id => teams.id)
|
||||
# fk_rails_... (user_id => users.id)
|
||||
#
|
||||
class TeamMembership < ApplicationRecord
|
||||
belongs_to :user
|
||||
belongs_to :team
|
||||
|
||||
validates :user_id, uniqueness: { scope: :team_id }
|
||||
end
|
||||
27
app/models/team_resource.rb
Normal file
27
app/models/team_resource.rb
Normal file
@@ -0,0 +1,27 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: team_resources
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# resourceable_type :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# resourceable_id :bigint not null
|
||||
# team_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_team_resources_on_resourceable (resourceable_type,resourceable_id)
|
||||
# index_team_resources_on_team_and_resourceable (team_id,resourceable_type,resourceable_id) UNIQUE
|
||||
# index_team_resources_on_team_id (team_id)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (team_id => teams.id)
|
||||
#
|
||||
class TeamResource < ApplicationRecord
|
||||
belongs_to :team
|
||||
belongs_to :resourceable, polymorphic: true
|
||||
|
||||
validates :resourceable_id, uniqueness: { scope: [ :team_id, :resourceable_type ] }
|
||||
end
|
||||
@@ -33,6 +33,8 @@ class User < ApplicationRecord
|
||||
has_many :account_users, dependent: :destroy
|
||||
has_many :accounts, through: :account_users, dependent: :destroy
|
||||
has_many :owned_accounts, class_name: "Account", foreign_key: "owner_id", dependent: :destroy
|
||||
has_many :team_memberships, dependent: :destroy
|
||||
has_many :teams, through: :team_memberships
|
||||
|
||||
has_many :providers, dependent: :destroy
|
||||
has_many :clusters, through: :accounts
|
||||
@@ -50,9 +52,15 @@ class User < ApplicationRecord
|
||||
providers.find_by(provider: "github")
|
||||
end
|
||||
|
||||
def portainer_jwt
|
||||
return @portainer_jwt if @portainer_jwt
|
||||
@portainer_jwt = providers.find_by(provider: "portainer")&.access_token
|
||||
def portainer_access_token
|
||||
return @portainer_access_token if @portainer_access_token
|
||||
@portainer_access_token = providers.find_by(provider: "portainer")&.access_token
|
||||
end
|
||||
|
||||
def needs_portainer_credential?(account)
|
||||
account.stack_manager&.portainer? &&
|
||||
account.stack_manager.enable_role_based_access_control? &&
|
||||
portainer_access_token.blank?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
20
app/policies/account_policy.rb
Normal file
20
app/policies/account_policy.rb
Normal file
@@ -0,0 +1,20 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AccountPolicy < ApplicationPolicy
|
||||
def admin?
|
||||
account_admin?
|
||||
end
|
||||
|
||||
def manage_stack_manager?
|
||||
account_admin?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def account_admin?
|
||||
return false unless record
|
||||
|
||||
account_user = AccountUser.find_by(user: user, account: record)
|
||||
account_user&.admin?
|
||||
end
|
||||
end
|
||||
@@ -1,9 +1,9 @@
|
||||
class Git::Client
|
||||
def self.from_provider(provider:, repository_url:)
|
||||
if provider.github?
|
||||
Git::Github::Client.new(access_token: provider.access_token, repository_url:)
|
||||
Git::Github::Client.new(access_token: provider.access_token, repository_url:, api_base_url: provider.api_base_url)
|
||||
elsif provider.gitlab?
|
||||
Git::Gitlab::Client.new(access_token: provider.access_token, repository_url:)
|
||||
Git::Gitlab::Client.new(access_token: provider.access_token, repository_url:, api_base_url: provider.api_base_url)
|
||||
else
|
||||
raise "Unsupported Git provider: #{provider}"
|
||||
end
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user