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));
    }
}