diff --git a/api/http/handler/endpoints/endpoint_create.go b/api/http/handler/endpoints/endpoint_create.go index 03f2bee44..05875063e 100644 --- a/api/http/handler/endpoints/endpoint_create.go +++ b/api/http/handler/endpoints/endpoint_create.go @@ -372,10 +372,16 @@ func (handler *Handler) createEdgeAgentEndpoint(tx dataservices.DataStoreTx, pay edgeKey := handler.ReverseTunnelService.GenerateEdgeKey(payload.URL, portainerHost, endpointID) endpoint := &portainer.Endpoint{ - ID: portainer.EndpointID(endpointID), - Name: payload.Name, - URL: portainerHost, - Type: portainer.EdgeAgentOnDockerEnvironment, + ID: portainer.EndpointID(endpointID), + Name: payload.Name, + URL: portainerHost, + Type: func() portainer.EndpointType { + // an empty container engine means that the endpoint is a Kubernetes endpoint + if payload.ContainerEngine == "" { + return portainer.EdgeAgentOnKubernetesEnvironment + } + return portainer.EdgeAgentOnDockerEnvironment + }(), ContainerEngine: payload.ContainerEngine, GroupID: portainer.EndpointGroupID(payload.GroupID), Gpus: payload.Gpus, diff --git a/api/http/handler/endpoints/endpoint_create_test.go b/api/http/handler/endpoints/endpoint_create_test.go new file mode 100644 index 000000000..767aa4f33 --- /dev/null +++ b/api/http/handler/endpoints/endpoint_create_test.go @@ -0,0 +1,172 @@ +package endpoints + +import ( + "net/http" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/chisel" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/portainer/portainer/pkg/fips" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// EE-only kubeconfig validation tests removed for CE + +func TestSaveEndpointAndUpdateAuthorizations(t *testing.T) { + _, store := datastore.MustNewTestStore(t, true, false) + + endpointGroup := &portainer.EndpointGroup{ + ID: 1, + Name: "test-endpoint-group", + } + + err := store.EndpointGroup().Create(endpointGroup) + require.NoError(t, err) + + h := &Handler{ + DataStore: store, + } + + testCases := []struct { + name string + endpointType portainer.EndpointType + expectRelation bool + }{ + { + name: "create azure environment, expect no relation to be created", + endpointType: portainer.AzureEnvironment, + expectRelation: false, + }, + { + name: "create edge agent environment, expect relation to be created", + endpointType: portainer.EdgeAgentOnDockerEnvironment, + expectRelation: true, + }, + { + name: "create kubernetes environment, expect no relation to be created", + endpointType: portainer.KubernetesLocalEnvironment, + expectRelation: false, + }, + { + name: "create kubeconfig environment, expect no relation to be created", + endpointType: portainer.AgentOnKubernetesEnvironment, + expectRelation: false, + }, + { + name: "create agent docker environment, expect no relation to be created", + endpointType: portainer.AgentOnDockerEnvironment, + expectRelation: false, + }, + { + name: "create unsecured environment, expect no relation to be created", + endpointType: portainer.DockerEnvironment, + expectRelation: false, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + endpoint := &portainer.Endpoint{ + ID: portainer.EndpointID(store.Endpoint().GetNextIdentifier()), + Type: testCase.endpointType, + GroupID: portainer.EndpointGroupID(endpointGroup.ID), + } + + err := h.saveEndpointAndUpdateAuthorizations(store, endpoint) + require.NoError(t, err) + + relation, relationErr := store.EndpointRelation().EndpointRelation(endpoint.ID) + if testCase.expectRelation { + require.NoError(t, relationErr) + require.NotNil(t, relation) + } else { + require.Error(t, relationErr) + require.True(t, store.IsErrObjectNotFound(relationErr)) + require.Nil(t, relation) + } + }) + } +} + +func TestCreateEndpointFailure(t *testing.T) { + fips.InitFIPS(false) + + _, store := datastore.MustNewTestStore(t, true, false) + + h := NewHandler(testhelpers.NewTestRequestBouncer()) + h.DataStore = store + + payload := &endpointCreatePayload{ + Name: "Test Endpoint", + EndpointCreationType: agentEnvironment, + TLS: true, + TLSCertFile: []byte("invalid data"), + TLSKeyFile: []byte("invalid data"), + } + + endpoint, httpErr := h.createEndpoint(store, payload) + require.NotNil(t, httpErr) + require.Equal(t, http.StatusInternalServerError, httpErr.StatusCode) + require.Nil(t, endpoint) +} + +func TestCreateEdgeAgentEndpoint_ContainerEngineMapping(t *testing.T) { + fips.InitFIPS(false) + + _, store := datastore.MustNewTestStore(t, true, false) + + // required group for save flow + endpointGroup := &portainer.EndpointGroup{ID: 1, Name: "test-group"} + err := store.EndpointGroup().Create(endpointGroup) + require.NoError(t, err) + + h := &Handler{ + DataStore: store, + ReverseTunnelService: chisel.NewService(store, nil, nil), + } + + tests := []struct { + name string + engine string + wantType portainer.EndpointType + }{ + { + name: "empty engine -> EdgeAgentOnKubernetesEnvironment", + engine: "", + wantType: portainer.EdgeAgentOnKubernetesEnvironment, + }, + { + name: "docker engine -> EdgeAgentOnDockerEnvironment", + engine: portainer.ContainerEngineDocker, + wantType: portainer.EdgeAgentOnDockerEnvironment, + }, + { + name: "podman engine -> EdgeAgentOnDockerEnvironment", + engine: portainer.ContainerEnginePodman, + wantType: portainer.EdgeAgentOnDockerEnvironment, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + payload := &endpointCreatePayload{ + Name: "edge-endpoint", + EndpointCreationType: edgeAgentEnvironment, + ContainerEngine: tc.engine, + GroupID: 1, + URL: "https://portainer.example:9443", + } + + ep, httpErr := h.createEdgeAgentEndpoint(store, payload) + require.Nil(t, httpErr) + require.NotNil(t, ep) + + assert.Equal(t, tc.wantType, ep.Type) + assert.Equal(t, tc.engine, ep.ContainerEngine) + }) + } +} diff --git a/app/portainer/views/endpoints/edit/endpoint.html b/app/portainer/views/endpoints/edit/endpoint.html index 3fcdc4d9a..52bc36f2c 100644 --- a/app/portainer/views/endpoints/edit/endpoint.html +++ b/app/portainer/views/endpoints/edit/endpoint.html @@ -57,8 +57,10 @@ - You should configure the features available in this Kubernetes environment in the - Kubernetes configuration view. + + You should configure the features available in this Kubernetes environment in the + Kubernetes configuration view. + diff --git a/app/react/portainer/environments/types.ts b/app/react/portainer/environments/types.ts index 9279e0d4c..4f3fff120 100644 --- a/app/react/portainer/environments/types.ts +++ b/app/react/portainer/environments/types.ts @@ -205,6 +205,8 @@ export enum EnvironmentCreationTypes { export enum ContainerEngine { Docker = 'docker', Podman = 'podman', + // an empty container engine means that the endpoint is a Kubernetes endpoint + Kubernetes = '', } export enum PlatformType { diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardKubernetes/WizardKubernetes.test.tsx b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardKubernetes/WizardKubernetes.test.tsx new file mode 100644 index 000000000..be16f9816 --- /dev/null +++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardKubernetes/WizardKubernetes.test.tsx @@ -0,0 +1,94 @@ +import { HttpResponse } from 'msw'; +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; +import { server, http } from '@/setup-tests/server'; + +import { WizardKubernetes } from './WizardKubernetes'; + +function renderComponent() { + // minimal settings so EdgeAgentForm can render + server.use( + http.get('/api/settings', () => + HttpResponse.json({ + AgentSecret: 'secret', + EdgePortainerUrl: 'https://example.com', + Edge: { + PingInterval: 60, + SnapshotInterval: 60, + CommandInterval: 60, + AsyncMode: false, + TunnelServerAddress: 'portainer.test:8000', + }, + }) + ), + http.get('/api/custom_templates', () => HttpResponse.json([])), + http.get('/api/system/status', () => + HttpResponse.json({ Version: '2.19.0', Edition: 'CE', InstanceID: '1' }) + ), + http.get('/api/endpoints', () => + HttpResponse.json([], { + headers: { + 'x-total-available': '0', + 'x-total-count': '0', + }, + }) + ) + ); + + const Wrapped = withTestQueryProvider(() => ( + {}} /> + )); + return render(); +} + +describe('WizardKubernetes', () => { + test('renders Edge Agent Standard form when selected', async () => { + const { getByText, queryByTestId, findByTestId } = renderComponent(); + + // select Edge Agent Standard + await userEvent.click(getByText('Edge Agent Standard')); + + // verify submit button is visible (smallest sanity check for setup) + await expect( + findByTestId('edge-agent-form-submit-button') + ).resolves.toBeVisible(); + expect( + queryByTestId('endpointCreate-portainerServerUrlInput') + ).toBeInTheDocument(); + }); + + test('submits ContainerEngine as empty string for Kubernetes', async () => { + let observedEntries: Array<[string, string]> = []; + + server.use( + http.post('/api/endpoints', async ({ request }) => { + const form = await request.formData(); + observedEntries = Array.from(form.entries()).map(([key, value]) => [ + key, + typeof value === 'string' ? value : 'binary', + ]); + return HttpResponse.json({}); + }) + ); + + const { getByText, getByTestId, findByTestId } = renderComponent(); + + await userEvent.click(getByText('Edge Agent Standard')); + + await userEvent.type(getByTestId('environmentCreate-nameInput'), 'k8s-env'); + + const submitBtn = await findByTestId('edge-agent-form-submit-button'); + await waitFor(() => expect(submitBtn).not.toBeDisabled()); + await userEvent.click(submitBtn); + + // assert POST happened and ContainerEngine key exists with empty string + await waitFor(() => { + expect(observedEntries.length).toBeGreaterThan(0); + expect( + observedEntries.some(([k, v]) => k === 'ContainerEngine' && v === '') + ).toBe(true); + }); + }); +}); diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardKubernetes/WizardKubernetes.tsx b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardKubernetes/WizardKubernetes.tsx index def305bdb..e6c5c7334 100644 --- a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardKubernetes/WizardKubernetes.tsx +++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardKubernetes/WizardKubernetes.tsx @@ -2,7 +2,10 @@ import { useState } from 'react'; import { Zap, UploadCloud } from 'lucide-react'; import _ from 'lodash'; -import { Environment } from '@/react/portainer/environments/types'; +import { + ContainerEngine, + Environment, +} from '@/react/portainer/environments/types'; import { commandsTabs } from '@/react/edge/components/EdgeScriptForm/scripts'; import { FeatureId } from '@/react/portainer/feature-flags/enums'; import { isBE } from '@/react/portainer/feature-flags/feature-flags.service'; @@ -98,6 +101,7 @@ export function WizardKubernetes({ onCreate }: Props) { onCreate(environment, 'kubernetesEdgeAgentStandard') } commands={[{ ...commandsTabs.k8sLinux, label: 'Linux' }]} + containerEngine={ContainerEngine.Kubernetes} /> ); case 'edgeAgentAsync': @@ -108,6 +112,7 @@ export function WizardKubernetes({ onCreate }: Props) { onCreate(environment, 'kubernetesEdgeAgentAsync') } commands={[{ ...commandsTabs.k8sLinux, label: 'Linux' }]} + containerEngine={ContainerEngine.Kubernetes} /> ); case 'kubeconfig':