diff --git a/src/main/java/com/redhat/devtools/intellij/common/utils/ConfigWatcher.java b/src/main/java/com/redhat/devtools/intellij/common/utils/ConfigWatcher.java index 696f992..f796983 100644 --- a/src/main/java/com/redhat/devtools/intellij/common/utils/ConfigWatcher.java +++ b/src/main/java/com/redhat/devtools/intellij/common/utils/ConfigWatcher.java @@ -13,7 +13,6 @@ import com.intellij.openapi.diagnostic.Logger; import io.fabric8.kubernetes.api.model.Config; import io.fabric8.kubernetes.client.internal.KubeConfigUtils; -import org.jetbrains.annotations.NotNull; import java.io.IOException; import java.nio.file.FileSystems; @@ -24,14 +23,19 @@ import java.nio.file.WatchEvent; import java.nio.file.WatchKey; import java.nio.file.WatchService; +import java.util.Collection; +import java.util.List; import java.util.function.Consumer; +import java.util.stream.Collectors; public class ConfigWatcher implements Runnable { private static final Logger LOG = Logger.getInstance(ConfigWatcher.class); - private final Path config; - protected Listener listener; + private final List configs; + protected final Listener listener; + private final HighSensitivityRegistrar registrar; + private WatchService service; public interface Listener { void onUpdate(ConfigWatcher source, Config config); @@ -42,94 +46,146 @@ public ConfigWatcher(String config, Listener listener) { } public ConfigWatcher(Path config, Listener listener) { - this.config = config; + this(List.of(config), listener); + } + + public ConfigWatcher(List configs, Listener listener) { + this(configs, listener, new HighSensitivityRegistrar()); + } + + public ConfigWatcher(List configs, Listener listener, HighSensitivityRegistrar registrar) { + this.configs = configs; this.listener = listener; + this.registrar = registrar; } @Override public void run() { - runOnConfigChange((Config config) -> { - listener.onUpdate(this, config); - }); + watch((Config config) -> listener.onUpdate(this, config)); } - protected Config loadConfig() { - try { - return ConfigHelper.loadKubeConfig(config.toAbsolutePath().toString()); - } catch (IOException e) { - return null; + public void close() throws IOException { + if (service != null) { + service.close(); } } - private void runOnConfigChange(Consumer consumer) { - try (WatchService service = newWatchService()) { - registerWatchService(service); - WatchKey key; - while ((key = service.take()) != null) { - key.pollEvents().stream() - .forEach((event) -> consumer.accept(loadConfig(getPath(event)))); - key.reset(); - } - } catch (IOException | InterruptedException e) { + private void watch(Consumer consumer) { + try (WatchService service = createWatchService()) { + Collection watchedDirectories = getWatchedDirectories(); + watchedDirectories.forEach(directory -> + new ConfigDirectoryWatch(directory, consumer, service, registrar).start() + ); + } catch (IOException e) { + String configPaths = configs.stream() + .map(path -> path.toAbsolutePath().toString()) + .collect(Collectors.joining()); Logger.getInstance(ConfigWatcher.class).warn( - "Could not watch kubernetes config file at " + config.toAbsolutePath(), e); + "Could not watch kubernetes config file at " + configPaths, e); } } - protected WatchService newWatchService() throws IOException { - return FileSystems.getDefault().newWatchService(); + protected WatchService createWatchService() throws IOException { + return this.service = FileSystems.getDefault().newWatchService(); } - @NotNull - private void registerWatchService(WatchService service) throws IOException { - HighSensitivityRegistrar modifier = new HighSensitivityRegistrar(); - modifier.registerService(getWatchedPath(), - new WatchEvent.Kind[]{ - StandardWatchEventKinds.ENTRY_CREATE, - StandardWatchEventKinds.ENTRY_MODIFY, - StandardWatchEventKinds.ENTRY_DELETE}, - service); + private Collection getWatchedDirectories() { + return configs.stream() + .filter(this::isFileInDirectory) + .map(Path::getParent) + .collect(Collectors.toSet()); } - protected boolean isConfigPath(Path path) { - return path.equals(config); + protected boolean isFileInDirectory(Path path) { + return path != null + && Files.isRegularFile(path) + && Files.isDirectory(path.getParent()); } - /** - * Returns {@link Config} for the given path if the kube config file - *
    - *
  • exists and
  • - *
  • is not empty and
  • - *
  • is valid yaml
  • - *
- * Returns {@code null} otherwise. - * - * @param path the path to the kube config - * @return returns true if the kube config that the event points to exists, is not empty and is valid yaml - */ - private Config loadConfig(Path path) { - if (path == null) { - return null; + private class ConfigDirectoryWatch { + private final Path directory; + private final WatchService service; + private final HighSensitivityRegistrar registrar; + private final Consumer consumer; + + private ConfigDirectoryWatch(Path directory, Consumer consumer, WatchService service, HighSensitivityRegistrar registrar) { + this.directory = directory; + this.consumer = consumer; + this.service = service; + this.registrar = registrar; } - try { - if (Files.exists(path) - && isConfigPath(path) - && Files.size(path) > 0) { - return KubeConfigUtils.parseConfig(path.toFile()); + + private void start() { + try { + register(directory, service, registrar); + watch(consumer, service); + } catch (InterruptedException e) { + LOG.warn("Watching " + directory + " was interrupted", e); + } catch (IOException e) { + LOG.warn("Could not watch " + directory, e); } - } catch (Exception e) { - // only catch - LOG.warn("Could not load kube config at " + path.toAbsolutePath(), e); } - return null; - } - private Path getPath(WatchEvent event) { - return getWatchedPath().resolve((Path) event.context()); - } + private void register(Path path, WatchService service, HighSensitivityRegistrar registrar) throws IOException { + registrar.registerService(path, + new WatchEvent.Kind[]{ + StandardWatchEventKinds.ENTRY_CREATE, + StandardWatchEventKinds.ENTRY_MODIFY, + StandardWatchEventKinds.ENTRY_DELETE + }, + service); + } + + private void watch(Consumer consumer, WatchService service) throws InterruptedException { + for (WatchKey key = service.take(); key != null; key = service.take()) { + key.pollEvents().forEach((event) -> { + Path changed = getAbsolutePath(directory, (Path) event.context()); + if (isConfigPath(changed)) { + consumer.accept(loadConfig(changed)); + } + }); + key.reset(); + } + } + + protected boolean isConfigPath(Path path) { + return configs != null + && configs.contains(path); + } + + /** + * Returns {@link Config} for the given path if the kube config file + *
    + *
  • exists and
  • + *
  • is not empty and
  • + *
  • is valid yaml
  • + *
+ * Returns {@code null} otherwise. + * + * @param path the path to the kube config + * @return returns true if the kube config that the event points to exists, is not empty and is valid yaml + */ + private Config loadConfig(Path path) { + // TODO: replace by Config#getKubeConfigFiles once kubernetes-client 7.0 is available + if (path == null) { + return null; + } + try { + if (Files.exists(path) + && Files.size(path) > 0) { + return KubeConfigUtils.parseConfig(path.toFile()); + } + } catch (Exception e) { + // only catch + LOG.warn("Could not load kube config at " + path.toAbsolutePath(), e); + } + return null; + } + + private Path getAbsolutePath(Path directory, Path relativePath) { + return directory.resolve(relativePath); + } - private Path getWatchedPath() { - return config.getParent(); } } diff --git a/src/test/java/com/redhat/devtools/intellij/common/utils/ConfigWatcherTest.java b/src/test/java/com/redhat/devtools/intellij/common/utils/ConfigWatcherTest.java new file mode 100644 index 0000000..ffd3cce --- /dev/null +++ b/src/test/java/com/redhat/devtools/intellij/common/utils/ConfigWatcherTest.java @@ -0,0 +1,164 @@ +/******************************************************************************* + * Copyright (c) 2024 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.intellij.common.utils; + +import io.fabric8.kubernetes.api.model.Config; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.WatchEvent; +import java.nio.file.WatchKey; +import java.nio.file.WatchService; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class ConfigWatcherTest { + + private Path config1; + private Path config2; + private Path config3; + + private final ConfigWatcher.Listener listener = mock(ConfigWatcher.Listener.class); + private final HighSensitivityRegistrar registrar = Mockito.mock(HighSensitivityRegistrar.class); + private final WatchService service = mock(WatchService.class); + + private ConfigWatcher watcher; + + @Before + public void before() throws IOException { + this.config1 = Paths.get(Files.createTempFile("skywalker", null).toString()); + this.config2 = Paths.get(Files.createTempFile("yoda", null).toString()); + this.config3 = Paths.get(Files.createTempFile("obiwan", null).toString()); + this.watcher = new TestableConfigWatcher(List.of(config1, config2, config3), listener, registrar, service); + } + + @Test + public void run_registers_service_for_parent_directory() throws IOException { + // given + // when + watcher.run(); + // then + verify(registrar).registerService(eq(config1.getParent()), any(), any()); + verify(registrar).registerService(eq(config2.getParent()), any(), any()); + verify(registrar).registerService(eq(config3.getParent()), any(), any()); + } + + @Test + public void run_registers_service_only_once_if_parent_directory_is_the_same_for_all_configs() throws IOException { + // given + // when + watcher.run(); + // then + verify(registrar, times(1)).registerService(any(), any(), any()); + } + + @Test + public void run_does_NOT_register_service_if_config_is_directory() throws IOException { + // given + Path tempDir = Paths.get(System.getProperty("java.io.tmpdir")); + ConfigWatcher watcher = new TestableConfigWatcher(List.of(tempDir), listener, registrar, service); + // when + watcher.run(); + // then + verify(registrar, never()).registerService(any(), any(), any()); + } + + @Test + public void listener_is_called_if_config_file_is_changed() throws InterruptedException { + // given + ReportingListener reportingListener = new ReportingListener(); + ConfigWatcher watcher = new TestableConfigWatcher(List.of(config1), reportingListener, registrar, service); + createWatchKeyForService(config1, service); // config-file + // when + watcher.run(); + // then + assertThat(reportingListener.isCalled()).isTrue(); + } + + @Test + public void listener_is_NOT_called_if_a_different_file_is_changed() throws InterruptedException { + // given + ReportingListener reportingListener = new ReportingListener(); + ConfigWatcher watcher = new TestableConfigWatcher(List.of(config1), reportingListener, registrar, service); + createWatchKeyForService(config2, service); // non-config file + // when + watcher.run(); + // then + assertThat(reportingListener.isCalled()).isFalse(); + } + + @Test + public void listener_key_is_reset() throws InterruptedException { + // given + ReportingListener reportingListener = new ReportingListener(); + ConfigWatcher watcher = new TestableConfigWatcher(List.of(config1), reportingListener, registrar, service); + WatchKey key = createWatchKeyForService(config2, service); // non-config file + // when + watcher.run(); + // then + verify(key).reset(); + } + + private static WatchKey createWatchKeyForService(Path path, WatchService service) throws InterruptedException { + WatchEvent event = mock(WatchEvent.class); + when(event.context()) + .thenReturn(path); + WatchKey key = mock(WatchKey.class); + when(key.pollEvents()) + .thenReturn(List.of(event)); + when(service.take()) + .thenReturn(key) + .thenReturn(null); // 2nd call, causes listener to stop + return key; + } + + private static class ReportingListener implements ConfigWatcher.Listener { + + private boolean called = false; + + @Override + public void onUpdate(ConfigWatcher source, Config config) { + this.called = true; + } + + public boolean isCalled() throws InterruptedException { + return called; + } + } + + private static class TestableConfigWatcher extends ConfigWatcher { + + private final WatchService service; + + public TestableConfigWatcher(List configs, Listener listener, HighSensitivityRegistrar registrar, WatchService service) { + super(configs, listener, registrar); + this.service = service; + } + + @Override + protected WatchService createWatchService() throws IOException { + return service; + } + } +}