diff --git a/app/actions/portainer/authenticate.rb b/app/actions/portainer/authenticate.rb
new file mode 100644
index 00000000..23a554ca
--- /dev/null
+++ b/app/actions/portainer/authenticate.rb
@@ -0,0 +1,16 @@
+class Portainer::Authenticate
+ extend LightService::Action
+
+ expects :stack_manager, :user, :auth_code
+ expects :username, default: nil
+
+ executed do |context|
+ stack_manager = context.stack_manager
+ access_token = Portainer::Client.authenticate(
+ auth_code: context.auth_code,
+ username: context.username,
+ provider_url: stack_manager.provider_url
+ )
+ context.user.providers.find_or_initialize_by(provider: "portainer").update!(access_token:)
+ end
+end
diff --git a/app/actions/portainer/kubeconfig.rb b/app/actions/portainer/kubeconfig.rb
new file mode 100644
index 00000000..91c05795
--- /dev/null
+++ b/app/actions/portainer/kubeconfig.rb
@@ -0,0 +1,16 @@
+class Portainer::Kubeconfig
+ extend LightService::Action
+
+ expects :cluster, :user
+ promises :kubeconfig
+
+ executed do |context|
+ portainer_url = context.user.accounts.first.stack_manager.provider_url
+ portainer_client = Portainer::Client.new(portainer_url, context.user.portainer_jwt)
+ context.kubeconfig = portainer_client.get("/api/kubernetes/config?ids[]=#{context.cluster.external_id}")
+ rescue Portainer::Client::UnauthorizedError
+ context.fail_and_return!("Current user is unauthorized: #{context.user.email}")
+ rescue Portainer::Client::PermissionDeniedError
+ context.fail_and_return!("Current user is not authorized to access this cluster: #{context.user.email}")
+ end
+end
diff --git a/app/actions/portainer/sync_clusters.rb b/app/actions/portainer/sync_clusters.rb
new file mode 100644
index 00000000..f7e5bd38
--- /dev/null
+++ b/app/actions/portainer/sync_clusters.rb
@@ -0,0 +1,22 @@
+class Portainer::SyncClusters
+ extend LightService::Action
+
+ expects :user, :account
+ promises :clusters
+
+ executed do |context|
+ portainer_url = context.account.stack_manager.provider_url
+ portainer_client = Portainer::Client.new(portainer_url, context.user.portainer_jwt)
+ response = portainer_client.get("/api/endpoints")
+ clusters = []
+ response.each do |cluster|
+ clusters << context.account.clusters.create!(name: cluster["Name"], external_id: cluster["Id"])
+ end
+
+ context.clusters = clusters
+ rescue Portainer::Client::UnauthorizedError
+ context.fail_and_return!("Current user is unauthorized: #{context.user.email}")
+ rescue Portainer::Client::PermissionDeniedError
+ context.fail_and_return!("Current user is not authorized to access this cluster: #{context.user.email}")
+ end
+end
diff --git a/app/controllers/local/pages_controller.rb b/app/controllers/local/pages_controller.rb
index 8b6803ad..ce522c00 100644
--- a/app/controllers/local/pages_controller.rb
+++ b/app/controllers/local/pages_controller.rb
@@ -30,4 +30,33 @@ class Local::PagesController < ApplicationController
flash[:error] = "Invalid personal access token"
redirect_to github_token_path
end
+
+ def portainer_configuration
+ end
+
+ def update_portainer_configuration
+ stack_manager = current_account.stack_manager || current_account.build_stack_manager
+ stack_manager.update!(provider_url: params[:provider_url])
+ result = Portainer::Authenticate.execute(stack_manager:, user: current_user, auth_code: params[:password], username: params[:username])
+ if result.success?
+ flash[:notice] = "The Portainer configuration has been updated"
+ else
+ flash[:error] = result.message
+ end
+ redirect_to root_path
+ end
+
+ def github_oauth
+ result = Portainer::Authenticate.execute(
+ stack_manager: current_account.stack_manager,
+ user: current_user,
+ auth_code: params[:code]
+ )
+ if result.success?
+ flash[:notice] = "The Portainer configuration has been updated"
+ else
+ flash[:error] = result.message
+ end
+ redirect_to root_path
+ end
end
diff --git a/app/jobs/clusters/sync_clusters_job.rb b/app/jobs/clusters/sync_clusters_job.rb
new file mode 100644
index 00000000..c54baf8e
--- /dev/null
+++ b/app/jobs/clusters/sync_clusters_job.rb
@@ -0,0 +1,7 @@
+class Clusters::SyncClustersJob < ApplicationJob
+ queue_as :default
+
+ def perform(user, account)
+ Portainer::SyncClusters.execute(account:, user:)
+ end
+end
diff --git a/app/models/provider.rb b/app/models/provider.rb
index 2f242693..b7f19f8c 100644
--- a/app/models/provider.rb
+++ b/app/models/provider.rb
@@ -29,6 +29,7 @@ class Provider < ApplicationRecord
GITHUB_PROVIDER = "github"
CUSTOM_REGISTRY_PROVIDER = "container_registry"
GITLAB_PROVIDER = "gitlab"
+ PORTAINER_PROVIDER = "portainer"
GIT_TYPE = "git"
REGISTRY_TYPE = "registry"
PROVIDER_TYPES = {
diff --git a/app/models/user.rb b/app/models/user.rb
index b7239deb..8ae90daa 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -48,6 +48,11 @@ 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
+ end
+
private
def downcase_email
diff --git a/app/services/k8/client.rb b/app/services/k8/client.rb
index 49c3ea17..19e10608 100644
--- a/app/services/k8/client.rb
+++ b/app/services/k8/client.rb
@@ -78,6 +78,8 @@ module K8
def build_kubeconfig(kubeconfig_string)
if kubeconfig_string.is_a?(String)
load_kubeconfig(kubeconfig_string)
+ elsif kubeconfig_string.nil?
+ K8Stack.fetch_kubeconfig(@connection.cluster, @connection.user)
else
kubeconfig_string
end
diff --git a/app/services/k8/connection.rb b/app/services/k8/connection.rb
index 43c4a28b..aca99c93 100644
--- a/app/services/k8/connection.rb
+++ b/app/services/k8/connection.rb
@@ -6,6 +6,11 @@ class K8::Connection
end
def kubeconfig
- cluster.kubeconfig
+ # If the cluster has a kubeconfig, use it.
+ if cluster.kubeconfig.present?
+ cluster.kubeconfig
+ else
+ K8Stack.fetch_kubeconfig(cluster, user)
+ end
end
end
diff --git a/app/services/k8_stack.rb b/app/services/k8_stack.rb
new file mode 100644
index 00000000..8778af64
--- /dev/null
+++ b/app/services/k8_stack.rb
@@ -0,0 +1,15 @@
+class K8Stack
+ # The stack class is just to make sure that we don't hard couple to portainer
+ def self.fetch_kubeconfig(cluster, user)
+ stack_manager = user.accounts.first.stack_manager
+ if stack_manager&.portainer?
+ portainer_jwt = user.portainer_jwt
+ portainer_url = stack_manager.provider_url
+ raise "No Portainer JWT found" if portainer_jwt.blank?
+ raise "No Portainer URL found" if portainer_url.blank?
+ Portainer::Client.new(portainer_url, portainer_jwt).get_kubernetes_config
+ else
+ raise "Unsupported Kubernetes provider: #{stack_manager&.stack_manager_type}"
+ end
+ end
+end
diff --git a/app/services/portainer/client.rb b/app/services/portainer/client.rb
new file mode 100644
index 00000000..54d558ad
--- /dev/null
+++ b/app/services/portainer/client.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+require 'httparty'
+
+module Portainer
+ class Client
+ attr_reader :jwt, :provider_url
+
+ include HTTParty
+
+ default_options.update(verify: false)
+
+ class UnauthorizedError < StandardError; end
+ class PermissionDeniedError < StandardError; end
+
+ def initialize(provider_url, jwt)
+ @jwt = jwt
+ @provider_url = provider_url
+ end
+
+ def get_kubernetes_config
+ fetch_wrapper do
+ self.class.get(
+ "#{provider_url}/api/kubernetes/config",
+ headers: headers
+ )
+ end
+ end
+
+ def self.authenticate(auth_code:, username: nil, provider_url:)
+ response = if username.present?
+ post(
+ "#{provider_url}/api/auth",
+ headers: { 'Content-Type' => 'application/json' },
+ body: {
+ username: username,
+ password: auth_code
+ }.to_json
+ )
+ else
+ post(
+ "#{provider_url}/api/auth/oauth/validate",
+ headers: { 'Content-Type' => 'application/json' },
+ body: { code: auth_code }.to_json
+ )
+ end
+
+ response.parsed_response['jwt'] if response.success?
+ end
+
+ def get(path)
+ fetch_wrapper do
+ self.class.get("#{provider_url}#{path}", headers:)
+ end
+ end
+
+ private
+
+ def headers
+ @headers ||= {
+ 'Authorization' => "Bearer #{jwt}",
+ 'Content-Type' => 'application/json'
+ }
+ end
+
+ def fetch_wrapper(&block)
+ response = yield
+
+ raise UnauthorizedError, "Unauthorized to access Portainer" if response.code == 401
+ raise PermissionDeniedError, "Permission denied to access Portainer" if response.code == 403
+
+ if response.success?
+ response.parsed_response
+ else
+ raise "Failed to fetch from Portainer: #{response.code} #{response.body}"
+ end
+ end
+ end
+end
diff --git a/app/views/clusters/index.html.erb b/app/views/clusters/index.html.erb
index b54f824c..91e9d0da 100644
--- a/app/views/clusters/index.html.erb
+++ b/app/views/clusters/index.html.erb
@@ -16,6 +16,14 @@
New Cluster
<% end %>
+ <% if current_account.stack_manager&.portainer? %>
+ <%= link_to sync_clusters_path do %>
+
+ <% end %>
+ <% end %>
<%= tag.div id: ("clusters" if @pagy.pages == 1) do %>
diff --git a/app/views/local/pages/portainer_configuration.html.erb b/app/views/local/pages/portainer_configuration.html.erb
new file mode 100644
index 00000000..2e3749be
--- /dev/null
+++ b/app/views/local/pages/portainer_configuration.html.erb
@@ -0,0 +1,33 @@
+
+
Enter your Portainer URL below.
+
+ <%= form_with url: portainer_configuration_path, model: current_account.stack_manager, method: :put do |form| %>
+
+ <%= form.label :provider_url, "Portainer URL" %>
+ <%= form.text_field :provider_url, class: "input input-bordered w-full max-w-xs", placeholder: "http://portainer.portainer.svc.cluster.local:9000" %>
+
+
+
Login to Portainer
+
+ <%= form.label :username, "Username" %>
+ <%= form.text_field :username, class: "input input-bordered w-full max-w-xs" %>
+
+
+ <%= form.label :password, "Password" %>
+ <%= form.text_field :password, type: "password", class: "input input-bordered w-full max-w-xs", data: { toggle_password_target: "input" } %>
+
+
+
or
+
+ <%= link_to "Connect via Github", Git::Github::UrlHelper.authorize_url, class: "btn btn-primary" %>
+
+
+ <% end %>
+
+
diff --git a/config/routes.rb b/config/routes.rb
index 45fa352e..ef5952a1 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -118,6 +118,8 @@ Rails.application.routes.draw do
if Rails.application.config.local_mode
get "/github_token", to: "local/pages#github_token"
put "/github_token", to: "local/pages#update_github_token"
+ get "/portainer_configuration", to: "local/pages#portainer_configuration"
+ put "/portainer_configuration", to: "local/pages#update_portainer_configuration"
get "/github_oauth", to: "local/pages#github_oauth"
root to: "projects#index"
else
diff --git a/lib/tasks/portainer.rake b/lib/tasks/portainer.rake
new file mode 100644
index 00000000..5084e512
--- /dev/null
+++ b/lib/tasks/portainer.rake
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+require 'httparty'
+require 'yaml'
+require 'tempfile'
+
+PORTAINER_URL = "https://portainer.portainer.svc.cluster.local:9443"
+
+namespace :portainer do
+ desc 'Run Portainer task'
+ task run: :environment do
+ jwt = Portainer::Client.authenticate(
+ provider_url: PORTAINER_URL,
+ username: 'admin',
+ auth_code: ENV['PORTAINER_PASSWORD']
+ )
+
+ if jwt.present?
+ puts "JWT: #{jwt}"
+
+ # Get Kubernetes config
+ config_response = Portainer::Client.new(PORTAINER_URL, jwt).get_kubernetes_config
+
+ if config_response.present?
+ config_yaml = config_response.to_yaml
+
+ # Save to temp file
+ temp_file = Tempfile.new([ 'kubeconfig', '.yaml' ])
+ temp_file.write(config_yaml)
+ temp_file.close
+
+ puts "Kubeconfig saved to: #{temp_file.path}"
+
+ # Run kubectl command with the temp kubeconfig
+ output = `KUBECONFIG=#{temp_file.path} kubectl get pods 2>&1`
+ puts "\nKubectl output:"
+ puts output
+
+ # Clean up temp file
+ temp_file.unlink
+
+ puts "\nSUCCESSFULLY REACHED CLUSTER VIA PORTAINER"
+ else
+ puts "Error getting config: #{config_response.body}"
+ end
+ else
+ puts "Error: #{response.code}"
+ puts response.body
+ end
+ end
+end
diff --git a/spec/actions/portainer/authenticate_spec.rb b/spec/actions/portainer/authenticate_spec.rb
new file mode 100644
index 00000000..548c31a3
--- /dev/null
+++ b/spec/actions/portainer/authenticate_spec.rb
@@ -0,0 +1,15 @@
+require 'rails_helper'
+require 'support/shared_contexts/with_portainer'
+
+RSpec.describe Portainer::Authenticate do
+ include_context 'with portainer'
+ let(:user) { create(:user) }
+ let(:stack_manager) { create(:stack_manager) }
+ let(:auth_code) { 'auth_code' }
+
+ it 'can authenticate with portainer' do
+ result = described_class.execute(user:, stack_manager:, auth_code:)
+ expect(result).to be_success
+ expect(user.providers.first.access_token).to eql('jwt')
+ end
+end
diff --git a/spec/actions/portainer/kubeconfig_spec.rb b/spec/actions/portainer/kubeconfig_spec.rb
new file mode 100644
index 00000000..70d2953d
--- /dev/null
+++ b/spec/actions/portainer/kubeconfig_spec.rb
@@ -0,0 +1,18 @@
+require 'rails_helper'
+require 'support/shared_contexts/with_portainer'
+
+RSpec.describe Portainer::Kubeconfig do
+ let(:account) { create(:account) }
+ let(:cluster) { create(:cluster, account:) }
+ let!(:provider) { create(:provider, :portainer, user: account.owner) }
+ let!(:stack_manager) { create(:stack_manager, account:) }
+
+ context 'gets kubeconfig from portainer' do
+ include_context 'with portainer'
+ it 'can get kubeconfig from portainer' do
+ result = described_class.execute(cluster:, user: account.owner)
+ expect(result).to be_success
+ expect(result.kubeconfig).to eql(JSON.parse(File.read(Rails.root.join(*%w[spec resources portainer kubeconfig.json]))))
+ end
+ end
+end
diff --git a/spec/actions/portainer/sync_clusters_spec.rb b/spec/actions/portainer/sync_clusters_spec.rb
new file mode 100644
index 00000000..38e4f325
--- /dev/null
+++ b/spec/actions/portainer/sync_clusters_spec.rb
@@ -0,0 +1,19 @@
+require 'rails_helper'
+require 'support/shared_contexts/with_portainer'
+
+RSpec.describe Portainer::SyncClusters do
+ let(:account) { create(:account) }
+ let!(:provider) { create(:provider, :portainer, user: account.owner) }
+ let!(:stack_manager) { create(:stack_manager, account:) }
+
+ context 'syncs clusters from portainer' do
+ include_context 'with portainer'
+ it 'can sync clusters from portainer' do
+ result = described_class.execute(user: account.owner, account:)
+ expect(result).to be_success
+ expect(result.clusters.count).to eql(2)
+ expect(result.clusters.first.name).to eql('local')
+ expect(result.clusters.last.name).to eql('testing-production')
+ end
+ end
+end
diff --git a/spec/resources/portainer/authenticate.json b/spec/resources/portainer/authenticate.json
new file mode 100644
index 00000000..044af43f
--- /dev/null
+++ b/spec/resources/portainer/authenticate.json
@@ -0,0 +1,3 @@
+{
+ "jwt": "jwt"
+}
diff --git a/spec/resources/portainer/endpoints.json b/spec/resources/portainer/endpoints.json
new file mode 100644
index 00000000..6b735a10
--- /dev/null
+++ b/spec/resources/portainer/endpoints.json
@@ -0,0 +1,259 @@
+[
+ {
+ "Id": 1,
+ "Name": "local",
+ "Type": 5,
+ "ContainerEngine": "",
+ "URL": "https://kubernetes.default.svc",
+ "GroupId": 1,
+ "PublicURL": "",
+ "Gpus": [],
+ "TLSConfig": {
+ "TLS": true,
+ "TLSSkipVerify": true
+ },
+ "AzureCredentials": {
+ "ApplicationID": "",
+ "TenantID": "",
+ "AuthenticationKey": ""
+ },
+ "TagIds": [],
+ "Status": 1,
+ "Snapshots": [],
+ "UserAccessPolicies": {
+ "2": {
+ "RoleId": 4
+ }
+ },
+ "TeamAccessPolicies": {},
+ "EdgeKey": "",
+ "ComposeSyntaxMaxVersion": "3.9",
+ "SecuritySettings": {
+ "allowBindMountsForRegularUsers": true,
+ "allowPrivilegedModeForRegularUsers": true,
+ "allowVolumeBrowserForRegularUsers": false,
+ "allowHostNamespaceForRegularUsers": true,
+ "allowDeviceMappingForRegularUsers": true,
+ "allowStackManagementForRegularUsers": true,
+ "allowContainerCapabilitiesForRegularUsers": true,
+ "allowSysctlSettingForRegularUsers": true,
+ "enableHostManagementFeatures": false
+ },
+ "LastCheckInDate": 0,
+ "QueryDate": 0,
+ "Heartbeat": false,
+ "Edge": {
+ "AsyncMode": false,
+ "PingInterval": 0,
+ "SnapshotInterval": 0,
+ "CommandInterval": 0
+ },
+ "AuthorizedUsers": null,
+ "AuthorizedTeams": null,
+ "Tags": null,
+ "StatusMessage": {
+ "summary": "",
+ "detail": "",
+ "operation": "",
+ "operationStatus": "",
+ "warnings": null
+ },
+ "CloudProvider": null,
+ "Kubernetes": {
+ "Snapshots": [
+ {
+ "Time": 1756319913,
+ "KubernetesVersion": "v1.31.1",
+ "NodeCount": 2,
+ "TotalCPU": 4,
+ "TotalMemory": 8210763776,
+ "DiagnosticsData": null,
+ "PerformanceMetrics": {
+ "CPUUsage": 17,
+ "MemoryUsage": 133,
+ "NetworkUsage": 133383
+ }
+ }
+ ],
+ "Configuration": {
+ "UseLoadBalancer": false,
+ "UseServerMetrics": true,
+ "EnableResourceOverCommit": true,
+ "ResourceOverCommitPercentage": 0,
+ "StorageClasses": [
+ {
+ "Name": "do-block-storage",
+ "AccessModes": ["RWO"],
+ "Provisioner": "dobs.csi.digitalocean.com",
+ "AllowVolumeExpansion": true
+ }
+ ],
+ "IngressClasses": [
+ {
+ "Name": "nginx",
+ "Type": "nginx",
+ "Blocked": false,
+ "BlockedNamespaces": null
+ }
+ ],
+ "RestrictDefaultNamespace": false,
+ "IngressAvailabilityPerNamespace": false,
+ "AllowNoneIngressClass": false,
+ "RestrictSecrets": false,
+ "RestrictStandardUserIngressW": false
+ },
+ "Flags": {
+ "IsServerMetricsDetected": true,
+ "IsServerIngressClassDetected": true,
+ "IsServerStorageDetected": true
+ }
+ },
+ "PostInitMigrations": {
+ "MigrateIngresses": false,
+ "MigrateGPUs": false,
+ "MigrateGateKeeper": false,
+ "MigrateSecretOwners": false
+ },
+ "EdgeCheckinInterval": 5,
+ "Agent": {},
+ "LocalTimeZone": "",
+ "ChangeWindow": {
+ "Enabled": false,
+ "StartTime": "",
+ "EndTime": ""
+ },
+ "DeploymentOptions": null,
+ "EnableImageNotification": false,
+ "EnableGPUManagement": false
+ },
+ {
+ "Id": 2,
+ "Name": "testing-production",
+ "Type": 6,
+ "ContainerEngine": "",
+ "URL": "137.184",
+ "GroupId": 1,
+ "PublicURL": "",
+ "Gpus": null,
+ "TLSConfig": {
+ "TLS": true,
+ "TLSSkipVerify": true
+ },
+ "AzureCredentials": {
+ "ApplicationID": "",
+ "TenantID": "",
+ "AuthenticationKey": ""
+ },
+ "TagIds": [],
+ "Status": 1,
+ "Snapshots": [],
+ "UserAccessPolicies": {},
+ "TeamAccessPolicies": {},
+ "EdgeKey": "",
+ "ComposeSyntaxMaxVersion": "3.9",
+ "SecuritySettings": {
+ "allowBindMountsForRegularUsers": true,
+ "allowPrivilegedModeForRegularUsers": true,
+ "allowVolumeBrowserForRegularUsers": false,
+ "allowHostNamespaceForRegularUsers": true,
+ "allowDeviceMappingForRegularUsers": true,
+ "allowStackManagementForRegularUsers": true,
+ "allowContainerCapabilitiesForRegularUsers": true,
+ "allowSysctlSettingForRegularUsers": true,
+ "enableHostManagementFeatures": false
+ },
+ "LastCheckInDate": 0,
+ "QueryDate": 0,
+ "Heartbeat": false,
+ "Edge": {
+ "AsyncMode": false,
+ "PingInterval": 0,
+ "SnapshotInterval": 0,
+ "CommandInterval": 0
+ },
+ "AuthorizedUsers": null,
+ "AuthorizedTeams": null,
+ "Tags": null,
+ "StatusMessage": {
+ "summary": "",
+ "detail": "",
+ "operation": "",
+ "operationStatus": "",
+ "warnings": null
+ },
+ "CloudProvider": {
+ "Provider": "kubeconfig",
+ "Name": "KubeConfig",
+ "URL": "",
+ "Region": "",
+ "Size": null,
+ "NodeCount": 0,
+ "CPU": null,
+ "RAM": null,
+ "HDD": null,
+ "NetworkID": null,
+ "CredentialID": 1,
+ "ResourceGroup": "",
+ "Tier": "",
+ "PoolName": "",
+ "DNSPrefix": "",
+ "KubernetesVersion": "",
+ "AmiType": null,
+ "InstanceType": null,
+ "NodeVolumeSize": null,
+ "AddonWithArgs": null,
+ "NodeIPs": null,
+ "OfflineInstall": false
+ },
+ "Kubernetes": {
+ "Snapshots": [
+ {
+ "Time": 1756319913,
+ "KubernetesVersion": "",
+ "NodeCount": 0,
+ "TotalCPU": 0,
+ "TotalMemory": 0,
+ "DiagnosticsData": null,
+ "PerformanceMetrics": null
+ }
+ ],
+ "Configuration": {
+ "UseLoadBalancer": false,
+ "UseServerMetrics": false,
+ "EnableResourceOverCommit": true,
+ "ResourceOverCommitPercentage": 20,
+ "StorageClasses": [],
+ "IngressClasses": [],
+ "RestrictDefaultNamespace": false,
+ "IngressAvailabilityPerNamespace": false,
+ "AllowNoneIngressClass": false,
+ "RestrictSecrets": false,
+ "RestrictStandardUserIngressW": false
+ },
+ "Flags": {
+ "IsServerMetricsDetected": true,
+ "IsServerIngressClassDetected": true,
+ "IsServerStorageDetected": true
+ }
+ },
+ "PostInitMigrations": {
+ "MigrateIngresses": false,
+ "MigrateGPUs": false,
+ "MigrateGateKeeper": false,
+ "MigrateSecretOwners": false
+ },
+ "EdgeCheckinInterval": 5,
+ "Agent": {
+ "Version": "2.27.9"
+ },
+ "LocalTimeZone": "",
+ "ChangeWindow": {
+ "Enabled": false,
+ "StartTime": "",
+ "EndTime": ""
+ },
+ "DeploymentOptions": null,
+ "EnableImageNotification": false,
+ "EnableGPUManagement": false
+ }
+]
diff --git a/spec/resources/portainer/kubeconfig.json b/spec/resources/portainer/kubeconfig.json
new file mode 100644
index 00000000..b77abab9
--- /dev/null
+++ b/spec/resources/portainer/kubeconfig.json
@@ -0,0 +1,46 @@
+{
+ "kind": "Config",
+ "apiVersion": "v1",
+ "preferences": {},
+ "clusters": [
+ {
+ "name": "portainer-cluster-local",
+ "cluster": {
+ "server": "https://portainer.portainer.svc.cluster.local:9000/api/endpoints/1/kubernetes",
+ "insecure-skip-tls-verify": true
+ }
+ },
+ {
+ "name": "portainer-cluster-testing-production",
+ "cluster": {
+ "server": "https://portainer.portainer.svc.cluster.local:9000/api/endpoints/2/kubernetes",
+ "insecure-skip-tls-verify": true
+ }
+ }
+ ],
+ "users": [
+ {
+ "name": "portainer-sa-user-9ec6cb2d-de79-402b-aff3-e4e8e40ae0da-4",
+ "user": {
+ "token": "sample_token"
+ }
+ }
+ ],
+ "contexts": [
+ {
+ "name": "portainer-ctx-local",
+ "context": {
+ "cluster": "portainer-cluster-local",
+ "user": "portainer-sa-user-9ec6cb2d-de79-402b-aff3-e4e8e40ae0da-4"
+ }
+ },
+ {
+ "name": "portainer-ctx-testing-production",
+ "context": {
+ "cluster": "portainer-cluster-testing-production",
+ "user": "portainer-sa-user-9ec6cb2d-de79-402b-aff3-e4e8e40ae0da-4"
+ }
+ }
+ ],
+ "current-context": "portainer-ctx-local"
+}
diff --git a/spec/services/k8/connection_spec.rb b/spec/services/k8/connection_spec.rb
index a7218ec3..f673185a 100644
--- a/spec/services/k8/connection_spec.rb
+++ b/spec/services/k8/connection_spec.rb
@@ -1,4 +1,5 @@
require 'rails_helper'
+require 'support/shared_contexts/with_portainer'
RSpec.describe K8::Connection do
let!(:user) { create(:user) }
let(:connection) { described_class.new(cluster, user) }
@@ -11,4 +12,21 @@ RSpec.describe K8::Connection do
end
end
end
+
+ describe 'using the K8Stack' do
+ context 'kubernetes provider is portainer' do
+ include_context 'with portainer'
+ let!(:cluster) { create(:cluster, kubeconfig: nil) }
+ let(:account) { create(:account, owner: user) }
+
+ before do
+ create(:provider, provider: 'portainer', access_token: 'jwt', user:)
+ create(:stack_manager, account:)
+ end
+
+ it 'returns the kubeconfig' do
+ expect(connection.kubeconfig).to eq(JSON.parse(File.read(Rails.root.join(*%w[spec resources portainer kubeconfig.json]))))
+ end
+ end
+ end
end
diff --git a/spec/services/k8_stack_spec.rb b/spec/services/k8_stack_spec.rb
new file mode 100644
index 00000000..5d8b45f7
--- /dev/null
+++ b/spec/services/k8_stack_spec.rb
@@ -0,0 +1,20 @@
+require 'rails_helper'
+require 'support/shared_contexts/with_portainer'
+
+RSpec.describe K8Stack do
+ describe '.fetch_kubeconfig' do
+ include_context 'with portainer'
+ context 'when the stack manager is portainer' do
+ let(:account) { create(:account) }
+ let!(:provider) { create(:provider, :portainer, user: account.owner) }
+ let!(:stack_manager) { create(:stack_manager, :portainer, account:) }
+ let(:cluster) { create(:cluster, account:) }
+
+ subject { described_class.fetch_kubeconfig(cluster, account.owner) }
+
+ it 'fetches the kubeconfig' do
+ expect(subject).to eql(JSON.parse(File.read(Rails.root.join(*%w[spec resources portainer kubeconfig.json]))))
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_contexts/with_portainer.rb b/spec/support/shared_contexts/with_portainer.rb
new file mode 100644
index 00000000..607f13c0
--- /dev/null
+++ b/spec/support/shared_contexts/with_portainer.rb
@@ -0,0 +1,18 @@
+require 'rails_helper'
+RSpec.shared_context 'with portainer' do
+ before do
+ headers = { 'Content-Type' => 'application/json' }
+ WebMock.stub_request(:any, %r{/api/kubernetes/config}).to_return(
+ status: 200, body: File.read(Rails.root.join(*%w[spec resources portainer kubeconfig.json])), headers:
+ )
+ WebMock.stub_request(:any, %r{/api/endpoints}).to_return(
+ status: 200, body: File.read(Rails.root.join(*%w[spec resources portainer endpoints.json])), headers:
+ )
+ WebMock.stub_request(:any, %r{/api/auth/oauth/validate}).to_return(
+ status: 200, body: File.read(Rails.root.join(*%w[spec resources portainer authenticate.json])), headers:
+ )
+ WebMock.stub_request(:any, %r{/api/auth}).to_return(
+ status: 200, body: File.read(Rails.root.join(*%w[spec resources portainer authenticate.json])), headers:
+ )
+ end
+end