| // Licensed to the Apache Software Foundation (ASF) under one |
| // or more contributor license agreements. See the NOTICE file |
| // distributed with this work for additional information |
| // regarding copyright ownership. The ASF licenses this file |
| // to you 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 org.apache.cloudstack.userdata; |
| |
| import java.io.ByteArrayInputStream; |
| import java.io.ByteArrayOutputStream; |
| import java.io.IOException; |
| import java.io.InputStream; |
| import java.util.Arrays; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.Properties; |
| import java.util.stream.Collectors; |
| import java.util.zip.GZIPInputStream; |
| |
| import javax.mail.BodyPart; |
| import javax.mail.MessagingException; |
| import javax.mail.Multipart; |
| import javax.mail.Session; |
| import javax.mail.internet.MimeBodyPart; |
| import javax.mail.internet.MimeMessage; |
| import javax.mail.internet.MimeMultipart; |
| |
| import org.apache.commons.codec.binary.Base64; |
| import org.apache.commons.collections.CollectionUtils; |
| import org.apache.commons.lang3.StringUtils; |
| import org.apache.log4j.Logger; |
| |
| import com.cloud.utils.component.AdapterBase; |
| import com.cloud.utils.exception.CloudRuntimeException; |
| import com.sun.mail.util.BASE64DecoderStream; |
| |
| public class CloudInitUserDataProvider extends AdapterBase implements UserDataProvider { |
| |
| protected enum FormatType { |
| CLOUD_CONFIG, BASH_SCRIPT, MIME, CLOUD_BOOTHOOK, INCLUDE_FILE |
| } |
| |
| private static final String CLOUD_CONFIG_CONTENT_TYPE = "text/cloud-config"; |
| private static final String BASH_SCRIPT_CONTENT_TYPE = "text/x-shellscript"; |
| private static final String INCLUDE_FILE_CONTENT_TYPE = "text/x-include-url"; |
| private static final String CLOUD_BOOTHOOK_CONTENT_TYPE = "text/cloud-boothook"; |
| |
| private static final Map<FormatType, String> formatContentTypeMap = Map.ofEntries( |
| Map.entry(FormatType.CLOUD_CONFIG, CLOUD_CONFIG_CONTENT_TYPE), |
| Map.entry(FormatType.BASH_SCRIPT, BASH_SCRIPT_CONTENT_TYPE), |
| Map.entry(FormatType.CLOUD_BOOTHOOK, CLOUD_BOOTHOOK_CONTENT_TYPE), |
| Map.entry(FormatType.INCLUDE_FILE, INCLUDE_FILE_CONTENT_TYPE) |
| ); |
| |
| private static final Logger LOGGER = Logger.getLogger(CloudInitUserDataProvider.class); |
| |
| private static final Session session = Session.getDefaultInstance(new Properties()); |
| |
| @Override |
| public String getName() { |
| return "cloud-init"; |
| } |
| |
| protected boolean isGZipped(String encodedUserdata) { |
| if (StringUtils.isEmpty(encodedUserdata)) { |
| return false; |
| } |
| byte[] data = Base64.decodeBase64(encodedUserdata); |
| if (data.length < 2) { |
| return false; |
| } |
| int magic = data[0] & 0xff | ((data[1] << 8) & 0xff00); |
| return magic == GZIPInputStream.GZIP_MAGIC; |
| } |
| |
| protected String extractUserDataHeader(String userdata) { |
| List<String> lines = Arrays.stream(userdata.split("\n")) |
| .filter(x -> (x.startsWith("#") && !x.startsWith("##")) || (x.startsWith("Content-Type:"))) |
| .collect(Collectors.toList()); |
| if (CollectionUtils.isEmpty(lines)) { |
| throw new CloudRuntimeException("Failed to detect the user data format type as it " + |
| "does not contain a header"); |
| } |
| return lines.get(0); |
| } |
| |
| protected FormatType mapUserDataHeaderToFormatType(String header) { |
| if (header.equalsIgnoreCase("#cloud-config")) { |
| return FormatType.CLOUD_CONFIG; |
| } else if (header.startsWith("#!")) { |
| return FormatType.BASH_SCRIPT; |
| } else if (header.equalsIgnoreCase("#cloud-boothook")) { |
| return FormatType.CLOUD_BOOTHOOK; |
| } else if (header.startsWith("#include")) { |
| return FormatType.INCLUDE_FILE; |
| } else if (header.startsWith("Content-Type:")) { |
| return FormatType.MIME; |
| } else { |
| String msg = String.format("Cannot recognise the user data format type from the header line: %s." + |
| "Supported types are: cloud-config, bash script, cloud-boothook, include file or MIME", header); |
| LOGGER.error(msg); |
| throw new CloudRuntimeException(msg); |
| } |
| } |
| |
| /** |
| * Detect the user data type |
| * Reference: <a href="https://canonical-cloud-init.readthedocs-hosted.com/en/latest/explanation/format.html#user-data-formats" /> |
| */ |
| protected FormatType getUserDataFormatType(String userdata) { |
| if (StringUtils.isBlank(userdata)) { |
| String msg = "User data expected but provided empty user data"; |
| LOGGER.error(msg); |
| throw new CloudRuntimeException(msg); |
| } |
| |
| String header = extractUserDataHeader(userdata); |
| return mapUserDataHeaderToFormatType(header); |
| } |
| |
| private String getContentType(String userData, FormatType formatType) throws MessagingException { |
| if (formatType == FormatType.MIME) { |
| NoIdMimeMessage msg = new NoIdMimeMessage(session, new ByteArrayInputStream(userData.getBytes())); |
| return msg.getContentType(); |
| } |
| if (!formatContentTypeMap.containsKey(formatType)) { |
| throw new CloudRuntimeException(String.format("Cannot get the user data content type as " + |
| "its format type %s is invalid", formatType.name())); |
| } |
| return formatContentTypeMap.get(formatType); |
| } |
| |
| protected String getBodyPartContentAsString(BodyPart bodyPart) throws MessagingException, IOException { |
| Object content = bodyPart.getContent(); |
| if (content instanceof BASE64DecoderStream) { |
| return new String(((BASE64DecoderStream)bodyPart.getContent()).readAllBytes()); |
| } else if (content instanceof ByteArrayInputStream) { |
| return new String(((ByteArrayInputStream)bodyPart.getContent()).readAllBytes()); |
| } else if (content instanceof String) { |
| return (String)bodyPart.getContent(); |
| } |
| throw new CloudRuntimeException(String.format("Failed to get content for multipart data with content type: %s", getBodyPartContentType(bodyPart))); |
| } |
| |
| private String getBodyPartContentType(BodyPart bodyPart) throws MessagingException { |
| String contentType = StringUtils.defaultString(bodyPart.getDataHandler().getContentType(), bodyPart.getContentType()); |
| return contentType.contains(";") ? contentType.substring(0, contentType.indexOf(';')) : contentType; |
| } |
| |
| protected MimeBodyPart generateBodyPartMimeMessage(String userData, String contentType) throws MessagingException { |
| MimeBodyPart bodyPart = new MimeBodyPart(); |
| bodyPart.setContent(userData, contentType); |
| bodyPart.addHeader("Content-Transfer-Encoding", "base64"); |
| return bodyPart; |
| } |
| |
| protected MimeBodyPart generateBodyPartMimeMessage(String userData, FormatType formatType) throws MessagingException { |
| return generateBodyPartMimeMessage(userData, getContentType(userData, formatType)); |
| } |
| |
| private Multipart getMessageContent(NoIdMimeMessage message) { |
| Multipart messageContent; |
| try { |
| messageContent = (MimeMultipart) message.getContent(); |
| } catch (IOException | MessagingException e) { |
| messageContent = new MimeMultipart(); |
| } |
| return messageContent; |
| } |
| |
| private void addBodyPartToMultipart(Multipart existingMultipart, MimeBodyPart bodyPart) throws MessagingException, IOException { |
| boolean added = false; |
| final int existingCount = existingMultipart.getCount(); |
| for (int j = 0; j < existingCount; ++j) { |
| MimeBodyPart existingBodyPart = (MimeBodyPart)existingMultipart.getBodyPart(j); |
| String existingContentType = getBodyPartContentType(existingBodyPart); |
| String newContentType = getBodyPartContentType(bodyPart); |
| if (existingContentType.equals(newContentType)) { |
| String existingContent = getBodyPartContentAsString(existingBodyPart); |
| String newContent = getBodyPartContentAsString(bodyPart); |
| // generating a combined content MimeBodyPart to replace |
| MimeBodyPart combinedBodyPart = generateBodyPartMimeMessage( |
| simpleAppendSameFormatTypeUserData(existingContent, newContent), existingContentType); |
| existingMultipart.removeBodyPart(j); |
| existingMultipart.addBodyPart(combinedBodyPart, j); |
| added = true; |
| break; |
| } |
| } |
| if (!added) { |
| existingMultipart.addBodyPart(bodyPart); |
| } |
| } |
| |
| private void addBodyPartsToMessageContentFromUserDataContent(Multipart existingMultipart, |
| NoIdMimeMessage msgFromUserdata) throws MessagingException, IOException { |
| MimeMultipart newMultipart = (MimeMultipart)msgFromUserdata.getContent(); |
| final int existingCount = existingMultipart.getCount(); |
| final int newCount = newMultipart.getCount(); |
| for (int i = 0; i < newCount; ++i) { |
| BodyPart bodyPart = newMultipart.getBodyPart(i); |
| if (existingCount == 0) { |
| existingMultipart.addBodyPart(bodyPart); |
| continue; |
| } |
| addBodyPartToMultipart(existingMultipart, (MimeBodyPart)bodyPart); |
| } |
| } |
| |
| private NoIdMimeMessage createMultipartMessageAddingUserdata(String userData, FormatType formatType, |
| NoIdMimeMessage message) throws MessagingException, IOException { |
| NoIdMimeMessage newMessage = new NoIdMimeMessage(session); |
| Multipart messageContent = getMessageContent(message); |
| |
| if (formatType == FormatType.MIME) { |
| NoIdMimeMessage msgFromUserdata = new NoIdMimeMessage(session, new ByteArrayInputStream(userData.getBytes())); |
| addBodyPartsToMessageContentFromUserDataContent(messageContent, msgFromUserdata); |
| } else { |
| MimeBodyPart part = generateBodyPartMimeMessage(userData, formatType); |
| addBodyPartToMultipart(messageContent, part); |
| } |
| newMessage.setContent(messageContent); |
| return newMessage; |
| } |
| |
| private String simpleAppendSameFormatTypeUserData(String userData1, String userData2) { |
| return String.format("%s\n\n%s", userData1, userData2.substring(userData2.indexOf('\n')+1)); |
| } |
| |
| private void checkGzipAppend(String encodedUserData1, String encodedUserData2) { |
| if (isGZipped(encodedUserData1) || isGZipped(encodedUserData2)) { |
| throw new CloudRuntimeException("Gzipped user data can not be used together with other user data formats"); |
| } |
| } |
| |
| @Override |
| public String appendUserData(String encodedUserData1, String encodedUserData2) { |
| try { |
| checkGzipAppend(encodedUserData1, encodedUserData2); |
| String userData1 = new String(Base64.decodeBase64(encodedUserData1)); |
| String userData2 = new String(Base64.decodeBase64(encodedUserData2)); |
| FormatType formatType1 = getUserDataFormatType(userData1); |
| FormatType formatType2 = getUserDataFormatType(userData2); |
| if (formatType1.equals(formatType2) && List.of(FormatType.CLOUD_CONFIG, FormatType.BASH_SCRIPT).contains(formatType1)) { |
| return simpleAppendSameFormatTypeUserData(userData1, userData2); |
| } |
| NoIdMimeMessage message = new NoIdMimeMessage(session); |
| message = createMultipartMessageAddingUserdata(userData1, formatType1, message); |
| message = createMultipartMessageAddingUserdata(userData2, formatType2, message); |
| ByteArrayOutputStream output = new ByteArrayOutputStream(); |
| message.writeTo(output); |
| return output.toString(); |
| } catch (MessagingException | IOException | CloudRuntimeException e) { |
| String msg = String.format("Error attempting to merge user data as a multipart user data. " + |
| "Reason: %s", e.getMessage()); |
| LOGGER.error(msg, e); |
| throw new CloudRuntimeException(msg, e); |
| } |
| } |
| |
| /* This is a wrapper class just to remove Message-ID header from the resultant |
| multipart data which may contain server details. |
| */ |
| private class NoIdMimeMessage extends MimeMessage { |
| NoIdMimeMessage (Session session) { |
| super(session); |
| } |
| NoIdMimeMessage (Session session, InputStream is) throws MessagingException { |
| super(session, is); |
| } |
| @Override |
| protected void updateMessageID() throws MessagingException { |
| removeHeader("Message-ID"); |
| } |
| } |
| } |