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 @@ <% 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