All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.spotify.styx.util.BasicWorkflowValidator Maven / Gradle / Ivy

/*
 * -\-\-
 * Spotify Styx Service Common
 * --
 * Copyright (C) 2019 Spotify AB
 * --
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 * 
 *      http://www.apache.org/licenses/LICENSE-2.0
 * 
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 * -/-/-
 */

package com.spotify.styx.util;

import static com.spotify.styx.util.WorkflowValidator.lowerLimit;
import static com.spotify.styx.util.WorkflowValidator.upperLimit;
import static java.lang.String.format;

import com.spotify.styx.model.Workflow;
import com.spotify.styx.model.WorkflowConfiguration;
import java.time.Duration;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.regex.Pattern;
import org.springframework.expression.spel.standard.SpelExpressionParser;

public class BasicWorkflowValidator implements WorkflowValidator {

  private static final int MAX_ID_LENGTH = 256;
  private static final int MAX_DOCKER_ARGS_TOTAL = 1000000;
  private static final int MAX_RESOURCES = 5;
  private static final int MAX_RESOURCE_LENGTH = 256;
  private static final int MAX_COMMIT_SHA_LENGTH = 256;
  private static final int MAX_SERVICE_ACCOUNT_LENGTH = 256;
  private static final int MAX_RETRY_CONDITION_LENGTH = 256;
  private static final int MAX_ENV_VARS = 128;
  private static final int MAX_ENV_SIZE = 16 * 1024;
  private static final Pattern VALID_EMAIL_ADDRESS_REGEX =
      Pattern.compile("^[_A-Za-z0-9-+]+(\\.[_A-Za-z0-9-]+)*@[A-Za-z0-9-]+(\\.[A-Za-z0-9]+)*(\\.[A-Za-z]{2,})$",
          Pattern.CASE_INSENSITIVE);
  private static final String RESOURCE_TYPE_LAUNCH_PLAN = "LAUNCH_PLAN";

  private static final Duration MIN_RUNNING_TIMEOUT = Duration.ofMinutes(1);
  private final DockerImageValidator dockerImageValidator;

  public BasicWorkflowValidator(DockerImageValidator dockerImageValidator) {
    this.dockerImageValidator = Objects.requireNonNull(dockerImageValidator);
  }

  @Override
  public List validateWorkflow(Workflow workflow) {
    var workflowId = workflow.id();
    var cfg = workflow.configuration();

    final List e = new ArrayList<>();

    var componentId = workflowId.componentId();

    empty(e, "component id", workflowId.componentId());
    if (componentId.contains("#")) {
      e.add("component id cannot contain #");
    }

    empty(e, "workflow id", workflowId.id());

    if (!workflowId.id().equals(cfg.id())) {
      e.add("workflow id mismatch");
    }

    upperLimit(e, cfg.id().length(),
        MAX_ID_LENGTH, "id too long");
    upperLimit(e, cfg.commitSha().map(String::length).orElse(0),
        MAX_COMMIT_SHA_LENGTH, "commitSha too long");
    upperLimit(e, cfg.serviceAccount().map(String::length).orElse(0),
        MAX_SERVICE_ACCOUNT_LENGTH, "service account too long");
    upperLimit(e, cfg.resources().size(),
        MAX_RESOURCES, "too many resources");
    upperLimit(e, cfg.env().size(),
        MAX_ENV_VARS, "too many env vars");
    upperLimit(e, cfg.env().entrySet().stream()
            .mapToInt(entry -> entry.getKey().length() + entry.getValue().length()).sum(),
        MAX_ENV_SIZE, "env too big");
    upperLimit(e, cfg.retryCondition().map(String::length).orElse(0),
        MAX_RETRY_CONDITION_LENGTH, "retry condition too long");

    cfg.resources().stream().map(String::length).forEach(v ->
        upperLimit(e, v, MAX_RESOURCE_LENGTH, "resource name too long"));

    cfg.offset().ifPresent(offset -> {
      try {
        TimeUtil.addOffset(ZonedDateTime.now(ZoneOffset.UTC), offset);
      } catch (DateTimeParseException ex) {
        e.add(format("invalid offset: %s", ex.getMessage()));
      }
    });

    try {
      TimeUtil.cron(cfg.schedule());
    } catch (IllegalArgumentException ex) {
      e.add("invalid schedule");
    }

    cfg.runningTimeout().ifPresent(timeout ->
        lowerLimit(e, timeout, MIN_RUNNING_TIMEOUT, "running timeout is too small"));

    cfg.serviceAccount().ifPresent(serviceAccount -> {
      if (!validateServiceAccount(serviceAccount)) {
        e.add("service account is not a valid email address: " + serviceAccount);
      }
    });

    cfg.retryCondition().ifPresent(retryCondition -> {
      try {
        new SpelExpressionParser().parseRaw(retryCondition);
      } catch (RuntimeException ex) {
        e.add(format("invalid retry condition: %s", ex.getMessage()));
      }
    });

    validateConflictingExecConf(e, cfg);
    validateDockerConf(e, cfg);
    validateFlyteExecConf(e, cfg);

    return e;
  }

  private static boolean validateServiceAccount(String serviceAccount) {
    var matcher = VALID_EMAIL_ADDRESS_REGEX.matcher(serviceAccount);
    return matcher.matches();
  }

  private void validateConflictingExecConf(List e, WorkflowConfiguration cfg) {
    var hasDockerConf = cfg.dockerImage().isPresent() || cfg.dockerArgs().isPresent();
    if (cfg.flyteExecConf().isPresent() && hasDockerConf) {
      e.add("configuration cannot specify both docker and flyte parameters");
    }
  }

  private void validateDockerConf(List errors, WorkflowConfiguration config) {
    config.dockerArgs().ifPresent(args -> validateDockerArgs(errors, args));
    config.dockerImage().ifPresent(image -> validateDockerImage(errors, image));
  }

  private void validateDockerImage(List errors, String image) {
    dockerImageValidator.validateImageReference(image)
        .forEach(error-> errors.add("invalid image: " + error));
  }

  private void validateDockerArgs(List errors, List args) {
    final int dockerArgs = args.size() + args.stream().mapToInt(String::length).sum();
    upperLimit(errors, dockerArgs, MAX_DOCKER_ARGS_TOTAL, "docker args is too large");
  }

  private void validateFlyteExecConf(List errors, WorkflowConfiguration config) {
    config.flyteExecConf().ifPresent(flyteExecConf -> {
      var flyteIdentifier = flyteExecConf.referenceId();

      var resourceType = flyteIdentifier.resourceType();
      if (!RESOURCE_TYPE_LAUNCH_PLAN.equals(resourceType)) {
        errors.add(
            "only launch plans (\"" + RESOURCE_TYPE_LAUNCH_PLAN + "\") are supported as resource type, but received: "
            + resourceType);
      }

      empty(errors, "domain", flyteIdentifier.domain());
      empty(errors, "project", flyteIdentifier.project());
      empty(errors, "name", flyteIdentifier.name());
      empty(errors, "version", flyteIdentifier.version());
    });
  }

  private void empty(List errors, String fieldName, String value) {
    if (value.isEmpty()) {
      errors.add(fieldName + " cannot be empty");
    }
  }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy