azkaban-aplcache

Storage Stubs and Implementation + Guice Service Provider

4/19/2017 8:21:37 PM

Details

diff --git a/azkaban-common/build.gradle b/azkaban-common/build.gradle
index 2b04557..ed9b316 100644
--- a/azkaban-common/build.gradle
+++ b/azkaban-common/build.gradle
@@ -34,6 +34,7 @@ model {
 dependencies {
   compile project(':azkaban-spi')
 
+  compile('com.google.inject:guice:4.1.0')
   compile('com.google.guava:guava:21.0')
   compile('commons-collections:commons-collections:3.2.2')
   compile('org.apache.commons:commons-dbcp2:2.1.1')
diff --git a/azkaban-common/src/main/java/azkaban/AzkabanCommonModule.java b/azkaban-common/src/main/java/azkaban/AzkabanCommonModule.java
new file mode 100644
index 0000000..83aaf7c
--- /dev/null
+++ b/azkaban-common/src/main/java/azkaban/AzkabanCommonModule.java
@@ -0,0 +1,84 @@
+/*
+ * Copyright 2017 LinkedIn Corp.
+ *
+ * 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 azkaban;
+
+import azkaban.project.JdbcProjectLoader;
+import azkaban.project.ProjectLoader;
+import azkaban.spi.Storage;
+import azkaban.spi.StorageException;
+import azkaban.storage.LocalStorage;
+import azkaban.storage.StorageConfig;
+import azkaban.storage.StorageImplementationType;
+import azkaban.utils.Props;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Provides;
+import com.google.inject.Scopes;
+import com.google.inject.Singleton;
+import java.io.File;
+
+import static azkaban.storage.StorageImplementationType.*;
+
+
+public class AzkabanCommonModule extends AbstractModule {
+  private final Props props;
+  /**
+   * Storage Implementation
+   * This can be any of the {@link StorageImplementationType} values in which case {@link StorageFactory} will create
+   * the appropriate storage instance. Or one can feed in a custom implementation class using the full qualified
+   * path required by a classloader.
+   *
+   * examples: LOCAL, DATABASE, azkaban.storage.MyFavStorage
+   *
+   */
+  private final String storageImplementation;
+
+  public AzkabanCommonModule(Props props) {
+    this.props = props;
+    this.storageImplementation = props.getString(Constants.ConfigurationKeys.AZKABAN_STORAGE_TYPE, LOCAL.name());
+  }
+
+  @Override
+  protected void configure() {
+    bind(ProjectLoader.class).to(JdbcProjectLoader.class).in(Scopes.SINGLETON);
+    bind(Props.class).toInstance(props);
+    bind(Storage.class).to(resolveStorageClassType()).in(Scopes.SINGLETON);
+  }
+
+  public Class<? extends Storage> resolveStorageClassType() {
+    final StorageImplementationType type = StorageImplementationType.from(storageImplementation);
+    if (type != null) {
+      return type.getImplementationClass();
+    } else {
+      return loadCustomStorageClass(storageImplementation);
+    }
+  }
+
+  private Class<? extends Storage> loadCustomStorageClass(String storageImplementation) {
+    try {
+      return (Class<? extends Storage>) Class.forName(storageImplementation);
+    } catch (ClassNotFoundException e) {
+      throw new StorageException(e);
+    }
+  }
+
+  @Inject
+  public @Provides
+  LocalStorage createLocalStorage(StorageConfig config) {
+    return new LocalStorage(new File(config.getBaseDirectoryPath()));
+  }
+}
diff --git a/azkaban-common/src/main/java/azkaban/Constants.java b/azkaban-common/src/main/java/azkaban/Constants.java
index 7c7fc2e..80ab92d 100644
--- a/azkaban-common/src/main/java/azkaban/Constants.java
+++ b/azkaban-common/src/main/java/azkaban/Constants.java
@@ -84,6 +84,9 @@ public class Constants {
     // Max flow running time in mins, server will kill flows running longer than this setting.
     // if not set or <= 0, then there's no restriction on running time.
     public static final String AZKABAN_MAX_FLOW_RUNNING_MINS = "azkaban.server.flow.max.running.minutes";
+
+    public static final String AZKABAN_STORAGE_TYPE = "azkaban.storage.type";
+    public static final String AZKABAN_STORAGE_LOCAL_BASEDIRECTORY = "azkaban.storage.local.basedirectory";
   }
 
   public static class FlowProperties {
diff --git a/azkaban-common/src/main/java/azkaban/project/JdbcProjectLoader.java b/azkaban-common/src/main/java/azkaban/project/JdbcProjectLoader.java
index 3ff410c..ced5eb0 100644
--- a/azkaban-common/src/main/java/azkaban/project/JdbcProjectLoader.java
+++ b/azkaban-common/src/main/java/azkaban/project/JdbcProjectLoader.java
@@ -16,6 +16,7 @@
 
 package azkaban.project;
 
+import com.google.inject.Inject;
 import java.io.BufferedInputStream;
 import java.io.BufferedOutputStream;
 import java.io.File;
@@ -62,6 +63,7 @@ public class JdbcProjectLoader extends AbstractJdbcLoader implements
 
   private EncodingType defaultEncodingType = EncodingType.GZIP;
 
+  @Inject
   public JdbcProjectLoader(Props props) {
     super(props);
     tempDir = new File(props.getString("project.temp.dir", "temp"));
diff --git a/azkaban-common/src/main/java/azkaban/ServiceProvider.java b/azkaban-common/src/main/java/azkaban/ServiceProvider.java
new file mode 100644
index 0000000..9a67e05
--- /dev/null
+++ b/azkaban-common/src/main/java/azkaban/ServiceProvider.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2017 LinkedIn Corp.
+ *
+ * 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 azkaban;
+
+import com.google.inject.Injector;
+
+import static com.google.common.base.Preconditions.*;
+import static java.util.Objects.*;
+
+
+/**
+ * The {@link ServiceProvider} class is an interface to fetch any external dependency. Under the hood it simply
+ * maintains a Guice {@link Injector} which is used to fetch the required service type. The current direction of
+ * utilization of Guice is to gradually move classes into the Guice scope so that Guice can automatically resolve
+ * dependencies and provide the required services directly.
+ *
+ */
+public enum ServiceProvider {
+  SERVICE_PROVIDER;
+
+  private Injector injector = null;
+
+  /**
+   * Ensure that injector is set only once!
+   * @param injector Guice injector is itself used for providing services.
+   */
+  public synchronized void setInjector(Injector injector) {
+    checkState(this.injector == null, "Injector is already set");
+    this.injector = requireNonNull(injector, "arg injector is null");
+  }
+
+  public synchronized void unsetInjector() {
+    this.injector = null;
+  }
+
+  public <T> T getInstance(Class<T> clazz) {
+    return requireNonNull(injector).getInstance(clazz);
+  }
+
+}
diff --git a/azkaban-common/src/main/java/azkaban/storage/DatabaseStorage.java b/azkaban-common/src/main/java/azkaban/storage/DatabaseStorage.java
new file mode 100644
index 0000000..583fa78
--- /dev/null
+++ b/azkaban-common/src/main/java/azkaban/storage/DatabaseStorage.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2017 LinkedIn Corp.
+ *
+ * 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 azkaban.storage;
+
+import azkaban.project.JdbcProjectLoader;
+import azkaban.spi.Storage;
+import azkaban.spi.StorageMetadata;
+import java.io.InputStream;
+import java.net.URI;
+import javax.inject.Inject;
+
+
+/**
+ * DatabaseStorage
+ *
+ * This class helps in storing projects in the DB itself. This is intended to be the default since it is the current
+ * behavior of Azkaban.
+ */
+public class DatabaseStorage implements Storage {
+
+  @Inject
+  public DatabaseStorage(JdbcProjectLoader jdbcProjectLoader) {
+
+  }
+
+  @Override
+  public InputStream get(URI key) {
+    return null;
+  }
+
+  @Override
+  public URI put(StorageMetadata metadata, InputStream is) {
+    return null;
+  }
+
+  @Override
+  public boolean delete(URI key) {
+    throw new UnsupportedOperationException("Delete is not supported");
+  }
+}
diff --git a/azkaban-common/src/main/java/azkaban/storage/LocalStorage.java b/azkaban-common/src/main/java/azkaban/storage/LocalStorage.java
new file mode 100644
index 0000000..a71f131
--- /dev/null
+++ b/azkaban-common/src/main/java/azkaban/storage/LocalStorage.java
@@ -0,0 +1,106 @@
+/*
+ * Copyright 2017 LinkedIn Corp.
+ *
+ * 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 azkaban.storage;
+
+import azkaban.spi.Storage;
+import azkaban.spi.StorageException;
+import azkaban.spi.StorageMetadata;
+import azkaban.utils.FileIOUtils;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import org.apache.commons.io.FileUtils;
+import org.apache.log4j.Logger;
+
+import static com.google.common.base.Preconditions.*;
+
+
+public class LocalStorage implements Storage {
+  private static final Logger log = Logger.getLogger(LocalStorage.class);
+
+  final File baseDirectory;
+
+  public LocalStorage(File baseDirectory) {
+    this.baseDirectory = validateBaseDirectory(createIfDoesNotExist(baseDirectory));
+  }
+
+  @Override
+  public InputStream get(URI key) {
+    try {
+      return new FileInputStream(getFile(key));
+    } catch (FileNotFoundException e) {
+      return null;
+    }
+  }
+
+  private File getFile(URI key) {
+    return new File(baseDirectory, key.getPath());
+  }
+
+  @Override
+  public URI put(StorageMetadata metadata, InputStream is) {
+
+    final File projectDir = new File(baseDirectory, metadata.getProjectId());
+    if (projectDir.mkdir()) {
+      log.info("Created project dir: " + projectDir.getAbsolutePath());
+    }
+
+    final File targetFile = new File(projectDir, metadata.getVersion() + "." + metadata.getExtension());
+
+    if (targetFile.exists()) {
+      throw new StorageException(String.format(
+          "Error in LocalStorage. Target file already exists. targetFile: %s, Metadata: %s",
+          targetFile, metadata));
+    }
+    try {
+      FileUtils.copyInputStreamToFile(is, targetFile);
+    } catch (IOException e) {
+      log.error("LocalStorage error in put(): Metadata: " + metadata);
+      throw new StorageException(e);
+    }
+    return createRelativeURI(targetFile);
+  }
+
+  private URI createRelativeURI(File targetFile) {
+    return baseDirectory.toURI().relativize(targetFile.toURI());
+  }
+
+  @Override
+  public boolean delete(URI key) {
+    throw new UnsupportedOperationException("delete has not been implemented.");
+  }
+
+  private static File createIfDoesNotExist(File baseDirectory) {
+    if(!baseDirectory.exists()) {
+      baseDirectory.mkdir();
+      log.info("Creating dir: " + baseDirectory.getAbsolutePath());
+    }
+    return baseDirectory;
+  }
+
+  private static File validateBaseDirectory(File baseDirectory) {
+    checkArgument(baseDirectory.isDirectory());
+    if (!FileIOUtils.isDirWritable(baseDirectory)) {
+      throw new IllegalArgumentException("Directory not writable: " + baseDirectory);
+    }
+    return baseDirectory;
+  }
+}
diff --git a/azkaban-common/src/main/java/azkaban/storage/StorageManager.java b/azkaban-common/src/main/java/azkaban/storage/StorageManager.java
new file mode 100644
index 0000000..6d0f714
--- /dev/null
+++ b/azkaban-common/src/main/java/azkaban/storage/StorageManager.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2017 LinkedIn Corp.
+ *
+ * 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 azkaban.storage;
+
+import azkaban.project.Project;
+import azkaban.spi.Storage;
+import azkaban.spi.StorageException;
+import azkaban.spi.StorageMetadata;
+import com.google.inject.Inject;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.InputStream;
+import java.net.URI;
+import org.apache.log4j.Logger;
+
+
+/**
+ * StorageManager manages and coordinates all interactions with the Storage layer. This also includes bookkeeping
+ * like updating DB with the new versionm, etc
+ */
+public class StorageManager {
+  private static final Logger log = Logger.getLogger(StorageManager.class);
+
+  private final Storage storage;
+
+  @Inject
+  public StorageManager(Storage storage) {
+    this.storage = storage;
+  }
+
+  /**
+   * API to a project file into Azkaban Storage
+   *
+   * @param project           project ID
+   * @param fileExtension     extension of the file
+   * @param filename          name of the file
+   * @param localFile         local file
+   * @param uploader          the user who uploaded
+   */
+  public void uploadProject(
+      Project project,
+      String fileExtension,
+      String filename,
+      File localFile,
+      String uploader) {
+    final StorageMetadata metadata = new StorageMetadata(
+        String.valueOf(project.getId()),
+        String.valueOf(getLatestVersion(project)),
+        fileExtension
+    );
+    log.info(String.format(
+        "Uploading project. Uploader: %s, Metadata:%s, filename: %s[%d bytes]",
+        uploader, metadata, filename, localFile.length()
+    ));
+    try {
+      uploadProject(metadata, new FileInputStream(localFile));
+    } catch (FileNotFoundException e) {
+      throw new StorageException(e);
+    }
+  }
+
+  private int getLatestVersion(Project project) {
+    // TODO Implement
+    return -1;
+  }
+
+  public void uploadProject(StorageMetadata metadata, InputStream is) {
+    // TODO Implement
+    URI key = storage.put(metadata, is);
+  }
+}
diff --git a/azkaban-common/src/main/java/azkaban/utils/FileIOUtils.java b/azkaban-common/src/main/java/azkaban/utils/FileIOUtils.java
index dbea348..f301b74 100644
--- a/azkaban-common/src/main/java/azkaban/utils/FileIOUtils.java
+++ b/azkaban-common/src/main/java/azkaban/utils/FileIOUtils.java
@@ -42,6 +42,31 @@ import org.apache.log4j.Logger;
 public class FileIOUtils {
   private final static Logger logger = Logger.getLogger(FileIOUtils.class);
 
+  /**
+   * Check if a directory is writable
+   *
+   * @param dir directory file object
+   * @return true if it is writable. false, otherwise
+   */
+  public static boolean isDirWritable(File dir) {
+    File testFile = null;
+    try {
+      testFile = new File(dir, "_tmp");
+      /*
+       * Create and delete a dummy file in order to check file permissions. Maybe
+       * there is a safer way for this check.
+       */
+      testFile.createNewFile();
+    } catch (IOException e) {
+      return false;
+    } finally {
+      if (testFile != null) {
+        testFile.delete();
+      }
+    }
+    return true;
+  }
+
   public static class PrefixSuffixFileFilter implements FileFilter {
     private String prefix;
     private String suffix;
diff --git a/azkaban-common/src/test/java/azkaban/ServiceProviderTest.java b/azkaban-common/src/test/java/azkaban/ServiceProviderTest.java
new file mode 100644
index 0000000..c84ee8a
--- /dev/null
+++ b/azkaban-common/src/test/java/azkaban/ServiceProviderTest.java
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2017 LinkedIn Corp.
+ *
+ * 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 azkaban;
+
+import azkaban.project.JdbcProjectLoader;
+import azkaban.spi.Storage;
+import azkaban.storage.DatabaseStorage;
+import azkaban.storage.LocalStorage;
+import azkaban.storage.StorageManager;
+import azkaban.utils.Props;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import java.io.File;
+import org.apache.commons.io.FileUtils;
+import org.junit.After;
+import org.junit.Test;
+
+import static azkaban.ServiceProvider.*;
+import static org.junit.Assert.*;
+
+
+public class ServiceProviderTest {
+
+  public static final String AZKABAN_LOCAL_TEST_STORAGE = "AZKABAN_LOCAL_TEST_STORAGE";
+
+  @After
+  public void tearDown() throws Exception {
+    FileUtils.deleteDirectory(new File(AZKABAN_LOCAL_TEST_STORAGE));
+  }
+
+  @Test
+  public void testInjections() throws Exception {
+    Props props = new Props();
+    props.put("database.type", "h2");
+    props.put("h2.path", "h2");
+    props.put(Constants.ConfigurationKeys.AZKABAN_STORAGE_LOCAL_BASEDIRECTORY, AZKABAN_LOCAL_TEST_STORAGE);
+
+
+    Injector injector = Guice.createInjector(
+        new AzkabanCommonModule(props)
+    );
+    SERVICE_PROVIDER.unsetInjector();
+    SERVICE_PROVIDER.setInjector(injector);
+
+    assertNotNull(SERVICE_PROVIDER.getInstance(JdbcProjectLoader.class));
+    assertNotNull(SERVICE_PROVIDER.getInstance(StorageManager.class));
+    assertNotNull(SERVICE_PROVIDER.getInstance(DatabaseStorage.class));
+    assertNotNull(SERVICE_PROVIDER.getInstance(LocalStorage.class));
+    assertNotNull(SERVICE_PROVIDER.getInstance(Storage.class));
+  }
+}
diff --git a/azkaban-common/src/test/java/azkaban/storage/LocalStorageTest.java b/azkaban-common/src/test/java/azkaban/storage/LocalStorageTest.java
new file mode 100644
index 0000000..f4ff2b4
--- /dev/null
+++ b/azkaban-common/src/test/java/azkaban/storage/LocalStorageTest.java
@@ -0,0 +1,84 @@
+/*
+ * Copyright 2017 LinkedIn Corp.
+ *
+ * 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 azkaban.storage;
+
+import azkaban.spi.StorageMetadata;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.InputStream;
+import java.net.URI;
+import org.apache.commons.io.FileUtils;
+import org.apache.log4j.Logger;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import static org.junit.Assert.*;
+
+
+public class LocalStorageTest {
+  private static final Logger log = Logger.getLogger(LocalStorageTest.class);
+
+  static final String SAMPLE_FILE = "sample_flow_01.zip";
+  static final String LOCAL_STORAGE = "LOCAL_STORAGE";
+  static final File BASE_DIRECTORY = new File(LOCAL_STORAGE);
+
+  private final LocalStorage localStorage = new LocalStorage(BASE_DIRECTORY);
+
+  @Before
+  public void setUp() throws Exception {
+    tearDown();
+    BASE_DIRECTORY.mkdir();
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    FileUtils.deleteDirectory(BASE_DIRECTORY);
+  }
+
+  @Test
+  public void testAll() throws Exception {
+    ClassLoader classLoader = getClass().getClassLoader();
+    File testFile = new File(classLoader.getResource(SAMPLE_FILE).getFile());
+
+    URI key;
+    try (InputStream is = new FileInputStream(testFile)) {
+      // test put
+      key = localStorage.put(new StorageMetadata("testProjectId", "1", "zip"), is);
+    }
+    assertNotNull(key);
+    log.info("Key URI: " + key);
+
+    File expectedTargetFile = new File(BASE_DIRECTORY, new StringBuilder()
+        .append("testProjectId")
+        .append(File.separator)
+        .append("1.zip")
+        .toString()
+    );
+    assertTrue(expectedTargetFile.exists());
+    assertTrue(FileUtils.contentEquals(testFile, expectedTargetFile));
+
+    // test get
+    InputStream getIs = localStorage.get(key);
+    assertNotNull(getIs);
+    File getFile = new File("tmp.get");
+    FileUtils.copyInputStreamToFile(getIs, getFile);
+    assertTrue(FileUtils.contentEquals(testFile, getFile));
+    getFile.delete();
+  }
+}
diff --git a/azkaban-common/src/test/resources/sample_flow_01.zip b/azkaban-common/src/test/resources/sample_flow_01.zip
new file mode 100644
index 0000000..1147976
Binary files /dev/null and b/azkaban-common/src/test/resources/sample_flow_01.zip differ
diff --git a/azkaban-spi/src/main/java/azkaban/spi/StorageMetadata.java b/azkaban-spi/src/main/java/azkaban/spi/StorageMetadata.java
new file mode 100644
index 0000000..9ae84c9
--- /dev/null
+++ b/azkaban-spi/src/main/java/azkaban/spi/StorageMetadata.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright 2017 LinkedIn Corp.
+ *
+ * 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 azkaban.spi;
+
+import java.util.Objects;
+
+import static java.util.Objects.*;
+
+
+public class StorageMetadata {
+  private final String projectId;
+  private final String version;
+  private final String extension;
+
+  public StorageMetadata(String projectId, String version, String extension) {
+    this.projectId = requireNonNull(projectId);
+    this.version = requireNonNull(version);
+    this.extension = requireNonNull(extension);
+  }
+
+  @Override
+  public String toString() {
+    return "StorageMetadata{" + "projectId='" + projectId + '\'' + ", version='" + version + '\'' + '}';
+  }
+
+  public String getProjectId() {
+    return projectId;
+  }
+
+  public String getVersion() {
+    return version;
+  }
+
+  public String getExtension() {
+    return extension;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) {
+      return true;
+    }
+    if (o == null || getClass() != o.getClass()) {
+      return false;
+    }
+    StorageMetadata that = (StorageMetadata) o;
+    return Objects.equals(projectId, that.projectId) &&
+        Objects.equals(version, that.version) &&
+        Objects.equals(extension, that.extension);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(projectId, version, extension);
+  }
+}