1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-24 07:49:41 +02:00

refactor(azure/aci): migrate create view to react [EE-2188] (#6371)

This commit is contained in:
Chaim Lev-Ari 2022-02-01 19:38:45 +02:00 committed by GitHub
parent 1bb02eea59
commit 6f6f78fbe5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
53 changed files with 1476 additions and 571 deletions

View file

@ -0,0 +1,43 @@
import userEvent from '@testing-library/user-event';
import { UserContext } from '@/portainer/hooks/useUser';
import { UserViewModel } from '@/portainer/models/user';
import { renderWithQueryClient } from '@/react-tools/test-utils';
import { CreateContainerInstanceForm } from './CreateContainerInstanceForm';
jest.mock('@uirouter/react', () => ({
...jest.requireActual('@uirouter/react'),
useCurrentStateAndParams: jest.fn(() => ({
params: { endpointId: 5 },
})),
}));
test('submit button should be disabled when name or image is missing', async () => {
const user = new UserViewModel({ Username: 'user' });
const { findByText, getByText, getByLabelText } = renderWithQueryClient(
<UserContext.Provider value={{ user }}>
<CreateContainerInstanceForm />
</UserContext.Provider>
);
await expect(findByText(/Azure settings/)).resolves.toBeVisible();
const button = getByText(/Deploy the container/);
expect(button).toBeVisible();
expect(button).toBeDisabled();
const nameInput = getByLabelText(/name/i);
userEvent.type(nameInput, 'name');
const imageInput = getByLabelText(/image/i);
userEvent.type(imageInput, 'image');
await expect(findByText(/Deploy the container/)).resolves.toBeEnabled();
expect(nameInput).toHaveValue('name');
userEvent.clear(nameInput);
await expect(findByText(/Deploy the container/)).resolves.toBeDisabled();
});

View file

@ -0,0 +1,219 @@
import { Field, Form, Formik } from 'formik';
import { useCurrentStateAndParams, useRouter } from '@uirouter/react';
import { FormControl } from '@/portainer/components/form-components/FormControl';
import { Input, Select } from '@/portainer/components/form-components/Input';
import { FormSectionTitle } from '@/portainer/components/form-components/FormSectionTitle';
import { LoadingButton } from '@/portainer/components/Button/LoadingButton';
import { InputListError } from '@/portainer/components/form-components/InputList/InputList';
import { AccessControlForm } from '@/portainer/components/accessControlForm';
import { ContainerInstanceFormValues } from '@/azure/types';
import * as notifications from '@/portainer/services/notifications';
import { isAdmin, useUser } from '@/portainer/hooks/useUser';
import { validationSchema } from './CreateContainerInstanceForm.validation';
import { PortMapping, PortsMappingField } from './PortsMappingField';
import { useLoadFormState } from './useLoadFormState';
import {
getSubscriptionLocations,
getSubscriptionResourceGroups,
} from './utils';
import { useCreateInstance } from './useCreateInstanceMutation';
export function CreateContainerInstanceForm() {
const {
params: { endpointId: environmentId },
} = useCurrentStateAndParams();
if (!environmentId) {
throw new Error('endpointId url param is required');
}
const { user } = useUser();
const isUserAdmin = isAdmin(user);
const { initialValues, isLoading, providers, subscriptions, resourceGroups } =
useLoadFormState(environmentId, isUserAdmin);
const router = useRouter();
const { mutateAsync } = useCreateInstance(
resourceGroups,
environmentId,
user?.Id
);
if (isLoading) {
return null;
}
return (
<Formik<ContainerInstanceFormValues>
initialValues={initialValues}
validationSchema={() => validationSchema(isUserAdmin)}
onSubmit={onSubmit}
validateOnMount
validateOnChange
enableReinitialize
>
{({
errors,
handleSubmit,
isSubmitting,
isValid,
values,
setFieldValue,
}) => (
<Form className="form-horizontal" onSubmit={handleSubmit} noValidate>
<FormSectionTitle>Azure settings</FormSectionTitle>
<FormControl
label="Subscription"
inputId="subscription-input"
errors={errors.subscription}
>
<Field
name="subscription"
as={Select}
id="subscription-input"
options={subscriptions}
/>
</FormControl>
<FormControl
label="Resource group"
inputId="resourceGroup-input"
errors={errors.resourceGroup}
>
<Field
name="resourceGroup"
as={Select}
id="resourceGroup-input"
options={getSubscriptionResourceGroups(
values.subscription,
resourceGroups
)}
/>
</FormControl>
<FormControl
label="Location"
inputId="location-input"
errors={errors.location}
>
<Field
name="location"
as={Select}
id="location-input"
options={getSubscriptionLocations(values.subscription, providers)}
/>
</FormControl>
<FormSectionTitle>Container configuration</FormSectionTitle>
<FormControl label="Name" inputId="name-input" errors={errors.name}>
<Field
name="name"
as={Input}
id="name-input"
placeholder="e.g. myContainer"
/>
</FormControl>
<FormControl
label="Image"
inputId="image-input"
errors={errors.image}
>
<Field
name="image"
as={Input}
id="image-input"
placeholder="e.g. nginx:alpine"
/>
</FormControl>
<FormControl label="OS" inputId="os-input" errors={errors.os}>
<Field
name="os"
as={Select}
id="os-input"
options={[
{ label: 'Linux', value: 'Linux' },
{ label: 'Windows', value: 'Windows' },
]}
/>
</FormControl>
<PortsMappingField
value={values.ports}
onChange={(value) => setFieldValue('ports', value)}
errors={errors.ports as InputListError<PortMapping>[]}
/>
<div className="form-group">
<div className="col-sm-12 small text-muted">
This will automatically deploy a container with a public IP
address
</div>
</div>
<FormSectionTitle>Container Resources</FormSectionTitle>
<FormControl label="CPU" inputId="cpu-input" errors={errors.cpu}>
<Field
name="cpu"
as={Input}
id="cpu-input"
type="number"
placeholder="1"
/>
</FormControl>
<FormControl
label="Memory"
inputId="cpu-input"
errors={errors.memory}
>
<Field
name="memory"
as={Input}
id="memory-input"
type="number"
placeholder="1"
/>
</FormControl>
<AccessControlForm
formNamespace="accessControl"
onChange={(values) => setFieldValue('accessControl', values)}
values={values.accessControl}
errors={errors.accessControl}
/>
<div className="form-group">
<div className="col-sm-12">
<LoadingButton
disabled={!isValid}
isLoading={isSubmitting}
loadingText="Deployment in progress..."
>
<i className="fa fa-plus space-right" aria-hidden="true" />
Deploy the container
</LoadingButton>
</div>
</div>
</Form>
)}
</Formik>
);
async function onSubmit(values: ContainerInstanceFormValues) {
try {
await mutateAsync(values);
notifications.success('Container successfully created', values.name);
router.stateService.go('azure.containerinstances');
} catch (e) {
notifications.error('Failure', e as Error, 'Unable to create container');
}
}
}

View file

@ -0,0 +1,21 @@
import { object, string, number, boolean } from 'yup';
import { validationSchema as accessControlSchema } from '@/portainer/components/accessControlForm/AccessControlForm.validation';
import { validationSchema as portsSchema } from './PortsMappingField.validation';
export function validationSchema(isAdmin: boolean) {
return object().shape({
name: string().required('Name is required.'),
image: string().required('Image is required.'),
subscription: string().required('Subscription is required.'),
resourceGroup: string().required('Resource group is required.'),
location: string().required('Location is required.'),
os: string().oneOf(['Linux', 'Windows']),
cpu: number().positive(),
memory: number().positive(),
allocatePublicIP: boolean(),
ports: portsSchema(),
accessControl: accessControlSchema(isAdmin),
});
}

View file

@ -0,0 +1,13 @@
.item {
display: flex;
flex-direction: column;
position: relative;
}
.item .inputs {
}
.item .errors {
position: absolute;
bottom: -20px;
}

View file

@ -0,0 +1,90 @@
import { ButtonSelector } from '@/portainer/components/form-components/ButtonSelector/ButtonSelector';
import { FormError } from '@/portainer/components/form-components/FormError';
import { InputGroup } from '@/portainer/components/form-components/InputGroup';
import { InputList } from '@/portainer/components/form-components/InputList';
import {
InputListError,
ItemProps,
} from '@/portainer/components/form-components/InputList/InputList';
import styles from './PortsMappingField.module.css';
type Protocol = 'TCP' | 'UDP';
export interface PortMapping {
host: string;
container: string;
protocol: Protocol;
}
interface Props {
value: PortMapping[];
onChange(value: PortMapping[]): void;
errors?: InputListError<PortMapping>[] | string;
}
export function PortsMappingField({ value, onChange, errors }: Props) {
return (
<>
<InputList<PortMapping>
label="Port mapping"
value={value}
onChange={onChange}
addLabel="map additional port"
itemBuilder={() => ({ host: '', container: '', protocol: 'TCP' })}
item={Item}
errors={errors}
/>
{typeof errors === 'string' && (
<div className="form-group col-md-12">
<FormError>{errors}</FormError>
</div>
)}
</>
);
}
function Item({ onChange, item, error }: ItemProps<PortMapping>) {
return (
<div className={styles.item}>
<div className={styles.inputs}>
<InputGroup size="small">
<InputGroup.Addon>host</InputGroup.Addon>
<InputGroup.Input
placeholder="e.g. 80"
value={item.host}
onChange={(e) => handleChange('host', e.target.value)}
/>
</InputGroup>
<span style={{ margin: '0 10px 0 10px' }}>
<i className="fa fa-long-arrow-alt-right" aria-hidden="true" />
</span>
<InputGroup size="small">
<InputGroup.Addon>container</InputGroup.Addon>
<InputGroup.Input
placeholder="e.g. 80"
value={item.container}
onChange={(e) => handleChange('container', e.target.value)}
/>
</InputGroup>
<ButtonSelector<Protocol>
onChange={(value) => handleChange('protocol', value)}
value={item.protocol}
options={[{ value: 'TCP' }, { value: 'UDP' }]}
/>
</div>
{!!error && (
<div className={styles.errors}>
<FormError>{Object.values(error)[0]}</FormError>
</div>
)}
</div>
);
function handleChange(name: string, value: string) {
onChange({ ...item, [name]: value });
}
}

View file

@ -0,0 +1,11 @@
import { array, object, string } from 'yup';
export function validationSchema() {
return array(
object().shape({
host: string().required('host is required'),
container: string().required('container is required'),
protocol: string().oneOf(['TCP', 'UDP']),
})
).min(1, 'At least one port binding is required');
}

View file

@ -0,0 +1 @@
export { CreateContainerInstanceForm } from './CreateContainerInstanceForm';

View file

@ -0,0 +1,61 @@
import { useMutation, useQueryClient } from 'react-query';
import { createContainerGroup } from '@/azure/services/container-groups.service';
import { EnvironmentId } from '@/portainer/environments/types';
import PortainerError from '@/portainer/error';
import {
ContainerGroup,
ContainerInstanceFormValues,
ResourceGroup,
} from '@/azure/types';
import { UserId } from '@/portainer/users/types';
import { applyResourceControl } from '@/portainer/resource-control/resource-control.service';
import { getSubscriptionResourceGroups } from './utils';
export function useCreateInstance(
resourceGroups: {
[k: string]: ResourceGroup[];
},
environmentId: EnvironmentId,
userId?: UserId
) {
const queryClient = useQueryClient();
return useMutation<ContainerGroup, unknown, ContainerInstanceFormValues>(
(values) => {
if (!values.subscription) {
throw new PortainerError('subscription is required');
}
const subscriptionResourceGroup = getSubscriptionResourceGroups(
values.subscription,
resourceGroups
);
const resourceGroup = subscriptionResourceGroup.find(
(r) => r.value === values.resourceGroup
);
if (!resourceGroup) {
throw new PortainerError('resource group not found');
}
return createContainerGroup(
values,
environmentId,
values.subscription,
resourceGroup.label
);
},
{
async onSuccess(containerGroup, values) {
if (!userId) {
throw new Error('missing user id');
}
const resourceControl = containerGroup.Portainer.ResourceControl;
const accessControlData = values.accessControl;
await applyResourceControl(userId, accessControlData, resourceControl);
queryClient.invalidateQueries(['azure', 'container-instances']);
},
}
);
}

View file

@ -0,0 +1,171 @@
import { useQueries, useQuery } from 'react-query';
import { useEffect } from 'react';
import * as notifications from '@/portainer/services/notifications';
import PortainerError from '@/portainer/error';
import { EnvironmentId } from '@/portainer/environments/types';
import { Option } from '@/portainer/components/form-components/Input/Select';
import { getResourceGroups } from '@/azure/services/resource-groups.service';
import { getSubscriptions } from '@/azure/services/subscription.service';
import { getContainerInstanceProvider } from '@/azure/services/provider.service';
import { ContainerInstanceFormValues, Subscription } from '@/azure/types';
import { parseFromResourceControl } from '@/portainer/components/accessControlForm/model';
import {
getSubscriptionLocations,
getSubscriptionResourceGroups,
} from './utils';
export function useLoadFormState(
environmentId: EnvironmentId,
isUserAdmin: boolean
) {
const { subscriptions, isLoading: isLoadingSubscriptions } =
useSubscriptions(environmentId);
const { resourceGroups, isLoading: isLoadingResourceGroups } =
useResourceGroups(environmentId, subscriptions);
const { providers, isLoading: isLoadingProviders } = useProviders(
environmentId,
subscriptions
);
const subscriptionOptions =
subscriptions?.map((s) => ({
value: s.subscriptionId,
label: s.displayName,
})) || [];
const initSubscriptionId = getFirstValue(subscriptionOptions);
const subscriptionResourceGroups = getSubscriptionResourceGroups(
initSubscriptionId,
resourceGroups
);
const subscriptionLocations = getSubscriptionLocations(
initSubscriptionId,
providers
);
const initialValues: ContainerInstanceFormValues = {
name: '',
location: getFirstValue(subscriptionLocations),
subscription: initSubscriptionId,
resourceGroup: getFirstValue(subscriptionResourceGroups),
image: '',
os: 'Linux',
memory: 1,
cpu: 1,
ports: [{ container: '80', host: '80', protocol: 'TCP' }],
allocatePublicIP: true,
accessControl: parseFromResourceControl(isUserAdmin),
};
return {
isUserAdmin,
initialValues,
subscriptions: subscriptionOptions,
resourceGroups,
providers,
isLoading:
isLoadingProviders || isLoadingResourceGroups || isLoadingSubscriptions,
};
function getFirstValue<T extends string | number>(arr: Option<T>[]) {
if (arr.length === 0) {
return undefined;
}
return arr[0].value;
}
}
function useSubscriptions(environmentId: EnvironmentId) {
const { data, isError, error, isLoading } = useQuery(
'azure.subscriptions',
() => getSubscriptions(environmentId)
);
useEffect(() => {
if (isError) {
notifications.error(
'Failure',
error as PortainerError,
'Unable to retrieve Azure resources'
);
}
}, [isError, error]);
return { subscriptions: data || [], isLoading };
}
function useResourceGroups(
environmentId: EnvironmentId,
subscriptions: Subscription[]
) {
const queries = useQueries(
subscriptions.map((subscription) => ({
queryKey: ['azure.resourceGroups', subscription.subscriptionId],
queryFn: () =>
getResourceGroups(environmentId, subscription.subscriptionId),
}))
);
useEffect(() => {
const failedQuery = queries.find((q) => q.error);
if (failedQuery) {
notifications.error(
'Failure',
failedQuery.error as PortainerError,
'Unable to retrieve Azure resources'
);
}
}, [queries]);
return {
resourceGroups: Object.fromEntries(
queries.map((q, index) => [
subscriptions[index].subscriptionId,
q.data || [],
])
),
isLoading: queries.some((q) => q.isLoading),
};
}
function useProviders(
environmentId: EnvironmentId,
subscriptions: Subscription[]
) {
const queries = useQueries(
subscriptions.map((subscription) => ({
queryKey: [
'azure.containerInstanceProvider',
subscription.subscriptionId,
],
queryFn: () =>
getContainerInstanceProvider(
environmentId,
subscription.subscriptionId
),
}))
);
useEffect(() => {
const failedQuery = queries.find((q) => q.error);
if (failedQuery) {
notifications.error(
'Failure',
failedQuery.error as PortainerError,
'Unable to retrieve Azure resources'
);
}
}, [queries]);
return {
providers: Object.fromEntries(
queries.map((q, index) => [subscriptions[index].subscriptionId, q.data])
),
isLoading: queries.some((q) => q.isLoading),
};
}

View file

@ -0,0 +1,35 @@
import { ProviderViewModel } from '@/azure/models/provider';
import { ResourceGroup } from '@/azure/types';
export function getSubscriptionResourceGroups(
subscriptionId?: string,
resourceGroups?: Record<string, ResourceGroup[]>
) {
if (!subscriptionId || !resourceGroups || !resourceGroups[subscriptionId]) {
return [];
}
return resourceGroups[subscriptionId].map(({ name, id }) => ({
value: id,
label: name,
}));
}
export function getSubscriptionLocations(
subscriptionId?: string,
containerInstanceProviders?: Record<string, ProviderViewModel | undefined>
) {
if (!subscriptionId || !containerInstanceProviders) {
return [];
}
const provider = containerInstanceProviders[subscriptionId];
if (!provider) {
return [];
}
return provider.locations.map((location) => ({
value: location,
label: location,
}));
}

View file

@ -0,0 +1,34 @@
import { PageHeader } from '@/portainer/components/PageHeader';
import { Widget, WidgetBody } from '@/portainer/components/widget';
import { r2a } from '@/react-tools/react2angular';
import { CreateContainerInstanceForm } from './CreateContainerInstanceForm';
export function CreateContainerInstanceView() {
return (
<>
<PageHeader
title="Create container instance"
breadcrumbs={[
{ link: 'azure.containerinstances', label: 'Container instances' },
{ label: 'Add container' },
]}
/>
<div className="row">
<div className="col-sm-12">
<Widget>
<WidgetBody>
<CreateContainerInstanceForm />
</WidgetBody>
</Widget>
</div>
</div>
</>
);
}
export const CreateContainerInstanceViewAngular = r2a(
CreateContainerInstanceView,
[]
);

View file

@ -0,0 +1,11 @@
import angular from 'angular';
import { CreateContainerInstanceViewAngular } from './CreateContainerInstanceView';
export const containerInstancesModule = angular
.module('portainer.azure.containerInstances', [])
.component(
'createContainerInstanceView',
CreateContainerInstanceViewAngular
).name;

View file

@ -1,4 +1,8 @@
angular.module('portainer.azure', ['portainer.app']).config([
import angular from 'angular';
import { containerInstancesModule } from './ContainerInstances';
angular.module('portainer.azure', ['portainer.app', containerInstancesModule]).config([
'$stateRegistryProvider',
function ($stateRegistryProvider) {
'use strict';
@ -53,8 +57,7 @@ angular.module('portainer.azure', ['portainer.app']).config([
url: '/new/',
views: {
'content@': {
templateUrl: './views/containerinstances/create/createcontainerinstance.html',
controller: 'AzureCreateContainerInstanceController',
component: 'createContainerInstanceView',
},
},
};

View file

@ -48,48 +48,3 @@ export function ContainerGroupViewModel(data) {
this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl);
}
}
export function CreateContainerGroupRequest(model) {
this.location = model.Location;
var containerPorts = [];
var addressPorts = [];
for (var i = 0; i < model.Ports.length; i++) {
var binding = model.Ports[i];
if (!binding.container || !binding.host) {
continue;
}
containerPorts.push({
port: binding.container,
});
addressPorts.push({
port: binding.host,
protocol: binding.protocol,
});
}
this.properties = {
osType: model.OSType,
containers: [
{
name: model.Name,
properties: {
image: model.Image,
ports: containerPorts,
resources: {
requests: {
cpu: model.CPU,
memoryInGB: model.Memory,
},
},
},
},
],
ipAddress: {
type: model.AllocatePublicIP ? 'Public' : 'Private',
ports: addressPorts,
},
};
}

View file

@ -1,9 +0,0 @@
import _ from 'lodash-es';
export function ContainerInstanceProviderViewModel(data) {
this.Id = data.id;
this.Namespace = data.namespace;
var containerGroupType = _.find(data.resourceTypes, { resourceType: 'containerGroups' });
this.Locations = containerGroupType.locations;
}

View file

@ -0,0 +1,21 @@
import _ from 'lodash-es';
import { ProviderResponse } from '../types';
export interface ProviderViewModel {
id: string;
namespace: string;
locations: string[];
}
export function parseViewModel({
id,
namespace,
resourceTypes,
}: ProviderResponse): ProviderViewModel {
const containerGroupType = _.find(resourceTypes, {
resourceType: 'containerGroups',
});
const { locations = [] } = containerGroupType || {};
return { id, namespace, locations };
}

View file

@ -11,7 +11,6 @@ angular.module('portainer.azure').factory('Subscription', [
'api-version': '2016-06-01',
},
{
query: { method: 'GET' },
get: { method: 'GET', params: { id: '@id' } },
}
);

View file

@ -1,11 +1,12 @@
angular.module('portainer.azure').factory('AzureService', [
'$q',
'Azure',
'SubscriptionService',
'ResourceGroupService',
'ContainerGroupService',
'ProviderService',
function AzureServiceFactory($q, Azure, SubscriptionService, ResourceGroupService, ContainerGroupService, ProviderService) {
import { ResourceGroupViewModel } from '../models/resource_group';
import { SubscriptionViewModel } from '../models/subscription';
import { getResourceGroups } from './resource-groups.service';
import { getSubscriptions } from './subscription.service';
angular.module('portainer.azure').factory('AzureService', AzureService);
/* @ngInject */
export function AzureService($q, Azure, $async, EndpointProvider, ContainerGroupService) {
'use strict';
var service = {};
@ -13,30 +14,33 @@ angular.module('portainer.azure').factory('AzureService', [
return Azure.delete(id, '2018-04-01');
};
service.createContainerGroup = function (model, subscriptionId, resourceGroupName) {
return ContainerGroupService.create(model, subscriptionId, resourceGroupName);
service.subscriptions = async function subscriptions() {
return $async(async () => {
const environmentId = EndpointProvider.endpointID();
const subscriptions = await getSubscriptions(environmentId);
return subscriptions.map((s) => new SubscriptionViewModel(s));
});
};
service.subscriptions = function () {
return SubscriptionService.subscriptions();
};
service.resourceGroups = function resourceGroups(subscriptions) {
return $async(async () => {
return retrieveResourcesForEachSubscription(subscriptions, async (subscriptionId) => {
const environmentId = EndpointProvider.endpointID();
service.containerInstanceProvider = function (subscriptions) {
return retrieveResourcesForEachSubscription(subscriptions, ProviderService.containerInstanceProvider);
};
service.resourceGroups = function (subscriptions) {
return retrieveResourcesForEachSubscription(subscriptions, ResourceGroupService.resourceGroups);
const resourceGroups = await getResourceGroups(environmentId, subscriptionId);
return resourceGroups.map((r) => new ResourceGroupViewModel(r, subscriptionId));
});
});
};
service.containerGroups = function (subscriptions) {
return retrieveResourcesForEachSubscription(subscriptions, ContainerGroupService.containerGroups);
};
service.aggregate = function (resourcesBySubcription) {
service.aggregate = function (resourcesBySubscription) {
var aggregatedResources = [];
Object.keys(resourcesBySubcription).forEach(function (key) {
aggregatedResources = aggregatedResources.concat(resourcesBySubcription[key]);
Object.keys(resourcesBySubscription).forEach(function (key) {
aggregatedResources = aggregatedResources.concat(resourcesBySubscription[key]);
});
return aggregatedResources;
};
@ -68,5 +72,4 @@ angular.module('portainer.azure').factory('AzureService', [
}
return service;
},
]);
}

View file

@ -0,0 +1,78 @@
import { EnvironmentId } from '@/portainer/environments/types';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { ContainerGroup, ContainerInstanceFormValues } from '../types';
export async function createContainerGroup(
model: ContainerInstanceFormValues,
environmentId: EnvironmentId,
subscriptionId: string,
resourceGroupName: string
) {
const payload = transformToPayload(model);
try {
const { data } = await axios.put<ContainerGroup>(
buildUrl(environmentId, subscriptionId, resourceGroupName, model.name),
payload,
{ params: { 'api-version': '2018-04-01' } }
);
return data;
} catch (e) {
throw parseAxiosError(e as Error);
}
}
function buildUrl(
environmentId: EnvironmentId,
subscriptionId: string,
resourceGroupName: string,
containerGroupName: string
) {
return `/endpoints/${environmentId}/azure/subscriptions/${subscriptionId}/resourceGroups/${resourceGroupName}/providers/Microsoft.ContainerInstance/containerGroups/${containerGroupName}`;
}
function transformToPayload(model: ContainerInstanceFormValues) {
const containerPorts = [];
const addressPorts = [];
const ports = model.ports.filter((p) => p.container && p.host);
for (let i = 0; i < ports.length; i += 1) {
const binding = ports[i];
containerPorts.push({
port: binding.container,
});
addressPorts.push({
port: binding.host,
protocol: binding.protocol,
});
}
return {
location: model.location,
properties: {
osType: model.os,
containers: [
{
name: model.name,
properties: {
image: model.image,
ports: containerPorts,
resources: {
requests: {
cpu: model.cpu,
memoryInGB: model.memory,
},
},
},
},
],
ipAddress: {
type: model.allocatePublicIP ? 'Public' : 'Private',
ports: addressPorts,
},
},
};
}

View file

@ -1,4 +1,4 @@
import { ContainerGroupViewModel, CreateContainerGroupRequest } from '../models/container_group';
import { ContainerGroupViewModel } from '../models/container_group';
angular.module('portainer.azure').factory('ContainerGroupService', [
'$q',
@ -30,18 +30,6 @@ angular.module('portainer.azure').factory('ContainerGroupService', [
return new ContainerGroupViewModel(containerGroup);
}
service.create = function (model, subscriptionId, resourceGroupName) {
var payload = new CreateContainerGroupRequest(model);
return ContainerGroup.create(
{
subscriptionId: subscriptionId,
resourceGroupName: resourceGroupName,
containerGroupName: model.Name,
},
payload
).$promise;
};
return service;
},
]);

View file

@ -0,0 +1,29 @@
// import { ContainerInstanceProviderViewModel } from '../models/provider';
import { EnvironmentId } from '@/portainer/environments/types';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { parseViewModel } from '../models/provider';
import { ProviderResponse } from '../types';
import { azureErrorParser } from './utils';
export async function getContainerInstanceProvider(
environmentId: EnvironmentId,
subscriptionId: string
) {
try {
const url = `/endpoints/${environmentId}/azure/subscriptions/${subscriptionId}/providers/Microsoft.ContainerInstance`;
const { data } = await axios.get<ProviderResponse>(url, {
params: { 'api-version': '2018-02-01' },
});
return parseViewModel(data);
} catch (error) {
throw parseAxiosError(
error as Error,
'Unable to retrieve provider',
azureErrorParser
);
}
}

View file

@ -1,27 +0,0 @@
import { ContainerInstanceProviderViewModel } from '../models/provider';
angular.module('portainer.azure').factory('ProviderService', [
'$q',
'Provider',
function ProviderServiceFactory($q, Provider) {
'use strict';
var service = {};
service.containerInstanceProvider = function (subscriptionId) {
var deferred = $q.defer();
Provider.get({ subscriptionId: subscriptionId, providerNamespace: 'Microsoft.ContainerInstance' })
.$promise.then(function success(data) {
var provider = new ContainerInstanceProviderViewModel(data);
deferred.resolve(provider);
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to retrieve provider', err: err });
});
return deferred.promise;
};
return service;
},
]);

View file

@ -0,0 +1,42 @@
import { EnvironmentId } from '@/portainer/environments/types';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { ResourceGroup } from '../types';
import { azureErrorParser } from './utils';
export async function getResourceGroups(
environmentId: EnvironmentId,
subscriptionId: string
) {
try {
const {
data: { value },
} = await axios.get<{ value: ResourceGroup[] }>(
buildUrl(environmentId, subscriptionId),
{ params: { 'api-version': '2018-02-01' } }
);
return value;
} catch (err) {
throw parseAxiosError(
err as Error,
'Unable to retrieve resource groups',
azureErrorParser
);
}
}
function buildUrl(
environmentId: EnvironmentId,
subscriptionId: string,
resourceGroupName?: string
) {
let url = `/endpoints/${environmentId}/azure/subscriptions/${subscriptionId}/resourcegroups`;
if (resourceGroupName) {
url += `/${resourceGroupName}`;
}
return url;
}

View file

@ -7,23 +7,6 @@ angular.module('portainer.azure').factory('ResourceGroupService', [
'use strict';
var service = {};
service.resourceGroups = function (subscriptionId) {
var deferred = $q.defer();
ResourceGroup.query({ subscriptionId: subscriptionId })
.$promise.then(function success(data) {
var resourceGroups = data.value.map(function (item) {
return new ResourceGroupViewModel(item, subscriptionId);
});
deferred.resolve(resourceGroups);
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to retrieve resource groups', err: err });
});
return deferred.promise;
};
service.resourceGroup = resourceGroup;
async function resourceGroup(subscriptionId, resourceGroupName) {
const group = await ResourceGroup.get({ subscriptionId, resourceGroupName }).$promise;

View file

@ -0,0 +1,30 @@
import { EnvironmentId } from '@/portainer/environments/types';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { Subscription } from '../types';
import { azureErrorParser } from './utils';
export async function getSubscriptions(environmentId: EnvironmentId) {
try {
const { data } = await axios.get<{ value: Subscription[] }>(
buildUrl(environmentId)
);
return data.value;
} catch (e) {
throw parseAxiosError(
e as Error,
'Unable to retrieve subscriptions',
azureErrorParser
);
}
}
function buildUrl(environmentId: EnvironmentId, id?: string) {
let url = `/endpoints/${environmentId}/azure/subscriptions?api-version=2016-06-01`;
if (id) {
url += `/${id}`;
}
return url;
}

View file

@ -4,32 +4,11 @@ angular.module('portainer.azure').factory('SubscriptionService', [
'$q',
'Subscription',
function SubscriptionServiceFactory($q, Subscription) {
'use strict';
var service = {};
return { subscription };
service.subscriptions = function () {
var deferred = $q.defer();
Subscription.query({})
.$promise.then(function success(data) {
var subscriptions = data.value.map(function (item) {
return new SubscriptionViewModel(item);
});
deferred.resolve(subscriptions);
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to retrieve subscriptions', err: err });
});
return deferred.promise;
};
service.subscription = subscription;
async function subscription(id) {
const subscription = await Subscription.get({ id }).$promise;
return new SubscriptionViewModel(subscription);
}
return service;
},
]);

View file

@ -0,0 +1,12 @@
import { AxiosError } from 'axios';
export function azureErrorParser(axiosError: AxiosError) {
const message =
(axiosError.response?.data?.error?.message as string) ||
'Failed azure request';
return {
error: new Error(message),
details: message,
};
}

83
app/azure/types.ts Normal file
View file

@ -0,0 +1,83 @@
import { AccessControlFormData } from '@/portainer/components/accessControlForm/model';
import { ResourceControlResponse } from '@/portainer/models/resourceControl/resourceControl';
import { PortMapping } from './ContainerInstances/CreateContainerInstanceForm/PortsMappingField';
type OS = 'Linux' | 'Windows';
export interface ContainerInstanceFormValues {
name: string;
location?: string;
subscription?: string;
resourceGroup?: string;
image: string;
os: OS;
memory: number;
cpu: number;
ports: PortMapping[];
allocatePublicIP: boolean;
accessControl: AccessControlFormData;
}
interface PortainerMetadata {
ResourceControl: ResourceControlResponse;
}
interface Container {
name: string;
properties: {
environmentVariables: unknown[];
image: string;
ports: { port: number }[];
resources: {
cpu: number;
memoryInGB: number;
};
};
}
interface ContainerGroupProperties {
containers: Container[];
instanceView: {
events: unknown[];
state: 'pending' | string;
};
ipAddress: {
dnsNameLabelReusePolicy: string;
ports: { port: number; protocol: 'TCP' | 'UDP' }[];
type: 'Public' | 'Private';
};
osType: OS;
}
export interface ContainerGroup {
id: string;
name: string;
location: string;
type: string;
properties: ContainerGroupProperties;
Portainer: PortainerMetadata;
}
export interface Subscription {
subscriptionId: string;
displayName: string;
}
export interface ResourceGroup {
id: string;
name: string;
location: string;
subscriptionId: string;
}
interface ResourceType {
resourceType: 'containerGroups' | string;
locations: string[];
}
export interface ProviderResponse {
id: string;
namespace: string;
resourceTypes: ResourceType[];
}

View file

@ -1,122 +0,0 @@
import { ContainerGroupDefaultModel } from '../../../models/container_group';
angular.module('portainer.azure').controller('AzureCreateContainerInstanceController', [
'$q',
'$scope',
'$state',
'AzureService',
'Notifications',
'Authentication',
'ResourceControlService',
'FormValidator',
function ($q, $scope, $state, AzureService, Notifications, Authentication, ResourceControlService, FormValidator) {
var allResourceGroups = [];
var allProviders = [];
$scope.state = {
actionInProgress: false,
selectedSubscription: null,
selectedResourceGroup: null,
formValidationError: '',
};
$scope.changeSubscription = function () {
var selectedSubscription = $scope.state.selectedSubscription;
updateResourceGroupsAndLocations(selectedSubscription, allResourceGroups, allProviders);
};
$scope.addPortBinding = function () {
$scope.model.Ports.push({ host: '', container: '', protocol: 'TCP' });
};
$scope.removePortBinding = function (index) {
$scope.model.Ports.splice(index, 1);
};
$scope.create = function () {
var model = $scope.model;
var subscriptionId = $scope.state.selectedSubscription.Id;
var resourceGroupName = $scope.state.selectedResourceGroup.Name;
$scope.state.formValidationError = validateForm(model);
if ($scope.state.formValidationError) {
return false;
}
$scope.state.actionInProgress = true;
AzureService.createContainerGroup(model, subscriptionId, resourceGroupName)
.then(applyResourceControl)
.then(() => {
Notifications.success('Container successfully created', model.Name);
$state.go('azure.containerinstances');
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to create container');
})
.finally(function final() {
$scope.state.actionInProgress = false;
});
};
function applyResourceControl(newResourceGroup) {
const userId = Authentication.getUserDetails().ID;
const resourceControl = newResourceGroup.Portainer.ResourceControl;
const accessControlData = $scope.model.AccessControlData;
return ResourceControlService.applyResourceControl(userId, accessControlData, resourceControl);
}
function validateForm(model) {
if (!model.Ports || !model.Ports.length || model.Ports.every((port) => !port.host || !port.container)) {
return 'At least one port binding is required';
}
const error = FormValidator.validateAccessControl(model.AccessControlData, Authentication.isAdmin());
if (error !== '') {
return error;
}
return null;
}
function updateResourceGroupsAndLocations(subscription, resourceGroups, providers) {
$scope.state.selectedResourceGroup = resourceGroups[subscription.Id][0];
$scope.resourceGroups = resourceGroups[subscription.Id];
var currentSubLocations = providers[subscription.Id].Locations;
$scope.model.Location = currentSubLocations[0];
$scope.locations = currentSubLocations;
}
function initView() {
$scope.model = new ContainerGroupDefaultModel();
AzureService.subscriptions()
.then(function success(data) {
var subscriptions = data;
$scope.state.selectedSubscription = subscriptions[0];
$scope.subscriptions = subscriptions;
return $q.all({
resourceGroups: AzureService.resourceGroups(subscriptions),
containerInstancesProviders: AzureService.containerInstanceProvider(subscriptions),
});
})
.then(function success(data) {
var resourceGroups = data.resourceGroups;
allResourceGroups = resourceGroups;
var containerInstancesProviders = data.containerInstancesProviders;
allProviders = containerInstancesProviders;
var selectedSubscription = $scope.state.selectedSubscription;
updateResourceGroupsAndLocations(selectedSubscription, resourceGroups, containerInstancesProviders);
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve Azure resources');
});
}
initView();
},
]);

View file

@ -1,173 +0,0 @@
<rd-header>
<rd-header-title title-text="Create container instance"></rd-header-title>
<rd-header-content> <a ui-sref="azure.containerinstances">Container instances</a> &gt; Add container </rd-header-content>
</rd-header>
<div class="row">
<div class="col-sm-12">
<rd-widget>
<rd-widget-body>
<form class="form-horizontal" autocomplete="off" name="aciForm">
<div class="col-sm-12 form-section-title"> Azure settings </div>
<!-- subscription-input -->
<div class="form-group">
<label for="azure_subscription" class="col-sm-1 control-label text-left">Subscription</label>
<div class="col-sm-11">
<select
class="form-control"
name="azure_subscription"
ng-model="state.selectedSubscription"
ng-options="subscription.Name for subscription in subscriptions"
ng-change="changeSubscription()"
></select>
</div>
</div>
<!-- !subscription-input -->
<!-- resourcegroup-input -->
<div class="form-group">
<label for="azure_resourcegroup" class="col-sm-1 control-label text-left">Resource group</label>
<div class="col-sm-11">
<select
class="form-control"
name="azure_resourcegroup"
ng-model="state.selectedResourceGroup"
ng-options="resourceGroup.Name for resourceGroup in resourceGroups"
></select>
</div>
</div>
<!-- !resourcegroup-input -->
<!-- location-input -->
<div class="form-group">
<label for="azure_location" class="col-sm-1 control-label text-left">Location</label>
<div class="col-sm-11">
<select class="form-control" name="azure_location" ng-model="model.Location" ng-options="location for location in locations"></select>
</div>
</div>
<!-- !location-input -->
<div class="col-sm-12 form-section-title"> Container configuration </div>
<!-- name-input -->
<div class="form-group">
<label for="container_name" class="col-sm-1 control-label text-left">Name</label>
<div class="col-sm-11">
<input type="text" class="form-control" ng-model="model.Name" name="container_name" placeholder="e.g. myContainer" required />
</div>
</div>
<div class="form-group" ng-show="aciForm.container_name.$invalid">
<div class="col-sm-12 small text-warning">
<div ng-messages="aciForm.container_name.$error">
<p ng-message="required"> <i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Name is required. </p>
</div>
</div>
</div>
<!-- !name-input -->
<!-- image-input -->
<div class="form-group">
<label for="image_name" class="col-sm-1 control-label text-left">Image</label>
<div class="col-sm-11">
<input type="text" class="form-control" ng-model="model.Image" name="image_name" placeholder="e.g. nginx:alpine" required />
</div>
</div>
<div class="form-group" ng-show="aciForm.image_name.$invalid">
<div class="col-sm-12 small text-warning">
<div ng-messages="aciForm.image_name.$error">
<p ng-message="required"> <i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Image is required. </p>
</div>
</div>
</div>
<!-- !image-input -->
<!-- os-input -->
<div class="form-group">
<label for="container_os" class="col-sm-1 control-label text-left">OS</label>
<div class="col-sm-11">
<select class="form-control" ng-model="model.OSType" name="container_os">
<option value="Linux">Linux</option>
<option value="Windows">Windows</option>
</select>
</div>
</div>
<!-- !os-input -->
<!-- port-mapping -->
<div class="form-group">
<div class="col-sm-12">
<label class="control-label text-left">Port mapping</label>
<span class="label label-default interactive" style="margin-left: 10px" ng-click="addPortBinding()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> map additional port
</span>
</div>
<!-- port-mapping-input-list -->
<div class="col-sm-12 form-inline" style="margin-top: 10px">
<div ng-repeat="binding in model.Ports" style="margin-top: 2px">
<!-- host-port -->
<div class="input-group col-sm-4 input-group-sm">
<span class="input-group-addon">host</span>
<input type="text" class="form-control" ng-model="binding.host" placeholder="e.g. 80" />
</div>
<!-- !host-port -->
<span style="margin: 0 10px 0 10px">
<i class="fa fa-long-arrow-alt-right" aria-hidden="true"></i>
</span>
<!-- container-port -->
<div class="input-group col-sm-4 input-group-sm">
<span class="input-group-addon">container</span>
<input type="text" class="form-control" ng-model="binding.container" placeholder="e.g. 80" />
</div>
<!-- !container-port -->
<!-- protocol-actions -->
<div class="input-group col-sm-3 input-group-sm">
<div class="btn-group btn-group-sm">
<label class="btn btn-primary" ng-model="binding.protocol" uib-btn-radio="'TCP'">TCP</label>
<label class="btn btn-primary" ng-model="binding.protocol" uib-btn-radio="'UDP'">UDP</label>
</div>
<button class="btn btn-sm btn-danger" type="button" ng-click="removePortBinding($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
<!-- !protocol-actions -->
</div>
</div>
<!-- !port-mapping-input-list -->
</div>
<!-- !port-mapping -->
<!-- public-ip -->
<div class="form-group">
<div class="col-sm-12 small text-muted">This will automatically deploy a container with a public IP address</div>
</div>
<!-- public-ip -->
<div class="col-sm-12 form-section-title"> Container resources </div>
<!-- cpu-input -->
<div class="form-group">
<label for="container_cpu" class="col-sm-1 control-label text-left">CPU</label>
<div class="col-sm-11">
<input type="number" class="form-control" ng-model="model.CPU" name="container_cpu" placeholder="1" />
</div>
</div>
<!-- !cpu-input -->
<!-- memory-input -->
<div class="form-group">
<label for="container_memory" class="col-sm-1 control-label text-left">Memory</label>
<div class="col-sm-11">
<input type="number" class="form-control" ng-model="model.Memory" name="container_memory" placeholder="1" />
</div>
</div>
<!-- !memory-input -->
<!-- access-control -->
<por-access-control-form form-data="model.AccessControlData"></por-access-control-form>
<!-- !access-control -->
<!-- actions -->
<div class="col-sm-12 form-section-title"> Actions </div>
<div class="form-group">
<div class="col-sm-12">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="state.actionInProgress" ng-click="create()" button-spinner="state.actionInProgress">
<span ng-hide="state.actionInProgress">Deploy the container</span>
<span ng-show="state.actionInProgress">Deployment in progress...</span>
</button>
<span class="text-danger" ng-if="state.formValidationError" style="margin-left: 5px">{{ state.formValidationError }}</span>
</div>
</div>
<!-- !actions -->
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>

10
app/global.d.ts vendored
View file

@ -8,3 +8,13 @@ declare module '*.png' {
declare module '*.css';
declare module '@open-amt-cloud-toolkit/ui-toolkit-react/reactjs/src/kvm.bundle';
declare module 'axios-progress-bar' {
import { AxiosInstance } from 'axios';
import { NProgressOptions } from 'nprogress';
export function loadProgressBar(
config?: Partial<NProgressOptions>,
instance?: AxiosInstance
): void;
}

View file

@ -3,6 +3,7 @@ import Select from 'react-select';
import { Team, TeamId } from '@/portainer/teams/types';
interface Props {
name?: string;
value: TeamId[];
onChange(value: TeamId[]): void;
teams: Team[];
@ -12,6 +13,7 @@ interface Props {
}
export function TeamsSelector({
name,
value,
onChange,
teams,
@ -21,6 +23,7 @@ export function TeamsSelector({
}: Props) {
return (
<Select
name={name}
isMulti
getOptionLabel={(team) => team.Name}
getOptionValue={(team) => String(team.Id)}

View file

@ -5,6 +5,7 @@ import { UserId } from '@/portainer/users/types';
import './UsersSelector.css';
interface Props {
name?: string;
value: UserId[];
onChange(value: UserId[]): void;
users: UserViewModel[];
@ -14,6 +15,7 @@ interface Props {
}
export function UsersSelector({
name,
value,
onChange,
users,
@ -24,6 +26,7 @@ export function UsersSelector({
return (
<Select
isMulti
name={name}
getOptionLabel={(user) => user.Username}
getOptionValue={(user) => user.Id}
options={users}

View file

@ -1,5 +1,6 @@
import _ from 'lodash';
import { useEffect, useState, useCallback } from 'react';
import { FormikErrors } from 'formik';
import { ResourceControlOwnership as RCO } from '@/portainer/models/resourceControl/resourceControlOwnership';
import { BoxSelector, buildOption } from '@/portainer/components/BoxSelector';
@ -10,6 +11,8 @@ import { BoxSelectorOption } from '@/portainer/components/BoxSelector/types';
import { FormSectionTitle } from '@/portainer/components/form-components/FormSectionTitle';
import { SwitchField } from '@/portainer/components/form-components/SwitchField';
import { FormError } from '../form-components/FormError';
import { AccessControlFormData } from './model';
import { UsersField } from './UsersField';
import { TeamsField } from './TeamsField';
@ -19,9 +22,17 @@ export interface Props {
values: AccessControlFormData;
onChange(values: AccessControlFormData): void;
hideTitle?: boolean;
errors?: FormikErrors<AccessControlFormData>;
formNamespace?: string;
}
export function AccessControlForm({ values, onChange, hideTitle }: Props) {
export function AccessControlForm({
values,
onChange,
hideTitle,
errors,
formNamespace,
}: Props) {
const { users, teams, isLoading } = useLoadState();
const { user } = useUser();
@ -49,7 +60,7 @@ export function AccessControlForm({ values, onChange, hideTitle }: Props) {
<div className="col-sm-12">
<SwitchField
checked={values.accessControlEnabled}
name="ownership"
name={withNamespace('accessControlEnabled')}
label="Enable access control"
tooltip="When enabled, you can restrict the access and management of this resource."
onChange={(accessControlEnabled) =>
@ -63,7 +74,7 @@ export function AccessControlForm({ values, onChange, hideTitle }: Props) {
<>
<div className="form-group">
<BoxSelector
radioName="access-control"
radioName={withNamespace('ownership')}
value={values.ownership}
options={options}
onChange={(ownership) => handleChange({ ownership })}
@ -73,16 +84,19 @@ export function AccessControlForm({ values, onChange, hideTitle }: Props) {
<div aria-label="extra-options">
{isAdmin && (
<UsersField
name={withNamespace('authorizedUsers')}
users={users}
onChange={(authorizedUsers) =>
handleChange({ authorizedUsers })
}
value={values.authorizedUsers}
errors={errors?.authorizedUsers}
/>
)}
{(isAdmin || teams.length > 1) && (
<TeamsField
name={withNamespace('authorizedTeams')}
teams={teams}
overrideTooltip={
!isAdmin && teams.length > 1
@ -93,14 +107,25 @@ export function AccessControlForm({ values, onChange, hideTitle }: Props) {
handleChange({ authorizedTeams })
}
value={values.authorizedTeams}
errors={errors?.authorizedTeams}
/>
)}
{typeof errors === 'string' && (
<div className="form-group col-md-12">
<FormError>{errors}</FormError>
</div>
)}
</div>
)}
</>
)}
</>
);
function withNamespace(name: string) {
return formNamespace ? `${formNamespace}.${name}` : name;
}
}
function useOptions(isAdmin: boolean, teams?: Team[]) {

View file

@ -43,6 +43,8 @@ test('when access control is enabled, ownership is restricted and no teams or us
{
accessControlEnabled: true,
ownership: ResourceControlOwnership.RESTRICTED,
authorizedTeams: [],
authorizedUsers: [],
},
{ strict: true }
)
@ -50,19 +52,40 @@ test('when access control is enabled, ownership is restricted and no teams or us
});
});
test('when access control is enabled, ownership is restricted, user is admin but no users, should be valid', async () => {
test('when access control is enabled, ownership is restricted, user is admin should have either teams or users', async () => {
const schema = validationSchema(true);
await expect(
schema.validate(
{
const teams = {
accessControlEnabled: true,
ownership: ResourceControlOwnership.RESTRICTED,
authorizedTeams: [1],
},
{ strict: true }
)
).rejects.toThrowErrorMatchingSnapshot();
authorizedUsers: [],
};
await expect(schema.validate(teams, { strict: true })).resolves.toStrictEqual(
teams
);
const users = {
accessControlEnabled: true,
ownership: ResourceControlOwnership.RESTRICTED,
authorizedTeams: [],
authorizedUsers: [1],
};
await expect(schema.validate(users, { strict: true })).resolves.toStrictEqual(
users
);
const both = {
accessControlEnabled: true,
ownership: ResourceControlOwnership.RESTRICTED,
authorizedTeams: [1],
authorizedUsers: [2],
};
await expect(schema.validate(both, { strict: true })).resolves.toStrictEqual(
both
);
});
test('when access control is enabled, ownership is restricted, user is admin with teams and users, should be valid', async () => {

View file

@ -3,7 +3,8 @@ import { object, string, array, number, bool } from 'yup';
import { ResourceControlOwnership } from '@/portainer/models/resourceControl/resourceControlOwnership';
export function validationSchema(isAdmin: boolean) {
return object().shape({
return object()
.shape({
accessControlEnabled: bool(),
ownership: string()
.oneOf(Object.values(ResourceControlOwnership))
@ -11,31 +12,36 @@ export function validationSchema(isAdmin: boolean) {
is: true,
then: (schema) => schema.required(),
}),
authorizedUsers: array(number()).when(
['accessControlEnabled', 'ownership'],
{
is: (
accessControlEnabled: boolean,
ownership: ResourceControlOwnership
) =>
isAdmin &&
accessControlEnabled &&
ownership === ResourceControlOwnership.RESTRICTED,
then: (schema) =>
schema.required('You must specify at least one user.'),
authorizedUsers: array(number()),
authorizedTeams: array(number()),
})
.test(
'user-and-team',
isAdmin
? 'You must specify at least one team or user.'
: 'You must specify at least one team.',
({
accessControlEnabled,
ownership,
authorizedTeams,
authorizedUsers,
}) => {
if (
!accessControlEnabled ||
ownership !== ResourceControlOwnership.RESTRICTED
) {
return true;
}
),
authorizedTeams: array(number()).when(
['accessControlEnabled', 'ownership'],
{
is: (
accessControlEnabled: boolean,
ownership: ResourceControlOwnership
) =>
accessControlEnabled &&
ownership === ResourceControlOwnership.RESTRICTED,
then: (schema) => schema.required('You must specify at least one team'),
if (!isAdmin) {
return !!authorizedTeams && authorizedTeams.length > 0;
}
),
});
return (
!!authorizedTeams &&
!!authorizedUsers &&
(authorizedTeams.length > 0 || authorizedUsers.length > 0)
);
}
);
}

View file

@ -4,13 +4,22 @@ import { Link } from '@/portainer/components/Link';
import { Team } from '@/portainer/teams/types';
interface Props {
name: string;
teams: Team[];
value: number[];
overrideTooltip?: string;
onChange(value: number[]): void;
errors?: string | string[];
}
export function TeamsField({ teams, value, overrideTooltip, onChange }: Props) {
export function TeamsField({
name,
teams,
value,
overrideTooltip,
onChange,
errors,
}: Props) {
return (
<FormControl
label="Authorized teams"
@ -21,9 +30,11 @@ export function TeamsField({ teams, value, overrideTooltip, onChange }: Props) {
: undefined
}
inputId="teams-selector"
errors={errors}
>
{teams.length > 0 ? (
<TeamsSelector
name={name}
teams={teams}
onChange={onChange}
value={value}

View file

@ -4,12 +4,14 @@ import { UserViewModel } from '@/portainer/models/user';
import { Link } from '@/portainer/components/Link';
interface Props {
name: string;
users: UserViewModel[];
value: number[];
onChange(value: number[]): void;
errors?: string | string[];
}
export function UsersField({ users, value, onChange }: Props) {
export function UsersField({ name, users, value, onChange, errors }: Props) {
return (
<FormControl
label="Authorized users"
@ -19,9 +21,11 @@ export function UsersField({ users, value, onChange }: Props) {
: undefined
}
inputId="users-selector"
errors={errors}
>
{users.length > 0 ? (
<UsersSelector
name={name}
users={users}
onChange={onChange}
value={value}

View file

@ -1,9 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`when access control is enabled, ownership is restricted and no teams or users, should be invalid 1`] = `"You must specify at least one team"`;
exports[`when access control is enabled, ownership is restricted and no teams or users, should be invalid 1`] = `"You must specify at least one team or user."`;
exports[`when access control is enabled, ownership is restricted and no teams or users, should be invalid 2`] = `"You must specify at least one team"`;
exports[`when access control is enabled, ownership is restricted, user is admin but no users, should be valid 1`] = `"You must specify at least one user."`;
exports[`when access control is enabled, ownership is restricted and no teams or users, should be invalid 2`] = `"You must specify at least one team."`;
exports[`when only access control is enabled, should be invalid 1`] = `"ownership is a required field"`;

View file

@ -3,6 +3,8 @@ import clsx from 'clsx';
import { Tooltip } from '@/portainer/components/Tip/Tooltip';
import { FormError } from '../FormError';
import styles from './FormControl.module.css';
type Size = 'small' | 'medium' | 'large';
@ -40,13 +42,7 @@ export function FormControl({
{errors && (
<div className="form-group col-md-12">
<div className="small text-warning">
<i
className="fa fa-exclamation-triangle space-right"
aria-hidden="true"
/>
{errors}
</div>
<FormError>{errors}</FormError>
</div>
)}
</div>

View file

@ -0,0 +1,13 @@
import { PropsWithChildren } from 'react';
export function FormError({ children }: PropsWithChildren<unknown>) {
return (
<div className="small text-warning">
<i
className="fa fa-exclamation-triangle space-right"
aria-hidden="true"
/>
{children}
</div>
);
}

View file

@ -1,7 +1,7 @@
import clsx from 'clsx';
import { SelectHTMLAttributes } from 'react';
interface Option<T extends string | number> {
export interface Option<T extends string | number> {
value: T;
label: string;
}

View file

@ -20,6 +20,10 @@
display: flex;
}
.item-line.has-error {
margin-bottom: 20px;
}
.item-actions {
display: flex;
margin-left: 2px;

View file

@ -5,13 +5,17 @@ import { AddButton, Button } from '@/portainer/components/Button';
import { Tooltip } from '@/portainer/components/Tip/Tooltip';
import { Input } from '../Input';
import { FormError } from '../FormError';
import styles from './InputList.module.css';
import { arrayMove } from './utils';
interface ItemProps<T> {
export type InputListError<T> = Record<keyof T, string>;
export interface ItemProps<T> {
item: T;
onChange(value: T): void;
error?: InputListError<T>;
}
type Key = string | number;
type ChangeType = 'delete' | 'create' | 'update';
@ -38,6 +42,7 @@ interface Props<T> {
addLabel?: string;
itemKeyGetter?(item: T, index: number): Key;
movable?: boolean;
errors?: InputListError<T>[] | string;
}
export function InputList<T = DefaultType>({
@ -50,6 +55,7 @@ export function InputList<T = DefaultType>({
addLabel = 'Add item',
itemKeyGetter = (item: T, index: number) => index,
movable,
errors,
}: Props<T>) {
const Item = item;
@ -70,12 +76,17 @@ export function InputList<T = DefaultType>({
<div className={clsx('col-sm-12 form-inline', styles.items)}>
{value.map((item, index) => {
const key = itemKeyGetter(item, index);
const error = typeof errors === 'object' ? errors[index] : undefined;
return (
<div key={key} className={clsx(styles.itemLine)}>
<div
key={key}
className={clsx(styles.itemLine, { [styles.hasError]: !!error })}
>
<Item
item={item}
onChange={(value: T) => handleChangeItem(key, value)}
error={error}
/>
<div className={styles.itemActions}>
{movable && (
@ -172,12 +183,15 @@ function defaultItemBuilder(): DefaultType {
return { value: '' };
}
function DefaultItem({ item, onChange }: ItemProps<DefaultType>) {
function DefaultItem({ item, onChange, error }: ItemProps<DefaultType>) {
return (
<>
<Input
value={item.value}
onChange={(e) => onChange({ value: e.target.value })}
className={styles.defaultItem}
/>
<FormError>{error}</FormError>
</>
);
}

View file

@ -125,6 +125,6 @@ export function UserProvider({ children }: UserProviderProps) {
}
}
function isAdmin(user?: UserViewModel | null) {
export function isAdmin(user?: UserViewModel | null): boolean {
return !!user && user.Role === 1;
}

View file

@ -0,0 +1,51 @@
import { ResourceControlOwnership } from 'Portainer/models/resourceControl/resourceControlOwnership';
import { AccessControlFormData } from '../components/accessControlForm/model';
import { TeamId } from '../teams/types';
import { UserId } from '../users/types';
import { OwnershipParameters } from './types';
/**
* Transform AccessControlFormData to ResourceControlOwnershipParameters
* @param {int} userId ID of user performing the operation
* @param {AccessControlFormData} formValues Form data (generated by AccessControlForm)
* @param {int[]} subResources Sub Resources restricted by the ResourceControl
*/
export function parseOwnershipParameters(
userId: UserId,
formValues: AccessControlFormData,
subResources: (number | string)[] = []
): OwnershipParameters {
let { ownership } = formValues;
if (!formValues.accessControlEnabled) {
ownership = ResourceControlOwnership.PUBLIC;
}
let adminOnly = false;
let publicOnly = false;
let users: UserId[] = [];
let teams: TeamId[] = [];
switch (ownership) {
case ResourceControlOwnership.PUBLIC:
publicOnly = true;
break;
case ResourceControlOwnership.PRIVATE:
users.push(userId);
break;
case ResourceControlOwnership.RESTRICTED:
users = formValues.authorizedUsers;
teams = formValues.authorizedTeams;
break;
default:
adminOnly = true;
break;
}
return {
administratorsOnly: adminOnly,
public: publicOnly,
users,
teams,
subResources,
};
}

View file

@ -0,0 +1,48 @@
import { UserId } from '@/portainer/users/types';
import { AccessControlFormData } from '@/portainer/components/accessControlForm/model';
import { ResourceControlResponse } from '@/portainer/models/resourceControl/resourceControl';
import axios, { parseAxiosError } from '../services/axios';
import { parseOwnershipParameters } from './helper';
import { OwnershipParameters } from './types';
/**
* Apply a ResourceControl after Resource creation
* @param userId ID of User performing the action
* @param accessControlData ResourceControl to apply
* @param resourceControl ResourceControl to update
* @param subResources SubResources managed by the ResourceControl
*/
export function applyResourceControl(
userId: UserId,
accessControlData: AccessControlFormData,
resourceControl: ResourceControlResponse,
subResources: (number | string)[] = []
) {
const ownershipParameters = parseOwnershipParameters(
userId,
accessControlData,
subResources
);
return updateResourceControl(resourceControl.Id, ownershipParameters);
}
/**
* Update a ResourceControl
* @param resourceControlId ID of involved resource
* @param ownershipParameters Transient type from view data to payload
*/
async function updateResourceControl(
resourceControlId: string | number,
ownershipParameters: OwnershipParameters
) {
try {
await axios.put(
`/resource_controls/${resourceControlId}`,
ownershipParameters
);
} catch (error) {
throw parseAxiosError(error as Error);
}
}

View file

@ -0,0 +1,13 @@
import { TeamId } from '@/portainer/teams/types';
import { UserId } from '@/portainer/users/types';
/**
* Transient type from view data to payload
*/
export interface OwnershipParameters {
administratorsOnly: boolean;
public: boolean;
users: UserId[];
teams: TeamId[];
subResources: (number | string)[];
}

View file

@ -1,4 +1,6 @@
import axios, { AxiosError, AxiosRequestConfig } from 'axios';
import axiosOrigin, { AxiosError, AxiosRequestConfig } from 'axios';
import { loadProgressBar } from 'axios-progress-bar';
import 'axios-progress-bar/dist/nprogress.css';
import PortainerError from '../error';
import { get as localStorageGet } from '../hooks/useLocalStorage';
@ -8,11 +10,13 @@ import {
portainerAgentTargetHeader,
} from './http-request.helper';
const axiosApiInstance = axios.create({ baseURL: 'api' });
const axios = axiosOrigin.create({ baseURL: 'api' });
export default axiosApiInstance;
loadProgressBar(undefined, axios);
axiosApiInstance.interceptors.request.use(async (config) => {
export default axios;
axios.interceptors.request.use(async (config) => {
const newConfig = { headers: config.headers || {}, ...config };
const jwt = localStorageGet('JWT', '');
@ -41,18 +45,28 @@ export function agentInterceptor(config: AxiosRequestConfig) {
return newConfig;
}
axiosApiInstance.interceptors.request.use(agentInterceptor);
axios.interceptors.request.use(agentInterceptor);
export function parseAxiosError(err: Error, msg = '') {
export function parseAxiosError(
err: Error,
msg = '',
parseError = defaultErrorParser
) {
let resultErr = err;
let resultMsg = msg;
if ('isAxiosError' in err) {
const axiosError = err as AxiosError;
resultErr = new Error(`${axiosError.response?.data.message}`);
const msgDetails = axiosError.response?.data.details;
resultMsg = msg ? `${msg}: ${msgDetails}` : msgDetails;
const { error, details } = parseError(err as AxiosError);
resultErr = error;
resultMsg = msg ? `${msg}: ${details}` : details;
}
return new PortainerError(resultMsg, resultErr);
}
function defaultErrorParser(axiosError: AxiosError) {
const message = axiosError.response?.data.message;
const details = axiosError.response?.data.details || message;
const error = new Error(message);
return { error, details };
}

View file

@ -2,6 +2,8 @@ import { rest } from 'msw';
import { createMockTeams, createMockUsers } from '../react-tools/test-mocks';
import { azureHandlers } from './setup-handlers/azure';
export const handlers = [
rest.get('/api/teams', async (req, res, ctx) =>
res(ctx.json(createMockTeams(10)))
@ -9,4 +11,5 @@ export const handlers = [
rest.get('/api/users', async (req, res, ctx) =>
res(ctx.json(createMockUsers(10)))
),
...azureHandlers,
];

View file

@ -0,0 +1,76 @@
import { rest } from 'msw';
export const azureHandlers = [
rest.get('/api/endpoints/:endpointId/azure/subscriptions', (req, res, ctx) =>
res(
ctx.json({
value: [
{
id: '/subscriptions/sub1',
authorizationSource: 'RoleBased',
subscriptionId: 'sub1',
displayName: 'Portainer',
state: 'Enabled',
},
],
})
)
),
rest.get(
'/api/endpoints/:endpointId/azure/subscriptions/:subscriptionId/providers/Microsoft.ContainerInstance',
(req, res, ctx) =>
res(
ctx.json({
id: `/subscriptions/${req.params.subscriptionId}/providers/Microsoft.ContainerInstance`,
namespace: 'Microsoft.ContainerInstance',
resourceTypes: [
{
resourceType: 'containerGroups',
locations: [
'Australia East',
'Australia Southeast',
'Brazil South',
],
},
{
resourceType: 'serviceAssociationLinks',
locations: [
'Korea Central',
'North Central US',
'North Europe',
'Norway East',
'South Africa North',
'South Central US',
],
},
{
resourceType: 'locations',
locations: [],
},
],
})
)
),
rest.get(
'/api/endpoints/:endpointId/azure/subscriptions/:subsriptionId/resourcegroups',
(res, req, ctx) =>
req(
ctx.json({
value: [
{
id: `/subscriptions/${res.params.subscriptionId}/resourceGroups/rg1`,
name: 'rg1',
location: 'southcentralus',
properties: { provisioningState: 'Succeeded' },
},
{
id: `/subscriptions/${res.params.subscriptionId}/resourceGroups/rg2`,
name: 'rg2',
location: 'southcentralus',
properties: { provisioningState: 'Succeeded' },
},
],
})
)
),
];

View file

@ -92,6 +92,7 @@
"angularjs-slider": "^6.4.0",
"angulartics": "^1.6.0",
"axios": "^0.24.0",
"axios-progress-bar": "^1.2.0",
"babel-plugin-angularjs-annotate": "^0.10.0",
"bootbox": "^5.5.2",
"bootstrap": "^3.4.0",

View file

@ -5057,6 +5057,11 @@ axe-core@^4.3.5:
resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.3.5.tgz#78d6911ba317a8262bfee292aeafcc1e04b49cc5"
integrity sha512-WKTW1+xAzhMS5dJsxWkliixlO/PqC4VhmO9T4juNYcaTg9jzWiJsou6m5pxWYGfigWbwzJWeFY6z47a+4neRXA==
axios-progress-bar@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/axios-progress-bar/-/axios-progress-bar-1.2.0.tgz#f9ee88dc9af977246be1ef07eedfa4c990c639c5"
integrity sha512-PEgWb/b2SMyHnKJ/cxA46OdCuNeVlo8eqL0HxXPtz+6G/Jtpyo49icPbW+jpO1wUeDEjbqpseMoCyWxESxf5pA==
axios@^0.24.0:
version "0.24.0"
resolved "https://registry.yarnpkg.com/axios/-/axios-0.24.0.tgz#804e6fa1e4b9c5288501dd9dff56a7a0940d20d6"