portainer/portainer

View on GitHub
app/docker/views/services/create/createServiceController.js

Summary

Maintainability
F
4 days
Test Coverage
import _ from 'lodash-es';

import * as envVarsUtils from '@/react/components/form-components/EnvironmentVariablesFieldset/utils';
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
import { AccessControlFormData } from '../../../../portainer/components/accessControlForm/porAccessControlFormModel';

require('./includes/update-restart.html');
require('./includes/secret.html');
require('./includes/config.html');
require('./includes/resources-placement.html');

angular.module('portainer.docker').controller('CreateServiceController', [
  '$q',
  '$scope',
  '$state',
  '$timeout',
  'Service',
  'ServiceHelper',
  'ConfigService',
  'ConfigHelper',
  'SecretHelper',
  'SecretService',
  'VolumeService',
  'NetworkService',
  'ImageHelper',
  'LabelHelper',
  'Authentication',
  'ResourceControlService',
  'Notifications',
  'FormValidator',
  'PluginService',
  'RegistryService',
  'HttpRequestHelper',
  'NodeService',
  'WebhookService',
  'endpoint',
  function (
    $q,
    $scope,
    $state,
    $timeout,
    Service,
    ServiceHelper,
    ConfigService,
    ConfigHelper,
    SecretHelper,
    SecretService,
    VolumeService,
    NetworkService,
    ImageHelper,
    LabelHelper,
    Authentication,
    ResourceControlService,
    Notifications,
    FormValidator,
    PluginService,
    RegistryService,
    HttpRequestHelper,
    NodeService,
    WebhookService,
    endpoint
  ) {
    $scope.endpoint = endpoint;

    $scope.formValues = {
      Name: '',
      RegistryModel: new PorImageRegistryModel(),
      Mode: 'replicated',
      Replicas: 1,
      Command: '',
      EntryPoint: '',
      WorkingDir: '',
      User: '',
      Env: [],
      Labels: [],
      ContainerLabels: [],
      Volumes: [],
      Network: '',
      ExtraNetworks: [],
      HostsEntries: [],
      Ports: [],
      Parallelism: 1,
      PlacementConstraints: [],
      PlacementPreferences: [],
      UpdateDelay: '0s',
      UpdateOrder: 'stop-first',
      FailureAction: 'pause',
      Secrets: [],
      Configs: [],
      AccessControlData: new AccessControlFormData(),
      CpuLimit: 0,
      CpuReservation: 0,
      MemoryLimit: 0,
      MemoryReservation: 0,
      MemoryLimitUnit: 'MB',
      MemoryReservationUnit: 'MB',
      RestartCondition: 'any',
      RestartDelay: '5s',
      RestartMaxAttempts: 0,
      RestartWindow: '0s',
      LogDriverName: '',
      LogDriverOpts: [],
      Webhook: false,
    };

    $scope.state = {
      formValidationError: '',
      actionInProgress: false,
      pullImageValidity: false,
    };

    $scope.allowBindMounts = false;

    $scope.handleWebHookChange = handleWebHookChange;

    function handleWebHookChange(checked) {
      return $scope.$evalAsync(() => {
        $scope.formValues.Webhook = checked;
      });
    }

    $scope.handleEnvVarChange = handleEnvVarChange;
    function handleEnvVarChange(value) {
      $scope.formValues.Env = value;
    }

    $scope.refreshSlider = function () {
      $timeout(function () {
        $scope.$broadcast('rzSliderForceRender');
      });
    };

    $scope.setPullImageValidity = setPullImageValidity;
    function setPullImageValidity(validity) {
      $scope.state.pullImageValidity = validity;
    }

    $scope.addPortBinding = function () {
      $scope.formValues.Ports.push({ PublishedPort: '', TargetPort: '', Protocol: 'tcp', PublishMode: 'ingress' });
    };

    $scope.removePortBinding = function (index) {
      $scope.formValues.Ports.splice(index, 1);
    };

    $scope.addExtraNetwork = function () {
      $scope.formValues.ExtraNetworks.push({ Name: '' });
    };

    $scope.removeExtraNetwork = function (index) {
      $scope.formValues.ExtraNetworks.splice(index, 1);
    };

    $scope.addHostsEntry = function () {
      $scope.formValues.HostsEntries.push({});
    };

    $scope.removeHostsEntry = function (index) {
      $scope.formValues.HostsEntries.splice(index, 1);
    };

    $scope.addVolume = function () {
      $scope.formValues.Volumes.push({ Source: null, Target: '', ReadOnly: false, Type: 'volume' });
    };

    $scope.removeVolume = function (index) {
      $scope.formValues.Volumes.splice(index, 1);
    };

    $scope.addConfig = function () {
      $scope.formValues.Configs.push({});
    };

    $scope.removeConfig = function (index) {
      $scope.formValues.Configs.splice(index, 1);
      $scope.checkIfConfigDuplicated();
    };

    $scope.addSecret = function () {
      $scope.formValues.Secrets.push({ overrideTarget: false });
    };

    $scope.removeSecret = function (index) {
      $scope.formValues.Secrets.splice(index, 1);
      $scope.checkIfSecretDuplicated();
    };

    $scope.addPlacementConstraint = function () {
      $scope.formValues.PlacementConstraints.push({ key: '', operator: '==', value: '' });
    };

    $scope.removePlacementConstraint = function (index) {
      $scope.formValues.PlacementConstraints.splice(index, 1);
    };

    $scope.addPlacementPreference = function () {
      $scope.formValues.PlacementPreferences.push({ strategy: 'spread', value: '' });
    };

    $scope.removePlacementPreference = function (index) {
      $scope.formValues.PlacementPreferences.splice(index, 1);
    };

    $scope.addLabel = function () {
      $scope.formValues.Labels.push({ key: '', value: '' });
    };

    $scope.removeLabel = function (index) {
      $scope.formValues.Labels.splice(index, 1);
    };

    $scope.addContainerLabel = function () {
      $scope.formValues.ContainerLabels.push({ key: '', value: '' });
    };

    $scope.removeContainerLabel = function (index) {
      $scope.formValues.ContainerLabels.splice(index, 1);
    };

    $scope.addLogDriverOpt = function () {
      $scope.formValues.LogDriverOpts.push({ name: '', value: '' });
    };

    $scope.removeLogDriverOpt = function (index) {
      $scope.formValues.LogDriverOpts.splice(index, 1);
    };

    $scope.checkIfSecretDuplicated = function () {
      $scope.formValues.Secrets.$invalid = false;
      [...$scope.formValues.Secrets]
        .sort((a, b) => a.model.Id.localeCompare(b.model.Id))
        .sort((a, b) => {
          if (a.model.Id === b.model.Id) {
            $scope.formValues.Secrets.$invalid = true;
            $scope.formValues.Secrets.$error = 'Secret ' + a.model.Name + ' cannot be assigned multiple times.';
          }
        });
      if (!$scope.formValues.Secrets.$invalid) {
        $scope.formValues.Secrets.$error = '';
      }
    };

    $scope.checkIfConfigDuplicated = function () {
      $scope.formValues.Configs.$invalid = false;
      [...$scope.formValues.Configs]
        .sort((a, b) => a.model.Id.localeCompare(b.model.Id))
        .sort((a, b) => {
          if (a.model.Id === b.model.Id) {
            $scope.formValues.Configs.$invalid = true;
            $scope.formValues.Configs.$error = 'Config ' + a.model.Name + ' cannot be assigned multiple times.';
          }
        });
      if (!$scope.formValues.Configs.$invalid) {
        $scope.formValues.Configs.$error = '';
      }
    };

    function prepareImageConfig(config, input) {
      var imageConfig = ImageHelper.createImageConfigForContainer(input.RegistryModel);
      config.TaskTemplate.ContainerSpec.Image = imageConfig.fromImage;
    }

    function preparePortsConfig(config, input) {
      let ports = [];
      input.Ports.forEach(function (binding) {
        const port = {
          Protocol: binding.Protocol,
          PublishMode: binding.PublishMode,
        };
        if (binding.TargetPort) {
          port.TargetPort = +binding.TargetPort;
          if (binding.PublishedPort) {
            port.PublishedPort = +binding.PublishedPort;
          }
          ports.push(port);
        }
      });
      config.EndpointSpec.Ports = ports;
    }

    function prepareSchedulingConfig(config, input) {
      if (input.Mode === 'replicated') {
        config.Mode.Replicated = {
          Replicas: input.Replicas,
        };
      } else {
        config.Mode.Global = {};
      }
    }

    function commandToArray(cmd) {
      var tokens = [].concat
        .apply(
          [],
          cmd.split("'").map(function (v, i) {
            return i % 2 ? v : v.split(' ');
          })
        )
        .filter(Boolean);
      return tokens;
    }

    function prepareCommandConfig(config, input) {
      if (input.EntryPoint) {
        config.TaskTemplate.ContainerSpec.Command = commandToArray(input.EntryPoint);
      }
      if (input.Command) {
        config.TaskTemplate.ContainerSpec.Args = commandToArray(input.Command);
      }
      if (input.User) {
        config.TaskTemplate.ContainerSpec.User = input.User;
      }
      if (input.WorkingDir) {
        config.TaskTemplate.ContainerSpec.Dir = input.WorkingDir;
      }
    }

    function prepareEnvConfig(config, input) {
      config.TaskTemplate.ContainerSpec.Env = envVarsUtils.convertToArrayOfStrings(input.Env);
    }

    function prepareLabelsConfig(config, input) {
      config.Labels = LabelHelper.fromKeyValueToLabelHash(input.Labels);
      config.TaskTemplate.ContainerSpec.Labels = LabelHelper.fromKeyValueToLabelHash(input.ContainerLabels);
    }

    function createMountObjectFromVolume(volumeObject, target, readonly) {
      return {
        Target: target,
        Source: volumeObject.Id,
        Type: 'volume',
        ReadOnly: readonly,
        VolumeOptions: {
          Labels: volumeObject.Labels,
          DriverConfig: {
            Name: volumeObject.Driver,
            Options: volumeObject.Options,
          },
        },
      };
    }

    function prepareVolumes(config, input) {
      input.Volumes.forEach(function (volume) {
        if (volume.Source && volume.Target) {
          if (volume.Type !== 'volume') {
            config.TaskTemplate.ContainerSpec.Mounts.push(volume);
          } else {
            var volumeObject = volume.Source;
            var mount = createMountObjectFromVolume(volumeObject, volume.Target, volume.ReadOnly);
            config.TaskTemplate.ContainerSpec.Mounts.push(mount);
          }
        }
      });
    }

    function prepareNetworks(config, input) {
      var networks = [];
      if (input.Network) {
        networks.push({ Target: input.Network });
      }
      input.ExtraNetworks.forEach(function (network) {
        networks.push({ Target: network.Name });
      });
      config.Networks = _.uniqWith(networks, _.isEqual);
    }

    function prepareHostsEntries(config, input) {
      var hostsEntries = [];
      if (input.HostsEntries) {
        input.HostsEntries.forEach(function (host_ip) {
          if (host_ip.value && host_ip.value.indexOf(':') && host_ip.value.split(':').length === 2) {
            var keyVal = host_ip.value.split(':');
            // Hosts file format, IP_address canonical_hostname
            hostsEntries.push(keyVal[1] + ' ' + keyVal[0]);
          }
        });
        if (hostsEntries.length > 0) {
          config.TaskTemplate.ContainerSpec.Hosts = hostsEntries;
        }
      }
    }

    function prepareUpdateConfig(config, input) {
      config.UpdateConfig = {
        Parallelism: input.Parallelism || 0,
        Delay: ServiceHelper.translateHumanDurationToNanos(input.UpdateDelay) || 0,
        FailureAction: input.FailureAction,
        Order: input.UpdateOrder,
      };
    }

    function prepareRestartPolicy(config, input) {
      config.TaskTemplate.RestartPolicy = {
        Condition: input.RestartCondition || 'any',
        Delay: ServiceHelper.translateHumanDurationToNanos(input.RestartDelay) || 5000000000,
        MaxAttempts: input.RestartMaxAttempts || 0,
        Window: ServiceHelper.translateHumanDurationToNanos(input.RestartWindow) || 0,
      };
    }

    function preparePlacementConfig(config, input) {
      config.TaskTemplate.Placement.Constraints = ServiceHelper.translateKeyValueToPlacementConstraints(input.PlacementConstraints);
      config.TaskTemplate.Placement.Preferences = ServiceHelper.translateKeyValueToPlacementPreferences(input.PlacementPreferences);
    }

    function prepareConfigConfig(config, input) {
      if (input.Configs) {
        var configs = [];
        angular.forEach(input.Configs, function (config) {
          if (config.model) {
            var s = ConfigHelper.configConfig(config.model);
            s.File.Name = config.FileName || s.File.Name;
            configs.push(s);
          }
        });
        config.TaskTemplate.ContainerSpec.Configs = configs;
      }
    }

    function prepareSecretConfig(config, input) {
      if (input.Secrets) {
        var secrets = [];
        angular.forEach(input.Secrets, function (secret) {
          if (secret.model) {
            var s = SecretHelper.secretConfig(secret.model);
            s.File.Name = s.SecretName;
            if (secret.overrideTarget && secret.target && secret.target !== '') {
              s.File.Name = secret.target;
            }
            secrets.push(s);
          }
        });
        config.TaskTemplate.ContainerSpec.Secrets = secrets;
      }
    }

    function prepareResourcesCpuConfig(config, input) {
      // CPU Limit
      if (input.CpuLimit > 0) {
        config.TaskTemplate.Resources.Limits.NanoCPUs = input.CpuLimit * 1000000000;
      }
      // CPU Reservation
      if (input.CpuReservation > 0) {
        config.TaskTemplate.Resources.Reservations.NanoCPUs = input.CpuReservation * 1000000000;
      }
    }

    function prepareResourcesMemoryConfig(config, input) {
      // Memory Limit - Round to 0.125
      var memoryLimit = (Math.round(input.MemoryLimit * 8) / 8).toFixed(3);
      memoryLimit *= 1024 * 1024;
      if (input.MemoryLimitUnit === 'GB') {
        memoryLimit *= 1024;
      }
      if (memoryLimit > 0) {
        config.TaskTemplate.Resources.Limits.MemoryBytes = memoryLimit;
      }
      // Memory Resevation - Round to 0.125
      var memoryReservation = (Math.round(input.MemoryReservation * 8) / 8).toFixed(3);
      memoryReservation *= 1024 * 1024;
      if (input.MemoryReservationUnit === 'GB') {
        memoryReservation *= 1024;
      }
      if (memoryReservation > 0) {
        config.TaskTemplate.Resources.Reservations.MemoryBytes = memoryReservation;
      }
    }

    function prepareLogDriverConfig(config, input) {
      var logOpts = {};
      if (input.LogDriverName) {
        config.TaskTemplate.LogDriver = { Name: input.LogDriverName };
        if (input.LogDriverName !== 'none') {
          input.LogDriverOpts.forEach(function (opt) {
            if (opt.name) {
              logOpts[opt.name] = opt.value;
            }
          });
          if (Object.keys(logOpts).length !== 0 && logOpts.constructor === Object) {
            config.TaskTemplate.LogDriver.Options = logOpts;
          }
        }
      }
    }

    function prepareConfiguration() {
      var input = $scope.formValues;
      var config = {
        Name: input.Name,
        TaskTemplate: {
          ContainerSpec: {
            Mounts: [],
          },
          Placement: {},
          Resources: {
            Limits: {},
            Reservations: {},
          },
        },
        Mode: {},
        EndpointSpec: {},
      };
      prepareSchedulingConfig(config, input);
      prepareImageConfig(config, input);
      preparePortsConfig(config, input);
      prepareCommandConfig(config, input);
      prepareEnvConfig(config, input);
      prepareLabelsConfig(config, input);
      prepareVolumes(config, input);
      prepareNetworks(config, input);
      prepareHostsEntries(config, input);
      prepareUpdateConfig(config, input);
      prepareConfigConfig(config, input);
      prepareSecretConfig(config, input);
      preparePlacementConfig(config, input);
      prepareResourcesCpuConfig(config, input);
      prepareResourcesMemoryConfig(config, input);
      prepareRestartPolicy(config, input);
      prepareLogDriverConfig(config, input);
      return config;
    }

    function createNewService(config, accessControlData) {
      const registryModel = $scope.formValues.RegistryModel;
      var authenticationDetails = registryModel.Registry.Authentication ? RegistryService.encodedCredentials(registryModel.Registry) : '';
      HttpRequestHelper.setRegistryAuthenticationHeader(authenticationDetails);

      Service.create(config)
        .$promise.then(function success(data) {
          const serviceId = data.ID;
          const resourceControl = data.Portainer.ResourceControl;
          const userId = Authentication.getUserDetails().ID;
          const rcPromise = ResourceControlService.applyResourceControl(userId, accessControlData, resourceControl);
          const registryID = $scope.formValues.RegistryModel.Registry.Id;
          const webhookPromise = $q.when(endpoint.Type !== 4 && $scope.formValues.Webhook && WebhookService.createServiceWebhook(serviceId, endpoint.Id, registryID));
          return $q.all([rcPromise, webhookPromise]);
        })
        .then(function success() {
          Notifications.success('Success', 'Service successfully created');
          $state.go('docker.services', {}, { reload: true });
        })
        .catch(function error(err) {
          Notifications.error('Failure', err, 'Unable to create service');
        })
        .finally(function final() {
          $scope.state.actionInProgress = false;
        });
    }

    function validateForm(accessControlData, isAdmin) {
      $scope.state.formValidationError = '';
      var error = '';
      error = FormValidator.validateAccessControl(accessControlData, isAdmin) || $scope.formValues.Secrets.$error || $scope.formValues.Configs.$error;

      if (error) {
        $scope.state.formValidationError = error;
        return false;
      }
      return true;
    }

    $scope.volumesAreValid = volumesAreValid;
    function volumesAreValid() {
      const volumes = $scope.formValues.Volumes;
      return volumes.every((volume) => volume.Target && volume.Source);
    }

    $scope.create = function createService() {
      var accessControlData = $scope.formValues.AccessControlData;

      if (!validateForm(accessControlData, $scope.isAdmin)) {
        return;
      }

      $scope.state.actionInProgress = true;
      var config = prepareConfiguration();
      createNewService(config, accessControlData);
    };

    function initSlidersMaxValuesBasedOnNodeData(nodes) {
      var maxCpus = 0;
      var maxMemory = 0;
      for (var n in nodes) {
        if (nodes[n].CPUs && nodes[n].CPUs > maxCpus) {
          maxCpus = nodes[n].CPUs;
        }
        if (nodes[n].Memory && nodes[n].Memory > maxMemory) {
          maxMemory = nodes[n].Memory;
        }
      }
      if (maxCpus > 0) {
        $scope.state.sliderMaxCpu = maxCpus / 1000000000;
      } else {
        $scope.state.sliderMaxCpu = 32;
      }
      if (maxMemory > 0) {
        $scope.state.sliderMaxMemory = Math.floor(maxMemory / 1000 / 1000);
      } else {
        $scope.state.sliderMaxMemory = 32768;
      }
    }

    function initView() {
      var apiVersion = $scope.applicationState.endpoint.apiVersion;

      $q.all({
        volumes: VolumeService.volumes(),
        networks: NetworkService.networks(true, true, false),
        secrets: apiVersion >= 1.25 ? SecretService.secrets() : [],
        configs: apiVersion >= 1.3 ? ConfigService.configs(endpoint.Id) : [],
        nodes: NodeService.nodes(),
        availableLoggingDrivers: PluginService.loggingPlugins(apiVersion < 1.25),
        allowBindMounts: checkIfAllowedBindMounts(),
      })
        .then(function success(data) {
          $scope.availableVolumes = data.volumes;
          $scope.availableNetworks = data.networks;
          $scope.availableSecrets = data.secrets;
          $scope.availableConfigs = data.configs;
          $scope.availableLoggingDrivers = data.availableLoggingDrivers;
          initSlidersMaxValuesBasedOnNodeData(data.nodes);
          $scope.isAdmin = Authentication.isAdmin();
          $scope.allowBindMounts = data.allowBindMounts;
        })
        .catch(function error(err) {
          Notifications.error('Failure', err, 'Unable to initialize view');
        });
    }

    initView();

    async function checkIfAllowedBindMounts() {
      const isAdmin = Authentication.isAdmin();

      const { allowBindMountsForRegularUsers } = endpoint.SecuritySettings;

      return isAdmin || allowBindMountsForRegularUsers;
    }
  },
]);