From 4997e9c7be1ea5a55fc211e0e6739ceb4262e2cd Mon Sep 17 00:00:00 2001 From: congs Date: Mon, 18 Jul 2022 11:02:14 +1200 Subject: [PATCH] feat(gpu) EE-3191 Add GPU support for containers (#7146) --- api/datastore/migrator/migrate_ce.go | 3 + api/datastore/migrator/migrate_dbversion60.go | 30 +++ .../test_data/output_24_to_latest.json | 3 + api/docker/snapshot.go | 35 ++- api/http/handler/endpoints/endpoint_create.go | 13 ++ api/http/handler/endpoints/endpoint_update.go | 6 + api/portainer.go | 3 + api/swagger.yaml | 11 + app/docker/react/views/gpu.tsx | 210 ++++++++++++++++++ app/docker/react/views/index.ts | 5 + .../views/containers/containersController.js | 36 ++- .../create/createContainerController.js | 65 ++++++ .../containers/create/createcontainer.html | 14 ++ .../views/containers/edit/container.html | 4 + .../containers/edit/containerController.js | 17 ++ app/docker/views/dashboard/dashboard.html | 8 + .../views/dashboard/dashboardController.js | 38 ++++ .../environment.service/create.ts | 10 + app/portainer/environments/types.ts | 3 + .../EnvironmentItem/EnvironmentItem.tsx | 2 + .../views/endpoints/edit/endpoint.html | 4 + .../endpoints/edit/endpointController.js | 81 ++++++- app/react-tools/test-mocks.ts | 1 + .../form-components/InputGroup/InputGroup.tsx | 11 +- .../form-components/InputGroup/index.ts | 1 + .../form-components/ReactSelect.module.css | 1 + .../ContainersDatatable/columns/gpus.tsx | 12 + .../ContainersDatatable/columns/index.tsx | 2 + app/react/docker/containers/types.ts | 1 + .../WizardDocker/APITab/APIForm.tsx | 7 +- .../APITab/APIForm.validation.tsx | 3 + .../WizardDocker/APITab/types.ts | 2 + .../WizardDocker/SocketTab/SocketForm.tsx | 8 +- .../SocketTab/SocketForm.validation.tsx | 3 + .../WizardDocker/SocketTab/types.ts | 2 + .../shared/AgentForm/AgentForm.tsx | 7 +- .../shared/AgentForm/AgentForm.validation.tsx | 2 + .../EdgeAgentForm/EdgeAgentForm.tsx | 6 +- .../EdgeAgentForm/EdgeAgentForm.validation.ts | 3 + .../EdgeAgentTab/EdgeAgentForm/types.ts | 2 + .../shared/EdgeAgentTab/EdgeAgentTab.tsx | 3 + .../shared/Hardware/GpusList.tsx | 68 ++++++ .../shared/Hardware/Hardware.tsx | 22 ++ 43 files changed, 758 insertions(+), 10 deletions(-) create mode 100644 api/datastore/migrator/migrate_dbversion60.go create mode 100644 app/docker/react/views/gpu.tsx create mode 100644 app/react/docker/containers/ListView/ContainersDatatable/columns/gpus.tsx create mode 100644 app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/Hardware/GpusList.tsx create mode 100644 app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/Hardware/Hardware.tsx diff --git a/api/datastore/migrator/migrate_ce.go b/api/datastore/migrator/migrate_ce.go index a5e90a7ff..0114b1e34 100644 --- a/api/datastore/migrator/migrate_ce.go +++ b/api/datastore/migrator/migrate_ce.go @@ -103,6 +103,9 @@ func (m *Migrator) Migrate() error { // Portainer 2.14 newMigration(50, m.migrateDBVersionToDB50), + + // Portainer 2.15 + newMigration(60, m.migrateDBVersionToDB60), } var lastDbVersion int diff --git a/api/datastore/migrator/migrate_dbversion60.go b/api/datastore/migrator/migrate_dbversion60.go new file mode 100644 index 000000000..227f1262a --- /dev/null +++ b/api/datastore/migrator/migrate_dbversion60.go @@ -0,0 +1,30 @@ +package migrator + +import portainer "github.com/portainer/portainer/api" + +func (m *Migrator) migrateDBVersionToDB60() error { + if err := m.addGpuInputFieldDB60(); err != nil { + return err + } + + return nil +} + +func (m *Migrator) addGpuInputFieldDB60() error { + migrateLog.Info("- add gpu input field") + endpoints, err := m.endpointService.Endpoints() + if err != nil { + return err + } + + for _, endpoint := range endpoints { + endpoint.Gpus = []portainer.Pair{} + err = m.endpointService.UpdateEndpoint(endpoint.ID, &endpoint) + if err != nil { + return err + } + + } + + return nil +} diff --git a/api/datastore/test_data/output_24_to_latest.json b/api/datastore/test_data/output_24_to_latest.json index c4bf5e520..bd3bceb08 100644 --- a/api/datastore/test_data/output_24_to_latest.json +++ b/api/datastore/test_data/output_24_to_latest.json @@ -43,6 +43,7 @@ }, "EdgeCheckinInterval": 0, "EdgeKey": "", + "Gpus": [], "GroupId": 1, "Id": 1, "IsEdgeDevice": false, @@ -175,6 +176,8 @@ } }, "DockerVersion": "20.10.13", + "GpuUseAll": false, + "GpuUseList": null, "HealthyContainerCount": 0, "ImageCount": 9, "NodeCount": 0, diff --git a/api/docker/snapshot.go b/api/docker/snapshot.go index 2d638fb55..074cc4726 100644 --- a/api/docker/snapshot.go +++ b/api/docker/snapshot.go @@ -7,9 +7,10 @@ import ( "time" "github.com/docker/docker/api/types" + _container "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/filters" "github.com/docker/docker/client" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" ) // Snapshotter represents a service used to create environment(endpoint) snapshots @@ -154,11 +155,35 @@ func snapshotContainers(snapshot *portainer.DockerSnapshot, cli *client.Client) healthyContainers := 0 unhealthyContainers := 0 stacks := make(map[string]struct{}) + gpuUseSet := make(map[string]struct{}) + gpuUseAll := false for _, container := range containers { if container.State == "exited" { stoppedContainers++ } else if container.State == "running" { runningContainers++ + + // snapshot GPUs + response, err := cli.ContainerInspect(context.Background(), container.ID) + if err != nil { + return err + } + + var gpuOptions *_container.DeviceRequest = nil + for _, deviceRequest := range response.HostConfig.Resources.DeviceRequests { + if deviceRequest.Driver == "nvidia" || deviceRequest.Capabilities[0][0] == "gpu" { + gpuOptions = &deviceRequest + } + } + + if gpuOptions != nil { + if gpuOptions.Count == -1 { + gpuUseAll = true + } + for _, id := range gpuOptions.DeviceIDs { + gpuUseSet[id] = struct{}{} + } + } } if strings.Contains(container.Status, "(healthy)") { @@ -174,6 +199,14 @@ func snapshotContainers(snapshot *portainer.DockerSnapshot, cli *client.Client) } } + gpuUseList := make([]string, 0, len(gpuUseSet)) + for gpuUse := range gpuUseSet { + gpuUseList = append(gpuUseList, gpuUse) + } + + snapshot.GpuUseAll = gpuUseAll + snapshot.GpuUseList = gpuUseList + snapshot.RunningContainerCount = runningContainers snapshot.StoppedContainerCount = stoppedContainers snapshot.HealthyContainerCount = healthyContainers diff --git a/api/http/handler/endpoints/endpoint_create.go b/api/http/handler/endpoints/endpoint_create.go index 40367820b..a0d7bf20e 100644 --- a/api/http/handler/endpoints/endpoint_create.go +++ b/api/http/handler/endpoints/endpoint_create.go @@ -25,6 +25,7 @@ type endpointCreatePayload struct { URL string EndpointCreationType endpointCreationEnum PublicURL string + Gpus []portainer.Pair GroupID int TLS bool TLSSkipVerify bool @@ -142,6 +143,13 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error { payload.PublicURL = publicURL } + gpus := make([]portainer.Pair, 0) + err = request.RetrieveMultiPartFormJSONValue(r, "Gpus", &gpus, true) + if err != nil { + return errors.New("Invalid Gpus parameter") + } + payload.Gpus = gpus + checkinInterval, _ := request.RetrieveNumericMultiPartFormValue(r, "CheckinInterval", true) payload.EdgeCheckinInterval = checkinInterval @@ -290,6 +298,7 @@ func (handler *Handler) createAzureEndpoint(payload *endpointCreatePayload) (*po Type: portainer.AzureEnvironment, GroupID: portainer.EndpointGroupID(payload.GroupID), PublicURL: payload.PublicURL, + Gpus: payload.Gpus, UserAccessPolicies: portainer.UserAccessPolicies{}, TeamAccessPolicies: portainer.TeamAccessPolicies{}, AzureCredentials: credentials, @@ -323,6 +332,7 @@ func (handler *Handler) createEdgeAgentEndpoint(payload *endpointCreatePayload) URL: portainerHost, Type: portainer.EdgeAgentOnDockerEnvironment, GroupID: portainer.EndpointGroupID(payload.GroupID), + Gpus: payload.Gpus, TLSConfig: portainer.TLSConfiguration{ TLS: false, }, @@ -378,6 +388,7 @@ func (handler *Handler) createUnsecuredEndpoint(payload *endpointCreatePayload) Type: endpointType, GroupID: portainer.EndpointGroupID(payload.GroupID), PublicURL: payload.PublicURL, + Gpus: payload.Gpus, TLSConfig: portainer.TLSConfiguration{ TLS: false, }, @@ -412,6 +423,7 @@ func (handler *Handler) createKubernetesEndpoint(payload *endpointCreatePayload) Type: portainer.KubernetesLocalEnvironment, GroupID: portainer.EndpointGroupID(payload.GroupID), PublicURL: payload.PublicURL, + Gpus: payload.Gpus, TLSConfig: portainer.TLSConfiguration{ TLS: payload.TLS, TLSSkipVerify: payload.TLSSkipVerify, @@ -441,6 +453,7 @@ func (handler *Handler) createTLSSecuredEndpoint(payload *endpointCreatePayload, Type: endpointType, GroupID: portainer.EndpointGroupID(payload.GroupID), PublicURL: payload.PublicURL, + Gpus: payload.Gpus, TLSConfig: portainer.TLSConfiguration{ TLS: payload.TLS, TLSSkipVerify: payload.TLSSkipVerify, diff --git a/api/http/handler/endpoints/endpoint_update.go b/api/http/handler/endpoints/endpoint_update.go index 434d0d4fa..2a52f6f35 100644 --- a/api/http/handler/endpoints/endpoint_update.go +++ b/api/http/handler/endpoints/endpoint_update.go @@ -22,6 +22,8 @@ type endpointUpdatePayload struct { // URL or IP address where exposed containers will be reachable.\ // Defaults to URL if not specified PublicURL *string `example:"docker.mydomain.tld:2375"` + // GPUs information + Gpus []portainer.Pair // Group identifier GroupID *int `example:"1"` // Require TLS to connect against this environment(endpoint) @@ -110,6 +112,10 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) * endpoint.PublicURL = *payload.PublicURL } + if payload.Gpus != nil { + endpoint.Gpus = payload.Gpus + } + if payload.EdgeCheckinInterval != nil { endpoint.EdgeCheckinInterval = *payload.EdgeCheckinInterval } diff --git a/api/portainer.go b/api/portainer.go index 77e3b9e7f..6f6d04b30 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -199,6 +199,8 @@ type ( StackCount int `json:"StackCount"` SnapshotRaw DockerSnapshotRaw `json:"DockerSnapshotRaw"` NodeCount int `json:"NodeCount"` + GpuUseAll bool `json:"GpuUseAll"` + GpuUseList []string `json:"GpuUseList"` } // DockerSnapshotRaw represents all the information related to a snapshot as returned by the Docker API @@ -310,6 +312,7 @@ type ( GroupID EndpointGroupID `json:"GroupId" example:"1"` // URL or IP address where exposed containers will be reachable PublicURL string `json:"PublicURL" example:"docker.mydomain.tld:2375"` + Gpus []Pair `json:"Gpus"` TLSConfig TLSConfiguration `json:"TLSConfig"` AzureCredentials AzureCredentials `json:"AzureCredentials,omitempty" example:""` // List of tag identifiers to which this environment(endpoint) is associated diff --git a/api/swagger.yaml b/api/swagger.yaml index 5af686154..07f38407b 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -693,6 +693,12 @@ definitions: $ref: '#/definitions/portainer.DockerSnapshotRaw' DockerVersion: type: string + GpuUseAll: + type: boolean + GpuUseList: + items: + type: string + type: array HealthyContainerCount: type: integer ImageCount: @@ -849,6 +855,11 @@ definitions: EdgeKey: description: The key which is used to map the agent to Portainer type: string + Gpus: + description: Endpoint Gpus information + items: + $ref: '#/definitions/portainer.Pair' + type: array GroupId: description: Endpoint group identifier example: 1 diff --git a/app/docker/react/views/gpu.tsx b/app/docker/react/views/gpu.tsx new file mode 100644 index 000000000..3757d97b9 --- /dev/null +++ b/app/docker/react/views/gpu.tsx @@ -0,0 +1,210 @@ +import { useMemo } from 'react'; +import { components } from 'react-select'; +import { OnChangeValue } from 'react-select/dist/declarations/src/types'; +import { OptionProps } from 'react-select/dist/declarations/src/components/Option'; + +import { Select } from '@@/form-components/ReactSelect'; +import { Switch } from '@@/form-components/SwitchField/Switch'; +import { Tooltip } from '@@/Tip/Tooltip'; + +interface Values { + enabled: boolean; + selectedGPUs: string[]; + capabilities: string[]; +} + +interface GpuOption { + value: string; + label: string; + description?: string; +} + +export interface GPU { + value: string; + name: string; +} + +export interface Props { + values: Values; + onChange(values: Values): void; + gpus: GPU[]; + usedGpus: string[]; + usedAllGpus: boolean; +} + +const NvidiaCapabilitiesOptions = [ + // Taken from https://github.com/containerd/containerd/blob/master/contrib/nvidia/nvidia.go#L40 + { + value: 'compute', + label: 'compute', + description: 'required for CUDA and OpenCL applications', + }, + { + value: 'compat32', + label: 'compat32', + description: 'required for running 32-bit applications', + }, + { + value: 'graphics', + label: 'graphics', + description: 'required for running OpenGL and Vulkan applications', + }, + { + value: 'utility', + label: 'utility', + description: 'required for using nvidia-smi and NVML', + }, + { + value: 'video', + label: 'video', + description: 'required for using the Video Codec SDK', + }, + { + value: 'display', + label: 'display', + description: 'required for leveraging X11 display', + }, +]; + +function Option(props: OptionProps) { + const { + data: { value, description }, + } = props; + + return ( +
+ {/* eslint-disable-next-line react/jsx-props-no-spreading */} + + {`${value} - ${description}`} + +
+ ); +} + +export function Gpu({ + values, + onChange, + gpus, + usedGpus = [], + usedAllGpus, +}: Props) { + const options = useMemo(() => { + const options = gpus.map((gpu) => ({ + value: gpu.value, + label: + usedGpus.includes(gpu.value) || usedAllGpus + ? `${gpu.name} (in use)` + : gpu.name, + })); + + return options; + }, [gpus, usedGpus, usedAllGpus]); + + function onChangeValues(key: string, newValue: boolean | string[]) { + const newValues = { + ...values, + [key]: newValue, + }; + onChange(newValues); + } + + function toggleEnableGpu() { + onChangeValues('enabled', !values.enabled); + } + + function onChangeSelectedGpus(newValue: OnChangeValue) { + onChangeValues( + 'selectedGPUs', + newValue.map((option) => option.value) + ); + } + + function onChangeSelectedCaps(newValue: OnChangeValue) { + onChangeValues( + 'capabilities', + newValue.map((option) => option.value) + ); + } + + const gpuCmd = useMemo(() => { + const devices = values.selectedGPUs.join(','); + const caps = values.capabilities.join(','); + return `--gpus 'device=${devices},"capabilities=${caps}"`; + }, [values.selectedGPUs, values.capabilities]); + + const gpuValue = useMemo( + () => + options.filter((option) => values.selectedGPUs.includes(option.value)), + [values.selectedGPUs, options] + ); + + const capValue = useMemo( + () => + NvidiaCapabilitiesOptions.filter((option) => + values.capabilities.includes(option.value) + ), + [values.capabilities] + ); + + return ( +
+
+
+ Enable GPU + +
+
+ + isMulti + closeMenuOnSelect + value={gpuValue} + isDisabled={!values.enabled} + onChange={onChangeSelectedGpus} + options={options} + /> +
+
+ + {values.enabled && ( + <> +
+
+ Capabilities + +
+
+ + isMulti + closeMenuOnSelect + value={capValue} + options={NvidiaCapabilitiesOptions} + components={{ Option }} + onChange={onChangeSelectedCaps} + /> +
+
+ +
+
+ Control + +
+
+ {gpuCmd} +
+
+ + )} +
+ ); +} diff --git a/app/docker/react/views/index.ts b/app/docker/react/views/index.ts index 38cf0e44e..6f7da8a16 100644 --- a/app/docker/react/views/index.ts +++ b/app/docker/react/views/index.ts @@ -1,8 +1,13 @@ import angular from 'angular'; +import { Gpu } from 'Docker/react/views/gpu'; import { ItemView } from '@/react/docker/networks/ItemView'; import { r2a } from '@/react-tools/react2angular'; export const viewsModule = angular .module('portainer.docker.react.views', []) + .component( + 'gpu', + r2a(Gpu, ['values', 'onChange', 'gpus', 'usedGpus', 'usedAllGpus']) + ) .component('networkDetailsView', r2a(ItemView, [])).name; diff --git a/app/docker/views/containers/containersController.js b/app/docker/views/containers/containersController.js index 4c60cd1b1..0d7c71b62 100644 --- a/app/docker/views/containers/containersController.js +++ b/app/docker/views/containers/containersController.js @@ -1,4 +1,5 @@ angular.module('portainer.docker').controller('ContainersController', ContainersController); +import _ from 'lodash'; /* @ngInject */ function ContainersController($scope, ContainerService, Notifications, endpoint) { @@ -8,9 +9,42 @@ function ContainersController($scope, ContainerService, Notifications, endpoint) $scope.getContainers = getContainers; function getContainers() { + $scope.containers = null; + $scope.containers_t = null; ContainerService.containers(1) .then(function success(data) { - $scope.containers = data; + $scope.containers_t = data; + if ($scope.containers_t.length === 0) { + $scope.containers = $scope.containers_t; + return; + } + for (let item of $scope.containers_t) { + ContainerService.container(item.Id).then(function success(data) { + var Id = data.Id; + for (var i = 0; i < $scope.containers_t.length; i++) { + if (Id == $scope.containers_t[i].Id) { + const gpuOptions = _.find(data.HostConfig.DeviceRequests, function (o) { + return o.Driver === 'nvidia' || o.Capabilities[0][0] === 'gpu'; + }); + if (!gpuOptions) { + $scope.containers_t[i]['Gpus'] = 'none'; + } else { + let gpuStr = 'all'; + if (gpuOptions.Count !== -1) { + gpuStr = `id:${_.join(gpuOptions.DeviceIDs, ',')}`; + } + $scope.containers_t[i]['Gpus'] = `${gpuStr}`; + } + } + } + for (let item of $scope.containers_t) { + if (!Object.keys(item).includes('Gpus')) { + return; + } + } + $scope.containers = $scope.containers_t; + }); + } }) .catch(function error(err) { Notifications.error('Failure', err, 'Unable to retrieve containers'); diff --git a/app/docker/views/containers/create/createContainerController.js b/app/docker/views/containers/create/createContainerController.js index ab146d249..d68252695 100644 --- a/app/docker/views/containers/create/createContainerController.js +++ b/app/docker/views/containers/create/createContainerController.js @@ -69,6 +69,12 @@ angular.module('portainer.docker').controller('CreateContainerController', [ $scope.containerWebhookFeature = FeatureId.CONTAINER_WEBHOOK; $scope.formValues = { alwaysPull: true, + GPU: { + enabled: false, + useSpecific: false, + selectedGPUs: [], + capabilities: ['compute', 'utility'], + }, Console: 'none', Volumes: [], NetworkContainer: null, @@ -149,6 +155,7 @@ angular.module('portainer.docker').controller('CreateContainerController', [ Runtime: null, ExtraHosts: [], Devices: [], + DeviceRequests: [], CapAdd: [], CapDrop: [], Sysctls: {}, @@ -199,6 +206,12 @@ angular.module('portainer.docker').controller('CreateContainerController', [ $scope.config.HostConfig.Devices.splice(index, 1); }; + $scope.onGpuChange = function (values) { + return $async(async () => { + $scope.formValues.GPU = values; + }); + }; + $scope.addSysctl = function () { $scope.formValues.Sysctls.push({ name: '', value: '' }); }; @@ -417,6 +430,36 @@ angular.module('portainer.docker').controller('CreateContainerController', [ config.HostConfig.CapDrop = notAllowed.map(getCapName); } + function prepareGPUOptions(config) { + const driver = 'nvidia'; + const gpuOptions = $scope.formValues.GPU; + + const existingDeviceRequest = _.find($scope.config.HostConfig.DeviceRequests, function (o) { + return o.Driver === driver || o.Capabilities[0][0] === 'gpu'; + }); + if (existingDeviceRequest) { + _.pullAllBy(config.HostConfig.DeviceRequests, [existingDeviceRequest], 'Driver'); + } + + if (!gpuOptions.enabled) { + return; + } + + const deviceRequest = existingDeviceRequest || { + Driver: driver, + Count: -1, + DeviceIDs: [], // must be empty if Count != 0 https://github.com/moby/moby/blob/master/daemon/nvidia_linux.go#L50 + Capabilities: [], // array of ORed arrays of ANDed capabilites = [ [c1 AND c2] OR [c1 AND c3] ] : https://github.com/moby/moby/blob/master/api/types/container/host_config.go#L272 + // Options: { property1: "string", property2: "string" }, // seems to never be evaluated/used in docker API ? + }; + + deviceRequest.DeviceIDs = gpuOptions.selectedGPUs; + deviceRequest.Count = 0; + deviceRequest.Capabilities = [gpuOptions.capabilities]; + + config.HostConfig.DeviceRequests.push(deviceRequest); + } + function prepareConfiguration() { var config = angular.copy($scope.config); prepareCmd(config); @@ -433,6 +476,7 @@ angular.module('portainer.docker').controller('CreateContainerController', [ prepareLogDriver(config); prepareCapabilities(config); prepareSysctls(config); + prepareGPUOptions(config); return config; } @@ -571,6 +615,24 @@ angular.module('portainer.docker').controller('CreateContainerController', [ $scope.config.HostConfig.Devices = path; } + function loadFromContainerDeviceRequests() { + const deviceRequest = _.find($scope.config.HostConfig.DeviceRequests, function (o) { + return o.Driver === 'nvidia' || o.Capabilities[0][0] === 'gpu'; + }); + if (deviceRequest) { + $scope.formValues.GPU.enabled = true; + $scope.formValues.GPU.useSpecific = deviceRequest.Count !== -1; + $scope.formValues.GPU.selectedGPUs = deviceRequest.DeviceIDs || []; + if ($scope.formValues.GPU.useSpecific) { + $scope.formValues.GPU.selectedGPUs = deviceRequest.DeviceIDs; + } + // we only support a single set of capabilities for now + // UI needs to be reworked in order to support OR combinations of AND capabilities + $scope.formValues.GPU.capabilities = deviceRequest.Capabilities[0]; + $scope.formValues.GPU = { ...$scope.formValues.GPU }; + } + } + function loadFromContainerSysctls() { for (var s in $scope.config.HostConfig.Sysctls) { if ({}.hasOwnProperty.call($scope.config.HostConfig.Sysctls, s)) { @@ -651,6 +713,7 @@ angular.module('portainer.docker').controller('CreateContainerController', [ loadFromContainerLabels(d); loadFromContainerConsole(d); loadFromContainerDevices(d); + loadFromContainerDeviceRequests(d); loadFromContainerImageConfig(d); loadFromContainerResources(d); loadFromContainerCapabilities(d); @@ -715,6 +778,8 @@ angular.module('portainer.docker').controller('CreateContainerController', [ function (d) { var containers = d; $scope.runningContainers = containers; + $scope.gpuUseAll = $scope.endpoint.Snapshots[0].GpuUseAll; + $scope.gpuUseList = $scope.endpoint.Snapshots[0].GpuUseList; if ($transition$.params().from) { loadFromContainerSpec(); } else { diff --git a/app/docker/views/containers/create/createcontainer.html b/app/docker/views/containers/create/createcontainer.html index 0fcc06f9e..a1e022279 100644 --- a/app/docker/views/containers/create/createcontainer.html +++ b/app/docker/views/containers/create/createcontainer.html @@ -693,6 +693,20 @@ + +
GPU
+ + + + +
Resources
diff --git a/app/docker/views/containers/edit/container.html b/app/docker/views/containers/edit/container.html index ff38b1c11..a7906360d 100644 --- a/app/docker/views/containers/edit/container.html +++ b/app/docker/views/containers/edit/container.html @@ -322,6 +322,10 @@ + + GPUS + {{ computeDockerGPUCommand() }} + diff --git a/app/docker/views/containers/edit/containerController.js b/app/docker/views/containers/edit/containerController.js index c8ff39333..0320d1ff2 100644 --- a/app/docker/views/containers/edit/containerController.js +++ b/app/docker/views/containers/edit/containerController.js @@ -77,6 +77,23 @@ angular.module('portainer.docker').controller('ContainerController', [ $state.reload(); }; + $scope.computeDockerGPUCommand = () => { + const gpuOptions = _.find($scope.container.HostConfig.DeviceRequests, function (o) { + return o.Driver === 'nvidia' || o.Capabilities[0][0] === 'gpu'; + }); + if (!gpuOptions) { + return 'No GPU config found'; + } + let gpuStr = 'all'; + if (gpuOptions.Count !== -1) { + gpuStr = `"device=${_.join(gpuOptions.DeviceIDs, ',')}"`; + } + // we only support a single set of capabilities for now + // creation UI needs to be reworked in order to support OR combinations of AND capabilities + const capStr = `"capabilities=${_.join(gpuOptions.Capabilities[0], ',')}"`; + return `${gpuStr},${capStr}`; + }; + var update = function () { var nodeName = $transition$.params().nodeName; HttpRequestHelper.setPortainerAgentTargetHeader(nodeName); diff --git a/app/docker/views/dashboard/dashboard.html b/app/docker/views/dashboard/dashboard.html index 7b6403ec6..a8d6ab8a6 100644 --- a/app/docker/views/dashboard/dashboard.html +++ b/app/docker/views/dashboard/dashboard.html @@ -53,6 +53,10 @@ URL {{ endpoint.URL | stripprotocol }} + + {{ endpoint.Gpus.length <= 1 ? 'GPU' : 'GPUs' }} + {{ gpuInfoStr }} + Tags {{ endpointTags }} @@ -97,5 +101,9 @@ + +
+ +
diff --git a/app/docker/views/dashboard/dashboardController.js b/app/docker/views/dashboard/dashboardController.js index 7461c1ce8..4dddc944e 100644 --- a/app/docker/views/dashboard/dashboardController.js +++ b/app/docker/views/dashboard/dashboardController.js @@ -44,6 +44,36 @@ angular.module('portainer.docker').controller('DashboardController', [ $scope.offlineMode = false; $scope.showStacks = false; + $scope.buildGpusStr = function (gpuUseSet) { + var gpusAvailable = new Object(); + for (let i = 0; i < $scope.endpoint.Gpus.length; i++) { + if (!gpuUseSet.has($scope.endpoint.Gpus[i].name)) { + var exist = false; + for (let gpuAvailable in gpusAvailable) { + if ($scope.endpoint.Gpus[i].value == gpuAvailable) { + gpusAvailable[gpuAvailable] += 1; + exist = true; + } + } + if (exist === false) { + gpusAvailable[$scope.endpoint.Gpus[i].value] = 1; + } + } + } + var retStr = Object.keys(gpusAvailable).length + ? _.join( + _.map(Object.keys(gpusAvailable), (gpuAvailable) => { + var _str = gpusAvailable[gpuAvailable]; + _str += ' x '; + _str += gpuAvailable; + return _str; + }), + ' + ' + ) + : 'none'; + return retStr; + }; + async function initView() { const endpointMode = $scope.applicationState.endpoint.mode; $scope.endpoint = endpoint; @@ -72,6 +102,14 @@ angular.module('portainer.docker').controller('DashboardController', [ $scope.serviceCount = data.services.length; $scope.stackCount = data.stacks.length; $scope.info = data.info; + + $scope.gpuInfoStr = $scope.buildGpusStr(new Set()); + $scope.gpuUseAll = _.get($scope, 'endpoint.Snapshots[0].GpuUseAll', false); + $scope.gpuUseList = _.get($scope, 'endpoint.Snapshots[0].GpuUseList', []); + $scope.gpuFreeStr = 'all'; + if ($scope.gpuUseAll == true) $scope.gpuFreeStr = 'none'; + else $scope.gpuFreeStr = $scope.buildGpusStr(new Set($scope.gpuUseList)); + $scope.endpointTags = endpoint.TagIds.length ? _.join( _.filter( diff --git a/app/portainer/environments/environment.service/create.ts b/app/portainer/environments/environment.service/create.ts index a310c0055..42630c4c0 100644 --- a/app/portainer/environments/environment.service/create.ts +++ b/app/portainer/environments/environment.service/create.ts @@ -1,3 +1,4 @@ +import { Gpu } from '@/react/portainer/environments/wizard/EnvironmentsCreationView/shared/Hardware/GpusList'; import axios, { parseAxiosError } from '@/portainer/services/axios'; import { type EnvironmentGroupId } from '@/portainer/environment-groups/types'; import { type TagId } from '@/portainer/tags/types'; @@ -16,6 +17,7 @@ interface CreateLocalDockerEnvironment { socketPath?: string; publicUrl?: string; meta?: EnvironmentMetadata; + gpus?: Gpu[]; } export async function createLocalDockerEnvironment({ @@ -23,6 +25,7 @@ export async function createLocalDockerEnvironment({ socketPath = '', publicUrl = '', meta = { tagIds: [] }, + gpus = [], }: CreateLocalDockerEnvironment) { const url = prefixPath(socketPath); @@ -33,6 +36,7 @@ export async function createLocalDockerEnvironment({ url, publicUrl, meta, + gpus, } ); @@ -105,6 +109,7 @@ export interface EnvironmentOptions { azure?: AzureSettings; tls?: TLSSettings; isEdgeDevice?: boolean; + gpus?: Gpu[]; } interface CreateRemoteEnvironment { @@ -133,6 +138,7 @@ export interface CreateAgentEnvironmentValues { name: string; environmentUrl: string; meta: EnvironmentMetadata; + gpus: Gpu[]; } export function createAgentEnvironment({ @@ -159,12 +165,14 @@ interface CreateEdgeAgentEnvironment { portainerUrl: string; meta?: EnvironmentMetadata; pollFrequency: number; + gpus?: Gpu[]; } export function createEdgeAgentEnvironment({ name, portainerUrl, meta = { tagIds: [] }, + gpus = [], }: CreateEdgeAgentEnvironment) { return createEnvironment( name, @@ -176,6 +184,7 @@ export function createEdgeAgentEnvironment({ skipVerify: true, skipClientVerify: true, }, + gpus, } ); } @@ -201,6 +210,7 @@ async function createEnvironment( TagIds: arrayToJson(tagIds), CheckinInterval: options.checkinInterval, IsEdgeDevice: options.isEdgeDevice, + Gpus: arrayToJson(options.gpus), }; const { tls, azure } = options; diff --git a/app/portainer/environments/types.ts b/app/portainer/environments/types.ts index b47dad54e..c3b13f96d 100644 --- a/app/portainer/environments/types.ts +++ b/app/portainer/environments/types.ts @@ -40,6 +40,8 @@ export interface DockerSnapshot { ServiceCount: number; Swarm: boolean; DockerVersion: string; + GpuUseAll: boolean; + GpuUseList: string[]; } export interface KubernetesSnapshot { @@ -103,6 +105,7 @@ export type Environment = { AMTDeviceGUID?: string; Edge: EnvironmentEdge; SecuritySettings: EnvironmentSecuritySettings; + Gpus: { name: string; value: string }[]; }; /** * TS reference of endpoint_create.go#EndpointCreationType iota diff --git a/app/portainer/home/EnvironmentList/EnvironmentItem/EnvironmentItem.tsx b/app/portainer/home/EnvironmentList/EnvironmentItem/EnvironmentItem.tsx index fc39961ad..1518453e6 100644 --- a/app/portainer/home/EnvironmentList/EnvironmentItem/EnvironmentItem.tsx +++ b/app/portainer/home/EnvironmentList/EnvironmentItem/EnvironmentItem.tsx @@ -100,6 +100,8 @@ export function EnvironmentItem({ environment, onClick, groupName }: Props) { {environment.Snapshots[0].TotalCPU} {humanize(environment.Snapshots[0].TotalMemory)} + + {environment.Gpus.length} )} - diff --git a/app/portainer/views/endpoints/edit/endpoint.html b/app/portainer/views/endpoints/edit/endpoint.html index 1d9e3b95a..53ebad509 100644 --- a/app/portainer/views/endpoints/edit/endpoint.html +++ b/app/portainer/views/endpoints/edit/endpoint.html @@ -222,6 +222,10 @@ + +
Hardware acceleration
+ +