fix(environments): create k8s specific edge agent before connecting [r8s-438] (#1088)

Merging because this change is unrelated to the failing kubernetes/tests/helm-oci.spec.ts tests
pull/12191/merge
Ali 2025-08-25 09:32:10 +12:00 committed by GitHub
parent 2ce8788487
commit 35aa525bd2
6 changed files with 288 additions and 7 deletions

View File

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

View File

@ -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)
})
}
}

View File

@ -57,8 +57,10 @@
<information-panel ng-if="state.kubernetesEndpoint && (!state.edgeEndpoint || state.edgeAssociated)" title-text="Kubernetes features configuration">
<span class="small text-muted vertical-center">
<pr-icon icon="'wrench'" mode="'primary'"></pr-icon>
You should configure the features available in this Kubernetes environment in the
<a ui-sref="kubernetes.cluster.setup({endpointId: endpoint.Id})">Kubernetes configuration</a> view.
<div>
You should configure the features available in this Kubernetes environment in the
<a ui-sref="kubernetes.cluster.setup({endpointId: endpoint.Id})">Kubernetes configuration</a> view.
</div>
</span>
</information-panel>
</div>

View File

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

View File

@ -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(() => (
<WizardKubernetes onCreate={() => {}} />
));
return render(<Wrapped />);
}
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);
});
});
});

View File

@ -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':