TestConfigMap.java
/*
* Copyright (c) 2020, Stein Eldar Johnsen
*
* 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 net.morimekta.testing.file;
import net.morimekta.file.FileUtil;
import net.morimekta.testing.io.ResourceUtil;
import java.io.Closeable;
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.StandardOpenOption;
import java.util.HashSet;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.regex.Pattern;
/**
* A helper class to wrap a directory and operate on it like a kubernetes config map.
*/
public class TestConfigMap {
private final Class<?> resourceLoaderClass;
private final Path configMapDir;
private final Path data;
private final AtomicReference<Path> current;
private final AtomicInteger nextId;
private final Set<String> currentDataNames;
public TestConfigMap(Path dir) throws IOException {
this(dir, getCallingMethodClass());
}
public TestConfigMap(Path dir, Class<?> resourceLoaderClass) throws IOException {
var nextId = new AtomicInteger(ThreadLocalRandom.current().nextInt(1000, 9000));
var current = new AtomicReference<>(dir.resolve(".data_" + nextId.get()));
var data = dir.resolve(".data");
Files.createDirectory(current.get());
Files.createSymbolicLink(data, current.get().getFileName());
this.resourceLoaderClass = resourceLoaderClass;
this.configMapDir = dir;
this.nextId = nextId;
this.data = data;
this.current = current;
this.currentDataNames = new TreeSet<>();
}
public File getFile(String name) {
return configMapDir.resolve(validName(name)).toFile();
}
public Path getPath(String name) {
return configMapDir.resolve(validName(name));
}
public Path getConfigMapDir() {
return configMapDir;
}
public UpdateHelper update() throws IOException {
Path newDataDir = configMapDir.resolve(".data_" + nextId.incrementAndGet());
return new UpdateHelper(newDataDir);
}
@Override
public String toString() {
StringBuilder builder = new StringBuilder("ConfigMap{");
try {
boolean first = true;
for (Path path : new TreeSet<>(FileUtil.list(configMapDir, false))) {
if (Files.isHidden(path)) {
continue;
}
if (first) {
first = false;
} else {
builder.append(",");
}
builder.append("\"");
builder.append(path.getFileName());
builder.append("\"");
}
} catch (IOException e) {
builder.append(" // Error: ").append(e.getMessage());
}
builder.append("}");
return builder.toString();
}
public class UpdateHelper implements Closeable {
private final Path newDataDir;
private final Set<String> newDataNames;
private UpdateHelper(Path newDataDir) throws IOException {
Files.createDirectory(newDataDir);
this.newDataDir = newDataDir;
this.newDataNames = new TreeSet<>(currentDataNames);
for (String name : newDataNames) {
Path file = TestConfigMap.this.current.get().resolve(name);
Path target = newDataDir.resolve(name);
Files.copy(file, target);
}
}
public boolean delete(String name) throws IOException {
if (newDataNames.contains(validName(name))) {
Files.deleteIfExists(newDataDir.resolve(name));
newDataNames.remove(name);
return true;
}
return false;
}
public void copyResource(String resource) throws IOException {
var lastSeparator = resource.lastIndexOf("/");
var name = lastSeparator < 0 ? resource : resource.substring(lastSeparator + 1);
Path path = newDataDir.resolve(validName(name));
newDataNames.add(name);
Files.deleteIfExists(path);
ResourceUtil.copyResourceTo(resourceLoaderClass, resource, path);
}
public void copyResource(String name, String resource) throws IOException {
Path path = newDataDir.resolve(validName(name));
newDataNames.add(name);
Files.deleteIfExists(path);
ResourceUtil.copyResourceTo(resourceLoaderClass, resource, path);
}
public void writeContent(String name, String content) throws IOException {
Path path = newDataDir.resolve(validName(name));
newDataNames.add(name);
Files.writeString(path, content, StandardOpenOption.CREATE, StandardOpenOption.CREATE_NEW);
}
public Path path(String name) {
newDataNames.add(validName(name));
return newDataDir.resolve(name);
}
public File file(String name) {
newDataNames.add(validName(name));
return newDataDir.resolve(name).toFile();
}
@Override
public void close() throws IOException {
// A.: remove old "removed" files.
Set<String> removed = new HashSet<>(currentDataNames);
removed.removeAll(this.newDataNames);
for (String name : removed) {
Files.deleteIfExists(configMapDir.resolve(name));
}
currentDataNames.retainAll(newDataNames);
// B.: move symlink to new folder.
FileUtil.replaceSymbolicLink(data, newDataDir.getFileName());
// C.: ensure symlinks to new files.
for (String name : newDataNames) {
if (!Files.exists(configMapDir.resolve(name))) {
if (!Files.exists(newDataDir.resolve(name))) {
throw new IllegalStateException(
"No file created for '" + name + "'.");
}
Files.createSymbolicLink(configMapDir.resolve(name), Paths.get(".data", name));
}
}
currentDataNames.addAll(newDataNames);
// D.: delete old data dir.
FileUtil.deleteRecursively(current.get());
current.set(newDataDir);
}
}
private static String validName(String name) {
if (!VALID_NAME.matcher(name).matches()) {
throw new IllegalArgumentException("Invalid config map file name: \"" + name + "\"");
}
return name;
}
// only containing ASCII letters and numbers and '_',
// and only '.' and '-' inside the name, not at beginning or end.
private static final Pattern VALID_NAME = Pattern.compile("[_a-zA-Z0-9][-._a-zA-Z0-9]*[_a-zA-Z0-9]");
@SuppressWarnings("unchecked")
private static Class<?> getCallingMethodClass() {
return StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE)
.walk(frameStream -> frameStream
.map(frame -> (Class<Object>) frame.getDeclaringClass())
.filter(type -> !type.equals(TestConfigMap.class))
.findFirst())
.orElse((Class<Object>) TestConfigMap.class.asSubclass(Object.class));
}
}