View Javadoc
1   /*
2    * Copyright (c) 2016 ingenieux Labs
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    *
8    * http://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS,
12   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13   * See the License for the specific language governing permissions and
14   * limitations under the License.
15   */
16  
17  package br.com.ingenieux.mojo.beanstalk.env;
18  
19  import com.google.common.base.Predicate;
20  
21  import com.amazonaws.services.elasticbeanstalk.model.CheckDNSAvailabilityRequest;
22  import com.amazonaws.services.elasticbeanstalk.model.ConfigurationOptionSetting;
23  import com.amazonaws.services.elasticbeanstalk.model.CreateEnvironmentResult;
24  import com.amazonaws.services.elasticbeanstalk.model.DescribeConfigurationSettingsRequest;
25  import com.amazonaws.services.elasticbeanstalk.model.DescribeConfigurationSettingsResult;
26  import com.amazonaws.services.elasticbeanstalk.model.EnvironmentDescription;
27  
28  import org.apache.commons.lang.StringUtils;
29  import org.apache.maven.plugin.AbstractMojoExecutionException;
30  import org.apache.maven.plugin.MojoExecutionException;
31  import org.apache.maven.plugin.MojoFailureException;
32  import org.apache.maven.plugins.annotations.Mojo;
33  import org.apache.maven.plugins.annotations.Parameter;
34  
35  import java.util.ArrayList;
36  import java.util.Collection;
37  import java.util.Date;
38  import java.util.List;
39  import java.util.ListIterator;
40  import java.util.regex.Matcher;
41  import java.util.regex.Pattern;
42  
43  import br.com.ingenieux.mojo.aws.util.CredentialsUtil;
44  import br.com.ingenieux.mojo.beanstalk.cmd.dns.BindDomainsCommand;
45  import br.com.ingenieux.mojo.beanstalk.cmd.dns.BindDomainsContext;
46  import br.com.ingenieux.mojo.beanstalk.cmd.dns.BindDomainsContextBuilder;
47  import br.com.ingenieux.mojo.beanstalk.cmd.env.swap.SwapCNamesCommand;
48  import br.com.ingenieux.mojo.beanstalk.cmd.env.swap.SwapCNamesContext;
49  import br.com.ingenieux.mojo.beanstalk.cmd.env.swap.SwapCNamesContextBuilder;
50  import br.com.ingenieux.mojo.beanstalk.cmd.env.terminate.TerminateEnvironmentCommand;
51  import br.com.ingenieux.mojo.beanstalk.cmd.env.terminate.TerminateEnvironmentContext;
52  import br.com.ingenieux.mojo.beanstalk.cmd.env.terminate.TerminateEnvironmentContextBuilder;
53  import br.com.ingenieux.mojo.beanstalk.cmd.env.waitfor.WaitForEnvironmentCommand;
54  import br.com.ingenieux.mojo.beanstalk.cmd.env.waitfor.WaitForEnvironmentContext;
55  import br.com.ingenieux.mojo.beanstalk.cmd.env.waitfor.WaitForEnvironmentContextBuilder;
56  import br.com.ingenieux.mojo.beanstalk.util.EnvironmentHostnameUtil;
57  
58  import static java.lang.String.format;
59  import static org.apache.commons.lang.StringUtils.isNotBlank;
60  
61  /**
62   * Launches a new environment and, when done, replace with the existing, terminating when needed. It
63   * combines both create-environment, wait-for-environment, swap-environment-cnames, and
64   * terminate-environment
65   *
66   * @since 0.2.0
67   */
68  @Mojo(name = "replace-environment")
69  // Best Guess Evar
70  public class ReplaceEnvironmentMojo extends CreateEnvironmentMojo {
71  
72    /**
73     * Pattern for Increasing in Replace Environment
74     */
75    private static final Pattern PATTERN_NUMBERED = Pattern.compile("^(.*)-(\\d+)$");
76  
77    /**
78     * Max Environment Name Length
79     */
80    private static final int MAX_ENVNAME_LEN = 40;
81  
82    /**
83     * Minutes until timeout
84     */
85    @Parameter(property = "beanstalk.timeoutMins", defaultValue = "20")
86    Integer timeoutMins;
87  
88    /**
89     * Skips if Same Version?
90     */
91    @Parameter(property = "beanstalk.skipIfSameVersion", defaultValue = "true")
92    boolean skipIfSameVersion = true;
93  
94    /**
95     * Max Number of Attempts (for cnameSwap in Particular)
96     */
97    @Parameter(property = "beanstalk.maxAttempts", defaultValue = "15")
98    Integer maxAttempts = 15;
99  
100   /**
101    * Do a 'Mock' Terminate Environment Call (useful for Debugging)
102    */
103   @Parameter(property = "beanstalk.mockTerminateEnvironment", defaultValue = "false")
104   boolean mockTerminateEnvironment = false;
105 
106   /**
107    * Allows one Red (broken) environment to replace another Red (broken) environment.
108    */
109   @Parameter(property = "beanstalk.redToRedOkay", defaultValue = "true")
110   boolean redToRedOkay = true;
111 
112   /**
113    * Retry Interval, in Seconds
114    */
115   @Parameter(property = "beanstalk.attemptRetryInterval", defaultValue = "60")
116   int attemptRetryInterval = 60;
117 
118   /**
119    * <p>List of R53 Domains</p> <p> <p>Could be set as either:</p> <ul> <li>fqdn:hostedZoneId
120    * (e.g. "services.modafocas.org:Z3DJ4DL0DIEEJA")</li> <li>hosted zone name - will be set to
121    * root. (e.g., "modafocas.org")</li> </ul>
122    */
123   @Parameter(property = "beanstalk.domains")
124   String[] domains;
125 
126   /**
127    * Whether or not to copy option settings from old environment when replacing
128    */
129   @Parameter(property = "beanstalk.copyOptionSettings", defaultValue = "true")
130   boolean copyOptionSettings = true;
131 
132   /**
133    * Whether or not to keep the Elastic Beanstalk platform (aka solutionStack) from the old
134    * environment when replacing
135    */
136   @Parameter(property = "beanstalk.copySolutionStack", defaultValue = "true")
137   boolean copySolutionStack = true;
138 
139   @Override
140   protected EnvironmentDescription handleResults(Collection<EnvironmentDescription> environments) throws MojoExecutionException {
141     // Don't care - We're an exception to the rule, you know.
142 
143     return null;
144   }
145 
146   @Override
147   protected Object executeInternal() throws Exception {
148     solutionStack = lookupSolutionStack(solutionStack);
149 
150     /*
151      * Is the desired cname not being used by other environments? If so,
152      * just launch the environment
153      */
154     if (!hasEnvironmentFor(applicationName, cnamePrefix)) {
155       if (getLog().isInfoEnabled()) {
156         getLog().info("Just launching a new environment.");
157       }
158 
159       return super.executeInternal();
160     }
161 
162     /*
163      * Gets the current environment using this cname
164      */
165     EnvironmentDescription curEnv = getEnvironmentFor(applicationName, cnamePrefix);
166 
167     if (curEnv.getVersionLabel().equals(versionLabel) && skipIfSameVersion) {
168       getLog().warn(format("Environment is running version %s and skipIfSameVersion is true. Returning", versionLabel));
169 
170       return null;
171     }
172 
173     /*
174      * Decides on a environmentRef, and launches a new environment
175      */
176     String cnamePrefixToCreate = getCNamePrefixToCreate();
177 
178     /* TODO: Revise Suffix Dynamics */
179     if (getLog().isInfoEnabled()) {
180       getLog().info("Creating a new environment on " + cnamePrefixToCreate + ".elasticbeanstalk.com");
181     }
182 
183     if (copyOptionSettings) {
184       copyOptionSettings(curEnv);
185     }
186 
187     if (!solutionStack.equals(curEnv.getSolutionStackName()) && copySolutionStack) {
188       if (getLog().isWarnEnabled()) {
189         getLog()
190             .warn(
191                 format(
192                     "(btw, we're launching with solutionStack/ set to '%s' based on the existing env instead of the "
193                         + "default ('%s'). If this is not the desired behavior please set the copySolutionStack property to"
194                         + " false.",
195                     curEnv.getSolutionStackName(),
196                     solutionStack));
197       }
198 
199       solutionStack = curEnv.getSolutionStackName();
200     }
201 
202     /**
203      * TODO: Spend a comfy Saturday Afternoon in a Coffee Shop trying to figure out this nice boolean logic with Karnaugh Maps sponsored by Allogy
204      */
205     boolean userWantsHealthyExitStatus = mustBeHealthy;
206     final boolean currentEnvironmentIsRed = "Red".equals(curEnv.getHealth());
207 
208     if (redToRedOkay) {
209       //Side-effect: must be before createEnvironment() and waitForEnvironment() to effect superclass behavior.
210       mustBeHealthy = !currentEnvironmentIsRed;
211     }
212 
213     /**
214      * // I really meant it.
215      */
216     String newEnvironmentName = getNewEnvironmentName(StringUtils.defaultString(this.environmentName, curEnv.getEnvironmentName()));
217 
218     if (getLog().isInfoEnabled()) {
219       getLog().info("And it'll be named " + newEnvironmentName);
220       getLog().info("And it will replace a '" + curEnv.getHealth() + "' enviroment");
221     }
222 
223     CreateEnvironmentResult createEnvResult = createEnvironment(cnamePrefixToCreate, newEnvironmentName);
224 
225     /*
226      * Waits for completion
227      */
228     EnvironmentDescription newEnvDesc = null;
229 
230     try {
231       newEnvDesc = waitForEnvironment(createEnvResult.getEnvironmentId());
232     } catch (Exception exc) {
233       /*
234        * Terminates the failed launched environment
235        */
236       terminateEnvironment(createEnvResult.getEnvironmentId());
237 
238       handleException(exc);
239 
240       return null;
241     }
242 
243     /*
244      * Swaps. Due to beanstalker-25, we're doing some extra logic we
245      * actually woudln't want to.
246      */
247     {
248       boolean swapped = false;
249       for (int i = 1; i <= maxAttempts; i++) {
250         try {
251           swapEnvironmentCNames(newEnvDesc.getEnvironmentId(), curEnv.getEnvironmentId(), cnamePrefix, newEnvDesc);
252           swapped = true;
253           break;
254         } catch (Throwable exc) {
255           if (exc instanceof MojoFailureException) {
256             exc = Throwable.class.cast(MojoFailureException.class.cast(exc).getCause());
257           }
258 
259           getLog().warn(format("Attempt #%d/%d failed. Sleeping and retrying. Reason: %s (type: %s)", i, maxAttempts, exc.getMessage(), exc.getClass()));
260 
261           sleepInterval(attemptRetryInterval);
262         }
263       }
264 
265       if (!swapped) {
266         getLog().info("Failed to properly Replace Environment. Finishing the new one. And throwing you a failure");
267 
268         terminateEnvironment(newEnvDesc.getEnvironmentId());
269 
270         String message = "Unable to swap cnames. btw, see https://github.com/ingenieux/beanstalker/issues/25 and help us improve beanstalker";
271 
272         getLog().warn(message);
273 
274         throw new MojoFailureException(message);
275       }
276     }
277 
278     /*
279      * Terminates the previous environment
280      */
281     terminateEnvironment(curEnv.getEnvironmentId());
282 
283     /**
284      * TODO: I really need a Saturday Afternoon in order to understand this ffs.
285      */
286     if (currentEnvironmentIsRed && userWantsHealthyExitStatus) {
287       final String newHealth = newEnvDesc.getHealth();
288 
289       if (newHealth.equals("Green")) {
290         getLog().info("Previous environment was 'Red', new environment is 'Green' (for the moment)");
291       } else {
292         getLog().warn(format("Previous environment was 'Red', replacement environment is currently '%s'", newHealth));
293 
294         //NB: we have already switched from one broken service to another, so this is more for the build status indicator...
295         newEnvDesc = waitForGreenEnvironment(createEnvResult.getEnvironmentId());
296       }
297     }
298 
299     return createEnvResult;
300   }
301 
302   public void sleepInterval(int pollInterval) {
303     getLog().info(format("Sleeping for %d seconds (and until %s)", pollInterval, new Date(System.currentTimeMillis() + 1000 * pollInterval)));
304     try {
305       Thread.sleep(1000 * pollInterval);
306     } catch (InterruptedException e) {
307     }
308   }
309 
310   /**
311    * Prior to Launching a New Environment, lets look and copy the most we can
312    *
313    * @param curEnv current environment
314    */
315   private void copyOptionSettings(EnvironmentDescription curEnv) throws Exception {
316     /**
317      * Skip if we don't have anything
318      */
319     if (null != this.optionSettings && this.optionSettings.length > 0) {
320       return;
321     }
322 
323     DescribeConfigurationSettingsResult configSettings =
324         getService()
325             .describeConfigurationSettings(
326                 new DescribeConfigurationSettingsRequest().withApplicationName(applicationName).withEnvironmentName(curEnv.getEnvironmentName()));
327 
328     List<ConfigurationOptionSetting> newOptionSettings =
329         new ArrayList<ConfigurationOptionSetting>(configSettings.getConfigurationSettings().get(0).getOptionSettings());
330 
331     ListIterator<ConfigurationOptionSetting> listIterator = newOptionSettings.listIterator();
332 
333     while (listIterator.hasNext()) {
334       ConfigurationOptionSetting curOptionSetting = listIterator.next();
335 
336       boolean bInvalid = harmfulOptionSettingP(curEnv.getEnvironmentId(), curOptionSetting);
337 
338       if (bInvalid) {
339         getLog()
340             .info(
341                 format(
342                     "Excluding Option Setting: %s:%s['%s']",
343                     curOptionSetting.getNamespace(),
344                     curOptionSetting.getOptionName(),
345                     CredentialsUtil.redact(curOptionSetting.getValue())));
346         listIterator.remove();
347       } else {
348         getLog()
349             .info(
350                 format(
351                     "Including Option Setting: %s:%s['%s']",
352                     curOptionSetting.getNamespace(),
353                     curOptionSetting.getOptionName(),
354                     CredentialsUtil.redact(curOptionSetting.getValue())));
355       }
356     }
357 
358     Object __secGroups = project.getProperties().get("beanstalk.securityGroups");
359 
360     if (null != __secGroups) {
361       getLog().info("Checking beanstalk.securityGroups security groups.");
362     	
363       String securityGroups = StringUtils.defaultString(__secGroups.toString());
364 
365       if (!StringUtils.isBlank(securityGroups)) {
366         ConfigurationOptionSetting newOptionSetting = new ConfigurationOptionSetting("aws:autoscaling:launchconfiguration", "SecurityGroups", securityGroups);
367         newOptionSettings.add(newOptionSetting);
368         getLog()
369             .info(
370                 format(
371                     "Including Option Setting: %s:%s['%s']", newOptionSetting.getNamespace(), newOptionSetting.getOptionName(), newOptionSetting.getValue()));
372       }
373     }
374 
375     /*
376      * Then copy it back
377      */
378     this.optionSettings = newOptionSettings.toArray(new ConfigurationOptionSetting[newOptionSettings.size()]);
379   }
380 
381   /**
382    * Swaps environment cnames
383    *
384    * @param newEnvironmentId environment id
385    * @param curEnvironmentId environment id
386    */
387   protected void swapEnvironmentCNames(String newEnvironmentId, String curEnvironmentId, String cnamePrefix, EnvironmentDescription newEnv)
388       throws AbstractMojoExecutionException {
389     getLog().info("Swapping environment cnames " + newEnvironmentId + " and " + curEnvironmentId);
390 
391     {
392       SwapCNamesContext context =
393           SwapCNamesContextBuilder.swapCNamesContext() //
394               .withSourceEnvironmentId(newEnvironmentId) //
395               .withDestinationEnvironmentId(curEnvironmentId) //
396               .build();
397       SwapCNamesCommandd/env/swap/SwapCNamesCommand.html#SwapCNamesCommand">SwapCNamesCommand command = new SwapCNamesCommand(this);
398 
399       command.execute(context);
400     }
401 
402     /*
403      * Changes in Route53 as well.
404      */
405     if (null != domains) {
406       List<String> domainsToUse = new ArrayList<String>();
407 
408       for (String s : domains) {
409         if (isNotBlank(s)) {
410           domainsToUse.add(s.trim());
411         }
412       }
413 
414       if (!domainsToUse.isEmpty()) {
415 
416         final BindDomainsContext ctx = new BindDomainsContextBuilder().withCurEnv(newEnv).withDomains(domainsToUse).build();
417 
418         new BindDomainsCommand(this).execute(ctx);
419       } else {
420         getLog().info("Skipping r53 domain binding");
421       }
422     }
423 
424     {
425       WaitForEnvironmentContext context =
426           new WaitForEnvironmentContextBuilder()
427               .withApplicationName(applicationName) //
428               .withStatusToWaitFor("Ready") //
429               .withEnvironmentRef(newEnvironmentId) //
430               .withTimeoutMins(timeoutMins) //
431               .build();
432 
433       WaitForEnvironmentCommanditfor/WaitForEnvironmentCommand.html#WaitForEnvironmentCommand">WaitForEnvironmentCommand command = new WaitForEnvironmentCommand(this);
434 
435       command.execute(context);
436     }
437   }
438 
439   /**
440    * Terminates and waits for an environment
441    *
442    * @param environmentId environment id to terminate
443    */
444   protected void terminateEnvironment(String environmentId) throws AbstractMojoExecutionException {
445     if (mockTerminateEnvironment) {
446       getLog().info(format("We're ignoring the termination of environment id '%s' (see mockTerminateEnvironment)", environmentId));
447 
448       return;
449     }
450 
451     Exception lastException = null;
452     for (int i = 1; i <= maxAttempts; i++) {
453       getLog().info(format("Terminating environmentId=%s (attempt %d/%d)", environmentId, i, maxAttempts));
454 
455       try {
456         TerminateEnvironmentContext terminatecontext =
457             new TerminateEnvironmentContextBuilder().withEnvironmentId(environmentId).withTerminateResources(true).build();
458         TerminateEnvironmentCommandinate/TerminateEnvironmentCommand.html#TerminateEnvironmentCommand">TerminateEnvironmentCommand command = new TerminateEnvironmentCommand(this);
459 
460         command.execute(terminatecontext);
461 
462         return;
463       } catch (Exception exc) {
464         lastException = exc;
465       }
466     }
467 
468     throw new MojoFailureException("Unable to terminate environment " + environmentId, lastException);
469   }
470 
471   /**
472    * Waits for an environment to get ready. Throws an exception either if this environment
473    * couldn't get into Ready state or there was a timeout
474    *
475    * @param environmentId environmentId to wait for
476    * @return EnvironmentDescription in Ready state
477    */
478   protected EnvironmentDescription waitForEnvironment(String environmentId) throws AbstractMojoExecutionException {
479     getLog().info("Waiting for environmentId " + environmentId + " to get into Ready state");
480 
481     WaitForEnvironmentContext context =
482         new WaitForEnvironmentContextBuilder() //
483             .withApplicationName(applicationName) //
484             .withStatusToWaitFor("Ready")
485             .withEnvironmentRef(environmentId) //
486             .withHealth((mustBeHealthy ? "Green" : null)) //
487             .withTimeoutMins(timeoutMins)
488             .build();
489 
490     WaitForEnvironmentCommanditfor/WaitForEnvironmentCommand.html#WaitForEnvironmentCommand">WaitForEnvironmentCommand command = new WaitForEnvironmentCommand(this);
491 
492     return command.execute(context);
493   }
494 
495   /**
496    * TODO: What is Coffee anyway?
497    *
498    * Waits for an environment to become green. Throws an exception either if this environment
499    * couldn't get into a green state, or there was a timeout.
500    *
501    * @param environmentId environmentId to wait for
502    * @return EnvironmentDescription in Ready state
503    */
504   protected EnvironmentDescription waitForGreenEnvironment(String environmentId) throws AbstractMojoExecutionException {
505     getLog().info("Waiting for environmentId " + environmentId + " to become 'Green'");
506 
507     WaitForEnvironmentContext context =
508         new WaitForEnvironmentContextBuilder()
509             .withApplicationName(applicationName)
510             .withStatusToWaitFor("Ready")
511             .withEnvironmentRef(environmentId)
512             .withHealth("Green")
513             .withTimeoutMins(timeoutMins)
514             .build();
515 
516     WaitForEnvironmentCommanditfor/WaitForEnvironmentCommand.html#WaitForEnvironmentCommand">WaitForEnvironmentCommand command = new WaitForEnvironmentCommand(this);
517 
518     return command.execute(context);
519   }
520 
521   /**
522    * Creates a cname prefix if needed, or returns the desired one
523    *
524    * @return cname prefix to launch environment into
525    */
526   protected String getCNamePrefixToCreate() {
527     String cnamePrefixToReturn = cnamePrefix;
528     int i = 0;
529 
530     while (hasEnvironmentFor(applicationName, cnamePrefixToReturn) || isNamedEnvironmentUnavailable(cnamePrefixToReturn)) {
531       // TODO: Environment Length Restrictions
532       cnamePrefixToReturn = String.format("%s-%d", cnamePrefix, i++);
533     }
534 
535     return cnamePrefixToReturn;
536   }
537 
538   /**
539    * Boolean predicate for environment existence
540    *
541    * @param applicationName application name
542    * @param cnamePrefix     cname prefix
543    * @return true if the application name has this cname prefix
544    */
545   protected boolean hasEnvironmentFor(String applicationName, String cnamePrefix) {
546     return null != getEnvironmentFor(applicationName, cnamePrefix);
547   }
548 
549   /**
550    * Returns the environment description matching applicationName and environmentRef
551    *
552    * @param applicationName application name
553    * @param cnamePrefix     cname prefix
554    * @return environment description
555    */
556   protected EnvironmentDescription getEnvironmentFor(String applicationName, String cnamePrefix) {
557     Collection<EnvironmentDescription> environments = getEnvironmentsFor(applicationName);
558     Predicate<EnvironmentDescription> pred = EnvironmentHostnameUtil.getHostnamePredicate(getRegion(), cnamePrefix);
559 
560     /*
561      * Finds a matching environment
562      */
563     for (EnvironmentDescription envDesc : environments) {
564       if (pred.apply(envDesc)) {
565         return envDesc;
566       }
567     }
568 
569     return null;
570   }
571 
572   private String getNewEnvironmentName(String newEnvironmentName) {
573     String result = newEnvironmentName;
574     String environmentRadical = result;
575 
576     int i = 0;
577 
578     {
579       Matcher matcher = PATTERN_NUMBERED.matcher(newEnvironmentName);
580 
581       if (matcher.matches()) {
582         environmentRadical = matcher.group(1);
583 
584         i = 1 + Integer.valueOf(matcher.group(2));
585       }
586     }
587 
588     while (containsNamedEnvironment(result) || isNamedEnvironmentUnavailable(result)) {
589       result = formatAndTruncate("%s-%d", MAX_ENVNAME_LEN, environmentRadical, i++);
590     }
591 
592     return result;
593   }
594 
595   private boolean isNamedEnvironmentUnavailable(String cnamePrefix) {
596     return !getService().checkDNSAvailability(new CheckDNSAvailabilityRequest(cnamePrefix)).isAvailable();
597   }
598 
599   /**
600    * Elastic Beanstalk Contains a Max EnvironmentName Limit. Lets truncate it, shall we?
601    *
602    * @param mask   String.format Mask
603    * @param maxLen Maximum Length
604    * @param args   String.format args
605    * @return formatted String, or maxLen rightmost characters
606    */
607   protected String formatAndTruncate(String mask, int maxLen, Object... args) {
608     String result = String.format(mask, args);
609 
610     if (result.length() > maxLen) {
611       result = result.substring(result.length() - maxLen, result.length());
612     }
613 
614     return result;
615   }
616 
617   /**
618    * Boolean predicate for named environment
619    *
620    * @param environmentName environment name
621    * @return true if environment name exists
622    */
623   protected boolean containsNamedEnvironment(String environmentName) {
624     for (EnvironmentDescription envDesc : getEnvironmentsFor(applicationName)) {
625       if (envDesc.getEnvironmentName().equals(environmentName)) {
626         return true;
627       }
628     }
629 
630     return false;
631   }
632 }