diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b3072d4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +.classpath +.git +.gitignore +.project +.settings +.travis.yml +LICENSE +README.md +pom.xml +src +target/classes +target/etcd-viewer-*-SNAPSHOT +target/generated-sources +target/generated-test-sources +target/maven-archiver +target/surefire-reports +target/test-classes +target/test-resources +target/work diff --git a/Dockerfile b/Dockerfile index 0fc74c5..698ccfd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM jetty:9.2.12-jre7 +FROM jetty:9.2-jre8 MAINTAINER Nikos Fountas "nikfoundas@gmail.com" @@ -11,6 +11,3 @@ RUN curl --silent -L `cat /tmp/etcd-viewer-release-archive` > /var/lib/jetty/web # To build your own local custom etcd viewer docker image comment the above RUN commands and uncomment the following # ADD ./target/*.war /var/lib/jetty/webapps/ROOT.war - -RUN chmod 644 /var/lib/jetty/webapps/ROOT.war -RUN chown jetty:jetty /var/lib/jetty/webapps/ROOT.war diff --git a/pom.xml b/pom.xml index d8c3bdd..5f78a1b 100644 --- a/pom.xml +++ b/pom.xml @@ -22,6 +22,7 @@ 1.6.4 1.2.16 4.11 + 2.15.0 3.3.4 4.3.0 @@ -189,6 +190,13 @@ ${junit.version} test + + + org.mockito + mockito-core + ${mockito.version} + test + diff --git a/src/main/java/org/github/etcd/service/EtcdCluster.java b/src/main/java/org/github/etcd/service/EtcdCluster.java index a9a375d..7a051cd 100644 --- a/src/main/java/org/github/etcd/service/EtcdCluster.java +++ b/src/main/java/org/github/etcd/service/EtcdCluster.java @@ -1,6 +1,3 @@ -/** - * - */ package org.github.etcd.service; import java.util.Date; diff --git a/src/main/java/org/github/etcd/service/RestModule.java b/src/main/java/org/github/etcd/service/RestModule.java index a81c905..4463dac 100644 --- a/src/main/java/org/github/etcd/service/RestModule.java +++ b/src/main/java/org/github/etcd/service/RestModule.java @@ -1,15 +1,12 @@ -/** - * - */ package org.github.etcd.service; import javax.inject.Inject; import javax.inject.Singleton; -import org.github.etcd.service.api.v3.EtcdV3ProxyImpl; -import org.github.etcd.service.impl.ClusterManagerImpl; import org.github.etcd.service.api.EtcdProxy; import org.github.etcd.service.api.v2.EtcdV2ProxyImpl; +import org.github.etcd.service.api.v3.EtcdV3ProxyImpl; +import org.github.etcd.service.impl.ClusterManagerImpl; import org.github.etcd.viewer.EtcdWebSession; import com.google.inject.AbstractModule; @@ -17,24 +14,24 @@ public class RestModule extends AbstractModule { - private static final String ETCD_NODE = "etcd.clientURL"; - public static final String SELECTED_CLUSTER_NAME = "selectedCluster"; @Override protected void configure() { - - String etcdAddress = System.getenv(ETCD_NODE); - if (etcdAddress == null) { - etcdAddress = System.getProperty(ETCD_NODE, "http://localhost:2379/"); - } - - bindConstant().annotatedWith(Names.named(ETCD_NODE)).to(etcdAddress); + bindParameterValue(ClusterManagerImpl.DEFAULT_CLIENT_URL_KEY); + bindParameterValue(ClusterManagerImpl.CLUSTER_STORE_PATH_KEY); bind(ClusterManager.class).to(ClusterManagerImpl.class).in(Singleton.class); bind(EtcdProxyFactory.class).to(EtcdProxyFactoryImpl.class).in(Singleton.class); + } + private void bindParameterValue(String parameterName) { + String parameterValue = System.getenv(parameterName); + if (parameterValue == null) { + parameterValue = System.getProperty(parameterName, ""); + } + bindConstant().annotatedWith(Names.named(parameterName)).to(parameterValue); } private static class EtcdProxyFactoryImpl implements EtcdProxyFactory { diff --git a/src/main/java/org/github/etcd/service/api/EtcdNode.java b/src/main/java/org/github/etcd/service/api/EtcdNode.java index 8319df8..f898022 100644 --- a/src/main/java/org/github/etcd/service/api/EtcdNode.java +++ b/src/main/java/org/github/etcd/service/api/EtcdNode.java @@ -1,6 +1,3 @@ -/** - * - */ package org.github.etcd.service.api; import java.io.Serializable; diff --git a/src/main/java/org/github/etcd/service/impl/ClusterManagerImpl.java b/src/main/java/org/github/etcd/service/impl/ClusterManagerImpl.java index 1190fdc..7233483 100644 --- a/src/main/java/org/github/etcd/service/impl/ClusterManagerImpl.java +++ b/src/main/java/org/github/etcd/service/impl/ClusterManagerImpl.java @@ -1,5 +1,8 @@ package org.github.etcd.service.impl; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; @@ -21,6 +24,11 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.base.Strings; +import com.google.inject.name.Named; + public class ClusterManagerImpl implements ClusterManager { private static final Logger log = LoggerFactory.getLogger(ClusterManager.class); @@ -41,24 +49,47 @@ public int compare(EtcdMember o1, EtcdMember o2) { STATE_MAPPINGS.put("StateFollower", "follower"); } - @Inject - private EtcdProxyFactory proxyFactory; + private final EtcdProxyFactory proxyFactory; private Map clusters = Collections.synchronizedMap(new LinkedHashMap()); - private static final String DEFAULT_ETCD_CLIENT = "ETCD_CLIENT_URL"; - - public ClusterManagerImpl() { - - String etcdAddress = System.getenv(DEFAULT_ETCD_CLIENT); - if (etcdAddress == null) { - etcdAddress = System.getProperty(DEFAULT_ETCD_CLIENT, "http://localhost:2379"); + public static final String DEFAULT_CLIENT_URL_KEY = "ETCD_CLIENT_URL"; + private static final String DEFAULT_CLIENT_URL = "http://localhost:2379/"; + public static final String CLUSTER_STORE_PATH_KEY = "CLUSTER_STORE_PATH"; + private static final String CLUSTER_STORE_FILENAME = "clusters.json"; + + private final File clusterStoreFile; + private final ObjectMapper mapper = new ObjectMapper(); + + @Inject + public ClusterManagerImpl(@Named(DEFAULT_CLIENT_URL_KEY) String defaultClientUrl, + @Named(CLUSTER_STORE_PATH_KEY) String clusterStorePath, + EtcdProxyFactory proxyFactory) { + log.debug("{}: {}", DEFAULT_CLIENT_URL_KEY, defaultClientUrl); + log.debug("{}: {}", CLUSTER_STORE_PATH_KEY, clusterStorePath); + + if (Strings.isNullOrEmpty(defaultClientUrl)) { + defaultClientUrl = DEFAULT_CLIENT_URL; + log.debug("Using default {}: {}", DEFAULT_CLIENT_URL_KEY, defaultClientUrl); + } + + this.proxyFactory = proxyFactory; + + if (!Strings.isNullOrEmpty(clusterStorePath)) { + clusterStoreFile = new File(clusterStorePath, CLUSTER_STORE_FILENAME); + if (clusterStoreFile.exists()) { + loadClusters(); + } + } else { + clusterStoreFile = null; } - addCluster("default", etcdAddress, ApiVersion.V3); - // addCluster("kvm", "http://192.168.122.201:2379/"); + if (clusters.isEmpty()) { + addCluster("Local V2", defaultClientUrl, ApiVersion.V2); + addCluster("Local V3", defaultClientUrl, ApiVersion.V3); + } } - + @Override public boolean exists(String name) { return clusters.containsKey(name); @@ -73,12 +104,17 @@ public EtcdCluster getCluster(String name) { public EtcdCluster addCluster(String name, String etcdPeerAddress, ApiVersion apiVersion) { EtcdCluster cluster = new EtcdCluster(name, etcdPeerAddress, apiVersion); clusters.put(name, cluster); + + persistClusters(); + return cluster; } @Override public void removeCluster(String name) { clusters.remove(name); + + persistClusters(); } @Override @@ -157,6 +193,40 @@ public void refreshCluster(String name) { cluster.setLastRefreshTime(new Date()); cluster.setRefreshed(true); + persistClusters(); + } + + private void loadClusters() { + log.info("Loading {}", clusterStoreFile.getAbsolutePath()); + try { + Map incoming = mapper.readValue(clusterStoreFile, + new TypeReference>() {}); + clusters = Collections.synchronizedMap(incoming); + + log.debug("Loaded {} clusters", clusters.size()); + } catch (IOException e) { + log.error("Unable to read cluster config from {}", clusterStoreFile.getAbsolutePath()); + } + } + + private synchronized void persistClusters() { + if (clusterStoreFile != null) { + try { + Files.createDirectories(clusterStoreFile.getParentFile().toPath()); + } catch (IOException e) { + log.error("Unable to create configured cluster storage directory {}", + clusterStoreFile.getParentFile().getAbsolutePath(), e); + return; + } + + log.debug("Writing {} clusters", clusters.size()); + + try { + mapper.writeValue(clusterStoreFile, clusters); + } catch (IOException e) { + log.error("Unable to write cluster config to {}", clusterStoreFile.getAbsolutePath(), e); + } + } } } diff --git a/src/main/java/org/github/etcd/viewer/ConvertUtils.java b/src/main/java/org/github/etcd/viewer/ConvertUtils.java index a9f18c4..9335fcc 100644 --- a/src/main/java/org/github/etcd/viewer/ConvertUtils.java +++ b/src/main/java/org/github/etcd/viewer/ConvertUtils.java @@ -1,6 +1,3 @@ -/** - * - */ package org.github.etcd.viewer; import org.apache.wicket.request.mapper.parameter.PageParameters; diff --git a/src/main/java/org/github/etcd/viewer/html/cluster/AddClusterModalPanel.java b/src/main/java/org/github/etcd/viewer/html/cluster/AddClusterModalPanel.java index 68fb9ad..717c383 100644 --- a/src/main/java/org/github/etcd/viewer/html/cluster/AddClusterModalPanel.java +++ b/src/main/java/org/github/etcd/viewer/html/cluster/AddClusterModalPanel.java @@ -9,7 +9,6 @@ import org.apache.wicket.markup.html.form.FormComponent; import org.apache.wicket.markup.html.form.TextField; import org.apache.wicket.model.CompoundPropertyModel; -import org.apache.wicket.model.Model; import org.apache.wicket.model.ResourceModel; import org.apache.wicket.util.visit.IVisit; import org.apache.wicket.util.visit.IVisitor; diff --git a/src/main/java/org/github/etcd/viewer/html/node/EtcdNodePanel.java b/src/main/java/org/github/etcd/viewer/html/node/EtcdNodePanel.java index f947bc9..65868ad 100644 --- a/src/main/java/org/github/etcd/viewer/html/node/EtcdNodePanel.java +++ b/src/main/java/org/github/etcd/viewer/html/node/EtcdNodePanel.java @@ -1,6 +1,3 @@ -/** - * - */ package org.github.etcd.viewer.html.node; import java.util.ArrayList; diff --git a/src/main/java/org/github/etcd/viewer/html/pages/NavigationPage.java b/src/main/java/org/github/etcd/viewer/html/pages/NavigationPage.java index 8320c79..a1952b2 100644 --- a/src/main/java/org/github/etcd/viewer/html/pages/NavigationPage.java +++ b/src/main/java/org/github/etcd/viewer/html/pages/NavigationPage.java @@ -1,6 +1,3 @@ -/** - * - */ package org.github.etcd.viewer.html.pages; import org.apache.wicket.AttributeModifier; diff --git a/src/test/java/org/github/etcd/rest/EtcdResource.java b/src/test/java/org/github/etcd/rest/EtcdResource.java index 71a23d5..e7c3d95 100644 --- a/src/test/java/org/github/etcd/rest/EtcdResource.java +++ b/src/test/java/org/github/etcd/rest/EtcdResource.java @@ -1,6 +1,3 @@ -/** - * - */ package org.github.etcd.rest; import javax.ws.rs.DELETE; diff --git a/src/test/java/org/github/etcd/service/impl/ClusterManagerTest.java b/src/test/java/org/github/etcd/service/impl/ClusterManagerTest.java new file mode 100644 index 0000000..449deda --- /dev/null +++ b/src/test/java/org/github/etcd/service/impl/ClusterManagerTest.java @@ -0,0 +1,240 @@ +package org.github.etcd.service.impl; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +import org.github.etcd.service.ApiVersion; +import org.github.etcd.service.ClusterManager; +import org.github.etcd.service.EtcdCluster; +import org.github.etcd.service.EtcdProxyFactory; +import org.github.etcd.service.api.EtcdMember; +import org.github.etcd.service.api.EtcdProxy; +import org.github.etcd.service.api.EtcdSelfStats; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +public class ClusterManagerTest { + + private static final String STORE_READ_TEST_PATH = "src/test/resources/storereadtest"; + private static final String STORE_WRITE_TEST_PATH = "target/test-resources/storewritetest"; + private static final String BACKUP_STORE_FILENAME = "clusters.json.bak"; + + private EtcdProxyFactory etcdProxyFactory; + + @Before + public void setup() throws Exception { + etcdProxyFactory = mock(EtcdProxyFactory.class); + + wipeWriteTestPath(); + backupReadData(); + } + + @After + public void tearDown() throws Exception { + replaceReadData(); + wipeWriteTestPath(); + } + + private void wipeWriteTestPath() throws IOException { + Path storeWriteTestPath = Paths.get(STORE_WRITE_TEST_PATH); + if (Files.exists(storeWriteTestPath)) { + Files.walk(storeWriteTestPath) + .sorted(Comparator.reverseOrder()) + .map(Path::toFile) + .forEach(File::delete); + } + } + + private void backupReadData() throws Exception { + Files.copy(Paths.get(STORE_READ_TEST_PATH, "clusters.json"), + Paths.get(STORE_READ_TEST_PATH, BACKUP_STORE_FILENAME), StandardCopyOption.REPLACE_EXISTING); + } + + private void replaceReadData() throws Exception { + Files.move(Paths.get(STORE_READ_TEST_PATH, BACKUP_STORE_FILENAME), + Paths.get(STORE_READ_TEST_PATH, "clusters.json"), StandardCopyOption.REPLACE_EXISTING); + } + + @Test + public void constructor_noDefaultUrl_noStorePath() { + ClusterManager clusterManager = new ClusterManagerImpl("", "", etcdProxyFactory); + List clusters = clusterManager.getClusters(); + + boolean foundDefaultV2 = false; + boolean foundDefaultV3 = false; + for (EtcdCluster cluster : clusters) { + boolean v2 = "Local V2".equals(cluster.getName()); + boolean v3 = "Local V3".equals(cluster.getName()); + boolean urlMatches = "http://localhost:2379/".equals(cluster.getAddress()); + + foundDefaultV2 |= v2 && urlMatches && cluster.getApiVersion() == ApiVersion.V2; + foundDefaultV3 |= v3 && urlMatches && cluster.getApiVersion() == ApiVersion.V3; + } + + assertTrue(foundDefaultV2); + assertTrue(foundDefaultV3); + assertFalse(Files.exists(Paths.get(STORE_WRITE_TEST_PATH))); + } + + @Test + public void constructor_defaultUrl_noStorePath() { + String defaultClientUrl = "test default client URL"; + ClusterManager clusterManager = new ClusterManagerImpl(defaultClientUrl, "", etcdProxyFactory); + List clusters = clusterManager.getClusters(); + + boolean foundDefaultV2 = false; + boolean foundDefaultV3 = false; + for (EtcdCluster cluster : clusters) { + boolean v2 = "Local V2".equals(cluster.getName()); + boolean v3 = "Local V3".equals(cluster.getName()); + boolean urlMatches = defaultClientUrl.equals(cluster.getAddress()); + + foundDefaultV2 |= v2 && urlMatches && cluster.getApiVersion() == ApiVersion.V2; + foundDefaultV3 |= v3 && urlMatches && cluster.getApiVersion() == ApiVersion.V3; + } + + assertTrue(foundDefaultV2); + assertTrue(foundDefaultV3); + assertFalse(Files.exists(Paths.get(STORE_WRITE_TEST_PATH))); + } + + @Test + public void constructor_noExistingFile() { + assertFalse(Files.exists(Paths.get(STORE_WRITE_TEST_PATH))); + + String defaultClientUrl = "test default client URL"; + ClusterManager clusterManager = new ClusterManagerImpl(defaultClientUrl, STORE_WRITE_TEST_PATH, + etcdProxyFactory); + List clusters = clusterManager.getClusters(); + + boolean foundDefaultV2 = false; + boolean foundDefaultV3 = false; + for (EtcdCluster cluster : clusters) { + boolean v2 = "Local V2".equals(cluster.getName()); + boolean v3 = "Local V3".equals(cluster.getName()); + boolean urlMatches = defaultClientUrl.equals(cluster.getAddress()); + + foundDefaultV2 |= v2 && urlMatches && cluster.getApiVersion() == ApiVersion.V2; + foundDefaultV3 |= v3 && urlMatches && cluster.getApiVersion() == ApiVersion.V3; + } + + assertTrue(foundDefaultV2); + assertTrue(foundDefaultV3); + assertTrue(Files.exists(Paths.get(STORE_WRITE_TEST_PATH, "clusters.json"))); + } + + @Test + public void constructor_existingFile() { + ClusterManager clusterManager = new ClusterManagerImpl("", STORE_READ_TEST_PATH, etcdProxyFactory); + List clusters = clusterManager.getClusters(); + + assertEquals(2, clusters.size()); + for (EtcdCluster cluster : clusters) { + boolean v2NameMatch = "Existing V2".equals(cluster.getName()); + boolean v3NameMatch = "Existing V3".equals(cluster.getName()); + if (v2NameMatch) { + assertTrue("V2 client URL".equals(cluster.getAddress())); + assertTrue(cluster.getApiVersion() == ApiVersion.V2); + } else if (v3NameMatch) { + assertTrue("V3 client URL".equals(cluster.getAddress())); + assertTrue(cluster.getApiVersion() == ApiVersion.V3); + } + } + } + + @Test + public void addCluster_remove() { + ClusterManager clusterManager1 = new ClusterManagerImpl("", STORE_WRITE_TEST_PATH, etcdProxyFactory); + assertEquals(2, clusterManager1.getClusters().size()); + clusterManager1.removeCluster(clusterManager1.getClusters().get(0).getName()); + assertEquals(1, clusterManager1.getClusters().size()); + + ClusterManager clusterManager2 = new ClusterManagerImpl("", STORE_WRITE_TEST_PATH, etcdProxyFactory); + assertEquals(1, clusterManager2.getClusters().size()); + assertEquals(clusterManager1.getClusters().get(0).getName(), clusterManager2.getClusters().get(0).getName()); + } + + @Test + public void addCluster_persist() { + ClusterManager clusterManager1 = new ClusterManagerImpl("", STORE_WRITE_TEST_PATH, etcdProxyFactory); + for (EtcdCluster cluster : clusterManager1.getClusters()) { + clusterManager1.removeCluster(cluster.getName()); + } + assertTrue(clusterManager1.getClusters().isEmpty()); + + String expectedName = "new cluster 1"; + String expectedUrl = "new client URL"; + ApiVersion expectedVersion = ApiVersion.V2; + clusterManager1.addCluster(expectedName, expectedUrl, expectedVersion); + + assertTrue(Files.exists(Paths.get(STORE_WRITE_TEST_PATH, "clusters.json"))); + + ClusterManager clusterManager2 = new ClusterManagerImpl("", STORE_WRITE_TEST_PATH, etcdProxyFactory); + + assertEquals(1, clusterManager2.getClusters().size()); + + EtcdCluster cluster = clusterManager2.getClusters().get(0); + assertEquals(expectedName, cluster.getName()); + assertEquals(expectedUrl, cluster.getAddress()); + assertEquals(expectedVersion, cluster.getApiVersion()); + } + + @Test + public void refresh() { + ClusterManager clusterManager = new ClusterManagerImpl("", "", etcdProxyFactory); + List clusters = clusterManager.getClusters(); + for (EtcdCluster cluster : clusters) { + clusterManager.removeCluster(cluster.getName()); + } + + String clusterName = "new cluster 1"; + String clusterUrl = "new client URL"; + ApiVersion clusterVersion = ApiVersion.V2; + clusterManager.addCluster(clusterName, clusterUrl, clusterVersion); + EtcdCluster addedCluster = clusterManager.getCluster(clusterName); + assertNull(addedCluster.getMembers()); + assertFalse(addedCluster.isRefreshed()); + + EtcdMember leaderMember = new EtcdMember(); + leaderMember.setName("leader member"); + leaderMember.setClientURLs(Collections.singletonList("leaderUrl")); + EtcdMember followerMember = new EtcdMember(); + followerMember.setName("follower member"); + followerMember.setClientURLs(Collections.singletonList("followerUrl")); + EtcdProxy proxyToCluster = mock(EtcdProxy.class); + when(proxyToCluster.getMembers()).thenReturn(Arrays.asList(leaderMember, followerMember)); + when(etcdProxyFactory.getEtcdProxy(clusterName, clusterUrl, clusterVersion)).thenReturn(proxyToCluster); + + EtcdProxy proxyToLeader = mock(EtcdProxy.class); + EtcdSelfStats leaderStats = new EtcdSelfStats(); + leaderStats.setState("leader"); + when(proxyToLeader.getSelfStats()).thenReturn(leaderStats); + when(etcdProxyFactory.getEtcdProxy(clusterName, leaderMember.getClientURLs().get(0), clusterVersion)).thenReturn(proxyToLeader); + + EtcdProxy proxyToFollower = mock(EtcdProxy.class); + EtcdSelfStats followerStats = new EtcdSelfStats(); + followerStats.setState("follower"); + when(proxyToFollower.getSelfStats()).thenReturn(followerStats); + when(etcdProxyFactory.getEtcdProxy(clusterName, followerMember.getClientURLs().get(0), clusterVersion)).thenReturn(proxyToFollower); + + clusterManager.refreshCluster(clusterName); + + EtcdCluster refreshedCluster = clusterManager.getCluster(clusterName); + assertEquals(leaderMember.getClientURLs().get(0), refreshedCluster.getAddress()); + assertEquals(2, refreshedCluster.getMembers().size()); + assertTrue(refreshedCluster.isRefreshed()); + } + +} diff --git a/src/test/resources/log4j.properties b/src/test/resources/log4j.properties new file mode 100644 index 0000000..08f1ead --- /dev/null +++ b/src/test/resources/log4j.properties @@ -0,0 +1,14 @@ +log4j.appender.Stdout=org.apache.log4j.ConsoleAppender +log4j.appender.Stdout.layout=org.apache.log4j.PatternLayout +log4j.appender.Stdout.layout.conversionPattern=%-5p - %-26.26c{1} - %m\n + +log4j.rootLogger=INFO,Stdout + +log4j.logger.org.github.etcd=DEBUG + +log4j.logger.org.apache.wicket=INFO +log4j.logger.org.apache.wicket.protocol.http.HttpSessionStore=INFO +log4j.logger.org.apache.wicket.version=INFO +log4j.logger.org.apache.wicket.RequestCycle=INFO + +#log4j.logger.org.apache.cxf=DEBUG diff --git a/src/test/resources/storereadtest/clusters.json b/src/test/resources/storereadtest/clusters.json new file mode 100644 index 0000000..92a0618 --- /dev/null +++ b/src/test/resources/storereadtest/clusters.json @@ -0,0 +1 @@ +{"Local V2":{"name":"Existing V2","members":null,"refreshed":false,"authEnabled":false,"address":"V2 client URL","apiVersion":"V2","lastRefreshTime":null},"Local V3":{"name":"Existing V3","members":null,"refreshed":false,"authEnabled":false,"address":"V3 client URL","apiVersion":"V3","lastRefreshTime":null}} \ No newline at end of file