org.ligoj.app.plugin.prov.aws.ProvAwsTerraformService.java Source code

Java tutorial

Introduction

Here is the source code for org.ligoj.app.plugin.prov.aws.ProvAwsTerraformService.java

Source

/*
  * Licensed under MIT (https://github.com/ligoj/ligoj/blob/master/LICENSE)
 */
package org.ligoj.app.plugin.prov.aws;

import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.EnumMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;

import org.apache.commons.io.IOUtils;
import org.apache.commons.io.output.FileWriterWithEncoding;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.StringUtils;
import org.ligoj.app.model.Project;
import org.ligoj.app.model.Subscription;
import org.ligoj.app.plugin.prov.model.AbstractQuoteResource;
import org.ligoj.app.plugin.prov.model.ProvLocation;
import org.ligoj.app.plugin.prov.model.ProvQuoteInstance;
import org.ligoj.app.plugin.prov.model.ProvQuoteStorage;
import org.ligoj.app.plugin.prov.model.VmOs;
import org.ligoj.app.plugin.prov.terraform.Context;
import org.ligoj.app.plugin.prov.terraform.InstanceMode;
import org.ligoj.app.plugin.prov.terraform.TerraformUtils;
import org.ligoj.app.resource.subscription.SubscriptionResource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Service;

/**
 * Service in charge of Terraform generation for AWS.
 */
@Service
public class ProvAwsTerraformService {

    private static final String CLOUD_WATCH_ELB = "AWS/ApplicationELB";

    private static final String INDEX = "{{i}}";

    /**
     * Mapping between OS name and AMI base name handled by Terraform.
     */
    private Map<VmOs, String> mappingOsAmi = new EnumMap<>(VmOs.class);

    /**
     * Mapping between OS name and root device name.
     */
    private final Map<VmOs, String> mappingOsRootDevice = new EnumMap<>(VmOs.class);

    /**
     * Mapping between OS name and EBS device name. The value is a format using the last char and increment it by the
     * index (base 0). Sample, for index <code>4</code>:
     * <ul>
     * <li>Format <code>/dev/sda1</code> gives <code>/dev/sda4</code></li>
     * <li>Format <code>/dev/xvda</code> gives <code>/dev/xvdj</code></li>
     * </ul>
     * Note that the root device does not use this format. The first non root EBS device has index <code>0</code>.
     *
     * @see <a href="https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/device_naming.html">device_naming.html</a> for
     *      recommendations.
     * @see <a href="https://docs.aws.amazon.com/AWSEC2/latest/WindowsGuide/device_naming.html">device_naming.html</a>
     *      for recommendations.
     *
     */
    private final Map<VmOs, String> mappingOsEbsDevice = new EnumMap<>(VmOs.class);

    /**
     * Default Root device name.
     */
    private static final String DEFAULT_ROOT_DEVICE = "/dev/sda1";

    /**
     * Default EBS device format. See {@link #mappingOsEbsDevice} for more details.
     */
    private static final String DEFAULT_EBS_DEVICE = "/dev/sdf";

    public ProvAwsTerraformService() {
        // AMIs
        mappingOsAmi.put(VmOs.SUSE, "suse-sles");
        mappingOsAmi.put(VmOs.LINUX, "amazon");

        // Root devices
        mappingOsRootDevice.put(VmOs.LINUX, "/dev/xvda"); // AMZ

        // EBS devices
        mappingOsEbsDevice.put(VmOs.WINDOWS, "xvdf"); // only Windows
    }

    @Autowired
    private SubscriptionResource subscriptionResource;

    @Autowired
    protected TerraformUtils utils;

    /**
     * Generate the Terraform configuration files:
     * <ul>
     * <li><code>./terraform.tfvars</code> the project, public keys, customizations and subscription variables.</li>
     * <li><code>./secrets.auto.tfvars</code> the secret variables, cannot be downloaded.</li>
     * <li><code>./$my-region.tf</code> the region specific configuration.</li>
     * <li><code>./$my-region/instance-$instance.tf</code> for instances of this quote in a region.</li>
     * <li><code>./$my-region/dashboard-*</code> for dashboard in a region.</li>
     * </ul>
     * Static files are:
     * <ul>
     * <li><code>./main.tf</code> declaring global configuration.</li>
     * <li><code>./variables.tf</code> declaring global variables.</li>
     * <li><code>./$my-region.tf</code> the region specific configuration.</li>
     * <li><code>./$my-region/variables.tf</code> a specific region variables.</li>
     * <li><code>./$my-region/provider.tf</code> for provider configuration.</li>
     * <li><code>./$my-region/vpc.tf</code> for VPC configuration.</li>
     * <li><code>./$my-region/ami-$os.tf</code> for enabled OS in this region.</li>
     * </ul>
     *
     * @param context
     *            The Terraform context holding the subscription, the quote and the user inputs.
     * @throws IOException
     *             When Terraform content cannot be written.
     */
    public void write(final Context context) throws IOException {
        writeStatics(context);
        writeContext(context);
        writeRegions(context);
        writeSecrets(context.getSubscription());
    }

    /**
     * Write the global subscription and project context.
     */
    private void writeContext(final Context context) throws IOException {
        final Project project = context.getSubscription().getProject();
        context.add("project.id", project.getId().toString()).add("project.pkey", project.getPkey())
                .add("project.name", project.getName())
                .add("subscription.id", context.getSubscription().getId().toString());
        template(context, s -> replace(s, context), "terraform.keep.auto.tfvars");
    }

    private void writeStatics(final Context context) throws IOException {
        copy(context, "main.tf");
        copy(context, "variables.keep.tf");
    }

    private void writeRegions(final Context context) throws IOException {
        final Set<String> locations = new HashSet<>();
        context.getQuote().getInstances().stream().map(this::getLocation).map(ProvLocation::getName)
                .forEach(locations::add);
        for (final String location : locations) {
            context.setLocation(location);
            context.add("region", location);
            writeRegion(context);
        }
    }

    private ProvLocation getLocation(final AbstractQuoteResource resource) {
        return Objects.requireNonNullElse(resource.getLocation(), resource.getConfiguration().getLocation());
    }

    private void writeRegion(final Context context) throws IOException {
        final List<ProvQuoteInstance> instances = new ArrayList<>();
        context.getQuote().getInstances().stream()
                .filter(i -> getLocation(i).getName().equals(context.getLocation())).forEach(instances::add);
        final Map<InstanceMode, List<ProvQuoteInstance>> modes = new EnumMap<>(InstanceMode.class);
        Arrays.stream(InstanceMode.values()).forEach(m -> modes.put(m, new ArrayList<>()));
        instances.stream().forEach(i -> modes.get(toMode(i)).add(i));
        context.setModes(modes);

        writeRegionStatics(context);
        writeRegionOs(context, instances);
        writeRegionDashboard(context);
        writeRegionInstances(context);
    }

    /**
     * Write dashboard: charts and Markdown.
     */
    private void writeRegionDashboard(final Context context) throws IOException {
        final Map<InstanceMode, List<ProvQuoteInstance>> modes = context.getModes();
        // Charts
        context.setInstances(modes.get(InstanceMode.AUTO_SCALING));
        templateFromTo(
                context.add("scaling", getDashboardScaling(context))
                        .add("balancing", getDashboardBalancing(context))
                        .add("latency", getDashboardLatency(context)).add("network", getDashboardNetwork(context)),
                "my-region/dashboard-widgets.tpl.json", context.getLocation(), "dashboard-widgets.tpl.json");

        // Markdown
        templateFromTo(context.add("alb", getMd(modes.get(InstanceMode.AUTO_SCALING),
                "ALB|[${alb{{i}}_name}](/ec2/v2/home?region=${region}#LoadBalancers:search=${alb{{i}}_dns})|[http](http://${alb{{i}}_dns})"))
                .add("ec2", getMd(modes.get(InstanceMode.VM),
                        "EC2|[${ec2{{i}}_name}](/ec2/v2/home?region=${region}#Instances:search=${ec2{{i}}})|[http](http://${ec2{{i}}_ip})"))
                .add("spot", getMd(modes.get(InstanceMode.EPHEMERAL),
                        "EC2|[${spot{{i}}_name}](/ec2sp/v1/spot/home?region=${region}#)|${spot{{i}}_price}"))
                .add("asg", getMd(modes.get(InstanceMode.AUTO_SCALING),
                        "EC2/AS|[${asg{{i}}_name}](/ec2/autoscaling/home?region=${region}#AutoScalingGroups:id=${asg{{i}}};view=details)|")),
                "my-region/dashboard-widgets.tpl.md", context.getLocation(), "dashboard-widgets.tpl.md");

        // References for MD template and CloudWatch widgets
        templateFromTo(context.add("references", getDashboardReferences(context)), "my-region/dashboard.tf",
                context.getLocation(), "dashboard.tf");
    }

    private String getDashboardReferences(final Context context) {
        final StringBuilder buffer = new StringBuilder();
        appendDashboardReferences(buffer, context, context.getModes().get(InstanceMode.VM),
                "ec2{{i}} = \"${aws_instance.{{key}}.id}\"", "ec2{{i}}_name = \"{{name}}\"",
                "ec2{{i}}_ip = \"${aws_instance.{{key}}.public_ip}\"");
        appendDashboardReferences(buffer, context, context.getModes().get(InstanceMode.EPHEMERAL),
                "spot{{i}}       = \"${aws_spot_instance_request.{{key}}.id}\"", "spot{{i}}_name = \"{{name}}\"",
                "spot{{i}}_price = \"{{spot-price}}\"");
        appendDashboardReferences(buffer, context, context.getModes().get(InstanceMode.AUTO_SCALING),
                "asg{{i}}     = \"${aws_autoscaling_group.{{key}}.name}\"", "asg{{i}}_name = \"{{name}}\"");
        appendDashboardReferences(buffer, context, context.getModes().get(InstanceMode.AUTO_SCALING),
                "alb{{i}}     = \"${aws_lb.{{key}}.arn_suffix}\"",
                "alb{{i}}_tg  = \"${aws_lb_target_group.{{key}}.arn_suffix}\"", "alb{{i}}_name = \"{{name}}\"",
                "alb{{i}}_dns = \"${aws_lb.{{key}}.dns_name}\"");
        return buffer.toString();
    }

    private void appendDashboardReferences(final StringBuilder buffer, final Context context,
            final List<ProvQuoteInstance> instances, final String... formats) {
        final NormalizeFormat normalizeFormat = new NormalizeFormat();
        for (final String format : formats) {
            int index = 0;
            for (final ProvQuoteInstance instance : instances) {
                buffer.append('\n').append(replace(format, context.add("i", String.valueOf(index))
                        .add("key", normalizeFormat.format(instance.getName())).add("name", instance.getName())
                        .add("spot-price", String.valueOf(instance.getMaxVariableCost()))));
                index++;
            }
        }
    }

    private String getMd(final List<ProvQuoteInstance> instances, final String format) {
        final StringBuilder buffer = new StringBuilder();
        for (int index = 0; index < instances.size(); index++) {
            buffer.append('\n').append(replace(format, INDEX, String.valueOf(index)));
        }
        return buffer.toString();
    }

    private String getDashboardNetwork(final Context context) throws IOException {
        final String format = toString("my-region/dashboard-widgets-line.json");
        return newMetric(context, format, CLOUD_WATCH_ELB, "LoadBalancer", "${alb{{i}}}",
                new String[] { "ProcessedBytes", "-", "-", "${alb{{i}}_name}" });
    }

    private String getDashboardLatency(final Context context) throws IOException {
        final String format = toString("my-region/dashboard-widgets-line.json");
        return newMetric(context, format, CLOUD_WATCH_ELB, "LoadBalancer", "${alb{{i}}}",
                new String[] { "TargetResponseTime", "-", "-", "${alb{{i}}_name}" });
    }

    private String getDashboardScaling(final Context context) throws IOException {
        final String format = toString("my-region/dashboard-widgets-area.json");
        return newMetric(context, format, "AWS/AutoScaling", "AutoScalingGroupName", "${asg{{i}}}",
                new String[] { "GroupInServiceInstances", "2ca02c", "left", "${asg{{i}}_name}" },
                new String[] { "GroupPendingInstances", "ff7f0e", "right", "Pending ${asg{{i}}_name}" },
                new String[] { "GroupTerminatingInstances", "d62728", "right", "Term. ${asg{{i}}_name}" });
    }

    private String getDashboardBalancing(final Context context) throws IOException {
        final String format = toString("my-region/dashboard-widgets-area.json");
        return newMetric(context, format, CLOUD_WATCH_ELB, "TargetGroup",
                "${alb{{i}}_tg}\", \"LoadBalancer\", \"${alb{{i}}}\"",
                new String[] { "HealthyHostCount", "2ca02c", "left", "OK ${alb{{i}}_name}" },
                new String[] { "UnHealthyHostCount", "d62728", "right", "KO ${alb{{i}}_name}" });
    }

    private String newMetric(final Context context, final String format, final String service,
            final String idProperty, final String id, String[]... variants) {
        final List<ProvQuoteInstance> instances = context.getInstances();
        final StringBuilder buffer = new StringBuilder();
        final String serviceFmt = replace(format, "{{service}}", service);
        Arrays.stream(variants).forEach(variant -> {
            for (int index = 0; index < instances.size(); index++) {
                if (buffer.length() > 0) {
                    buffer.append(',');
                }
                buffer.append('\n');
                buffer.append(replace(serviceFmt, "{{property}}", replace(idProperty, INDEX, String.valueOf(index)),
                        "{{metric}}", variant[0], "{{id}}", id.replace(INDEX, String.valueOf(index)), "{{color}}",
                        variant[1], "{{position}}", variant[2], "{{label}}",
                        replace(variant[3], INDEX, String.valueOf(index))));
            }
        });
        return buffer.toString();
    }

    /**
     * Write referenced OS
     */
    private void writeRegionOs(final Context context, final List<ProvQuoteInstance> instances) throws IOException {
        final Set<VmOs> oss = new HashSet<>();
        instances.stream().map(ProvQuoteInstance::getOs).forEach(oss::add);
        for (final VmOs os : oss) {
            templateFromTo(context.add("name", toAmiName(os)), "my-region/ami-os.tf", context.getLocation(),
                    "ami-" + toAmiName(os) + ".tf");
        }
    }

    private String toAmiName(final VmOs os) {
        return mappingOsAmi.getOrDefault(os, os.name().toLowerCase(Locale.ENGLISH));
    }

    private void writeRegionStatics(final Context context) throws IOException {
        copyFromTo(context, "my-region/provider.tf", context.getLocation(), "provider.keep.tf");
        copyFromTo(context, "my-region/variables.keep.tf", context.getLocation(), "variables.keep.tf");
        copyFromTo(context, "my-region/vpc.tf", context.getLocation(), "vpc.tf");
    }

    /**
     * Write instances configuration.
     */
    private void writeRegionInstances(final Context context) throws IOException {
        // Write the the region bootstrap module : will be persistent to handle emptied region
        templateFromTo(context, "my-region.tf", context.getLocation() + ".keep.tf");

        // Write the instances, ALB,... within this instance
        final NormalizeFormat normalizeFormat = new NormalizeFormat();
        for (final Map.Entry<InstanceMode, List<ProvQuoteInstance>> entry : context.getModes().entrySet()) {
            final List<ProvQuoteInstance> instances = entry.getValue();
            final String mode = entry.getKey().name().toLowerCase(Locale.ENGLISH);
            final String template = "my-region/instance-" + mode + ".tf";
            for (final ProvQuoteInstance instance : instances) {
                context.add("key", normalizeFormat.format(instance.getName()))
                        .add("os", toAmiName(instance.getOs())).add("type", instance.getPrice().getType().getName())
                        .add("name", instance.getName())
                        .add("spot-price", String.valueOf(instance.getMaxVariableCost()))
                        .add("min", String.valueOf(instance.getMinQuantity()))
                        .add("max", String.valueOf(ObjectUtils.defaultIfNull(instance.getMaxQuantity(), 10)))
                        .add("root-device", getEbsDevices(instance, true, 0, 1))
                        .add("user-data", getUserData(instance)).add("ebs-devices", getEbsDevices(instance,
                                entry.getKey() == InstanceMode.AUTO_SCALING, 1, instance.getStorages().size()));
                templateFromTo(context, template, context.getLocation(), mode + "-" + context.get("key") + ".tf");
            }
        }
    }

    /**
     * Return user-data related to given instance.
     */
    private String getUserData(ProvQuoteInstance instance) throws IOException {
        try (InputStream shInput = toInput("user-data/nginx/" + instance.getOs().name().toLowerCase() + ".sh")) {
            if (shInput != null) {
                return "  user_data = <<-EOF\n" + IOUtils.toString(shInput, StandardCharsets.UTF_8) + "\n  EOF";
            }
        }
        return "";
    }

    private String getEbsDevices(final ProvQuoteInstance instance, final boolean intern, final int startIndex,
            final int endIndex) throws IOException {
        final StringBuilder builder = new StringBuilder();
        int idx = 0;
        final NormalizeFormat normalizeFormat = new NormalizeFormat();
        for (final ProvQuoteStorage storage : instance.getStorages()) {
            if (idx >= startIndex && idx < endIndex) {
                final String format = getDeviceFormat(intern, idx);
                builder.append('\n').append(replace(format, "{{key}}", normalizeFormat.format(storage.getName()),
                        "{{type}}", storage.getPrice().getType().getName(), "{{device}}",
                        toDeviceName(instance.getOs(), idx), "{{instance}}",
                        normalizeFormat.format(instance.getName()), "{{size}}", String.valueOf(storage.getSize())));
            }
            idx++;
        }
        return builder.toString();
    }

    private String getDeviceFormat(final boolean intern, int idx) throws IOException {
        final String deviceSuffix;
        if (intern) {
            if (idx >= 1) {
                deviceSuffix = "-1";
            } else {
                deviceSuffix = "-0";
            }
        } else {
            deviceSuffix = "";
        }
        return toString(String.format("my-region/instance-device%s.tf", deviceSuffix));
    }

    private String toDeviceName(final VmOs os, final int index) {
        if (index == 0) {
            // Root device
            return mappingOsRootDevice.getOrDefault(os, DEFAULT_ROOT_DEVICE);
        }
        final String format = mappingOsEbsDevice.getOrDefault(os, DEFAULT_EBS_DEVICE);
        return format.substring(0, format.length() - 1)
                + String.valueOf((char) (format.charAt(format.length() - 1) + index - 1));
    }

    /**
     * Write the secrets required by the provider.
     *
     * @param subscription
     *            The subscription identifier.
     * @throws IOException
     *             When secret cannot be written.
     */
    public void writeSecrets(final Subscription subscription) throws IOException {
        try (final Writer out = new FileWriterWithEncoding(utils.toFile(subscription, "secrets.auto.tfvars"),
                StandardCharsets.UTF_8)) {
            final Map<String, String> parameters = subscriptionResource.getParametersNoCheck(subscription.getId());
            out.write("access_key = \"");
            out.write(parameters.get(ProvAwsPluginResource.PARAMETER_ACCESS_KEY_ID));
            out.write("\"\nsecret_key = \"");
            out.write(parameters.get(ProvAwsPluginResource.PARAMETER_SECRET_ACCESS_KEY));
            out.write("\"\n");
        }
    }

    private void copy(final Context context, final String... fragments) throws IOException {
        Files.copy(toInput(String.join("/", fragments)),
                utils.toFile(context.getSubscription(), fragments).toPath(), StandardCopyOption.REPLACE_EXISTING);
    }

    private void copyFromTo(final Context context, final String from, final String... toFragments)
            throws IOException {
        Files.copy(toInput(from), utils.toFile(context.getSubscription(), toFragments).toPath(),
                StandardCopyOption.REPLACE_EXISTING);
    }

    private void template(final Context context, final Function<String, String> formater, final String... fragments)
            throws IOException {
        try (InputStream source = toInput(String.join("/", fragments));
                FileOutputStream target = new FileOutputStream(utils.toFile(context.getSubscription(), fragments));
                Writer targetW = new OutputStreamWriter(target);) {
            targetW.write(formater.apply(IOUtils.toString(source, StandardCharsets.UTF_8)));
        }
    }

    private void templateFromTo(final Context context, final String from, final String... toFragments)
            throws IOException {
        try (InputStream source = toInput(from);
                FileOutputStream target = new FileOutputStream(
                        utils.toFile(context.getSubscription(), toFragments));
                Writer targetW = new OutputStreamWriter(target);) {
            targetW.write(replace(IOUtils.toString(source, StandardCharsets.UTF_8), context));
        }
    }

    private String replace(String source, String... replaces) {
        String result = source;
        for (int index = 0; index < replaces.length; index += 2) {
            result = StringUtils.replace(result, replaces[index], replaces[index + 1]);
        }
        return result;
    }

    private String replace(String source, final Context context) {
        String result = source;
        for (final Map.Entry<String, String> entry : context.getContext().entrySet()) {
            result = StringUtils.replace(result, String.format("{{%s}}", entry.getKey()), entry.getValue());
        }
        return result;
    }

    private InstanceMode toMode(final ProvQuoteInstance instance) {
        // xLB
        if (instance.getMinQuantity() != 1 || instance.getMaxQuantity() == null
                || instance.getMaxQuantity().doubleValue() > instance.getMinQuantity()) {
            return InstanceMode.AUTO_SCALING;
        }

        // Single EC2 but with a price condition
        if (ObjectUtils.defaultIfNull(instance.getMaxVariableCost(), 0d) > 0) {
            return InstanceMode.EPHEMERAL;
        }
        // Single EC2
        return InstanceMode.VM;
    }

    private String toString(final String path) throws IOException {
        return IOUtils.toString(new ClassPathResource("terraform/" + path).getURI(), StandardCharsets.UTF_8);
    }

    private InputStream toInput(final String path) throws IOException {
        return new ClassPathResource("terraform/" + path).getInputStream();
    }
}