diff options
Diffstat (limited to 'node-admin/src/main/java/com')
20 files changed, 1347 insertions, 182 deletions
diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/maintenance/StorageMaintainer.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/maintenance/StorageMaintainer.java index 7d98a76dc6e..340f43b4671 100644 --- a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/maintenance/StorageMaintainer.java +++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/maintenance/StorageMaintainer.java @@ -3,6 +3,7 @@ package com.yahoo.vespa.hosted.node.admin.maintenance; import com.google.common.cache.Cache; import com.google.common.cache.CacheBuilder; +import com.yahoo.config.provision.ApplicationId; import com.yahoo.config.provision.DockerImage; import com.yahoo.config.provision.NodeType; import com.yahoo.vespa.hosted.node.admin.component.TaskContext; @@ -82,12 +83,13 @@ public class StorageMaintainer { public boolean syncLogs(NodeAgentContext context, boolean throttle) { Optional<URI> archiveUri = context.node().archiveUri(); if (archiveUri.isEmpty()) return false; + ApplicationId owner = context.node().owner().orElseThrow(); List<SyncFileInfo> syncFileInfos = FileFinder.files(pathOnHostUnderContainerVespaHome(context, "logs/vespa")) .maxDepth(2) .stream() .sorted(Comparator.comparing(FileFinder.FileAttributes::lastModifiedTime)) - .flatMap(fa -> SyncFileInfo.forLogFile(archiveUri.get(), fa.path(), throttle).stream()) + .flatMap(fa -> SyncFileInfo.forLogFile(archiveUri.get(), fa.path(), throttle, owner).stream()) .collect(Collectors.toList()); return syncClient.sync(context, syncFileInfos, throttle ? 1 : 100); diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/maintenance/servicedump/Artifact.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/maintenance/servicedump/Artifact.java new file mode 100644 index 00000000000..edab90afea7 --- /dev/null +++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/maintenance/servicedump/Artifact.java @@ -0,0 +1,61 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.node.admin.maintenance.servicedump; + +import java.nio.file.Path; +import java.util.Optional; + +/** + * An artifact file produced by a {@link ArtifactProducer}. + * + * @author bjorncs + */ +class Artifact { + + enum Classification { + CONFIDENTIAL("confidential"), + INTERNAL("internal"); + + private final String value; + Classification(String value) { this.value = value; } + public String value() { return value; } + } + + private final Classification classification; + private final Path fileInNode; + private final Path fileOnHost; + private final boolean compressOnUpload; + + private Artifact(Builder builder) { + if (builder.fileOnHost == null && builder.fileInNode == null) { + throw new IllegalArgumentException("No file specified"); + } else if (builder.fileOnHost != null && builder.fileInNode != null) { + throw new IllegalArgumentException("Only a single file can be specified"); + } + this.fileInNode = builder.fileInNode; + this.fileOnHost = builder.fileOnHost; + this.classification = builder.classification; + this.compressOnUpload = Boolean.TRUE.equals(builder.compressOnUpload); + } + + static Builder newBuilder() { return new Builder(); } + + Optional<Classification> classification() { return Optional.ofNullable(classification); } + Optional<Path> fileInNode() { return Optional.ofNullable(fileInNode); } + Optional<Path> fileOnHost() { return Optional.ofNullable(fileOnHost); } + boolean compressOnUpload() { return compressOnUpload; } + + static class Builder { + private Classification classification; + private Path fileInNode; + private Path fileOnHost; + private Boolean compressOnUpload; + + private Builder() {} + + Builder classification(Classification c) { this.classification = c; return this; } + Builder fileInNode(Path f) { this.fileInNode = f; return this; } + Builder fileOnHost(Path f) { this.fileOnHost = f; return this; } + Builder compressOnUpload() { this.compressOnUpload = true; return this; } + Artifact build() { return new Artifact(this); } + } +} diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/maintenance/servicedump/ArtifactProducer.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/maintenance/servicedump/ArtifactProducer.java index 83e83f1f4d2..e4a9e6aeea5 100644 --- a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/maintenance/servicedump/ArtifactProducer.java +++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/maintenance/servicedump/ArtifactProducer.java @@ -1,10 +1,11 @@ // Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. package com.yahoo.vespa.hosted.node.admin.maintenance.servicedump; -import com.yahoo.vespa.hosted.node.admin.nodeagent.NodeAgentContext; -import com.yahoo.vespa.hosted.node.admin.task.util.file.UnixPath; +import com.yahoo.vespa.hosted.node.admin.task.util.process.CommandResult; -import java.io.IOException; +import java.nio.file.Path; +import java.util.List; +import java.util.OptionalDouble; /** * Produces service dump artifacts. @@ -13,10 +14,25 @@ import java.io.IOException; */ interface ArtifactProducer { - String name(); + String artifactName(); + String description(); + List<Artifact> produceArtifacts(Context ctx); - void produceArtifact(NodeAgentContext context, String configId, ServiceDumpReport.DumpOptions options, - UnixPath resultDirectoryInNode) throws IOException; + interface Context { + String serviceId(); + int servicePid(); + CommandResult executeCommandInNode(List<String> command, boolean logOutput); + Path outputDirectoryInNode(); + Path pathInNodeUnderVespaHome(String relativePath); + Path pathOnHostFromPathInNode(Path pathInNode); + Options options(); + + interface Options { + OptionalDouble duration(); + boolean callGraphRecording(); + boolean sendProfilingSignal(); + } + } } diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/maintenance/servicedump/ArtifactProducers.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/maintenance/servicedump/ArtifactProducers.java new file mode 100644 index 00000000000..4218df662da --- /dev/null +++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/maintenance/servicedump/ArtifactProducers.java @@ -0,0 +1,107 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.node.admin.maintenance.servicedump; + +import com.yahoo.yolean.concurrent.Sleeper; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * @author bjorncs + */ +class ArtifactProducers { + + private final Map<String, ArtifactProducer> producers; + private final Map<String, List<ArtifactProducer>> aliases; + + private ArtifactProducers(Set<ArtifactProducer> producers, + Map<String, List<Class<? extends ArtifactProducer>>> aliases) { + var producerMap = producers.stream() + .collect(Collectors.toMap(ArtifactProducer::artifactName, Function.identity())); + Map<String, List<ArtifactProducer>> aliasMap = new HashMap<>(); + aliases.forEach((alias, mapping) -> { + List<ArtifactProducer> concreteMapping = mapping.stream() + .map(type -> producers.stream() + .filter(p -> p.getClass().equals(type)) + .findAny() + .orElseThrow(() -> new IllegalArgumentException("No producer of type " + type))) + .collect(Collectors.toList()); + if (producerMap.containsKey(alias)) { + throw new IllegalStateException("Alias name '" + alias + "' conflicts with producer"); + } + aliasMap.put(alias, concreteMapping); + }); + this.producers = producerMap; + this.aliases = aliasMap; + } + + static ArtifactProducers createDefault(Sleeper sleeper) { + var producers = Set.of( + new PerfReporter(), + new JvmDumper.JavaFlightRecorder(sleeper), + new JvmDumper.HeapDump(), + new JvmDumper.Jmap(), + new JvmDumper.Jstat(), + new JvmDumper.Jstack(), + new PmapReporter(), + new VespaLogDumper(sleeper)); + var aliases = + Map.of( + "jvm-dump", + List.of( + JvmDumper.HeapDump.class, JvmDumper.Jmap.class, JvmDumper.Jstat.class, + JvmDumper.Jstack.class, VespaLogDumper.class) + ); + return new ArtifactProducers(producers, aliases); + } + + static ArtifactProducers createCustom(Set<ArtifactProducer> producers, + Map<String, List<Class<? extends ArtifactProducer>>> aliases) { + return new ArtifactProducers(producers, aliases); + } + + List<ArtifactProducer> resolve(List<String> requestedArtifacts) { + List<ArtifactProducer> resolved = new ArrayList<>(); + for (String artifact : requestedArtifacts) { + if (aliases.containsKey(artifact)) { + aliases.get(artifact).stream() + .filter(p -> !resolved.contains(p)) + .forEach(resolved::add); + } else if (producers.containsKey(artifact)) { + ArtifactProducer producer = producers.get(artifact); + if (!resolved.contains(producer)) { + resolved.add(producer); + } + } else { + throw createInvalidArtifactException(artifact); + } + } + return resolved; + } + + private IllegalArgumentException createInvalidArtifactException(String artifact) { + String producersString = producers.keySet().stream() + .map(a -> "'" + a + "'") + .sorted() + .collect(Collectors.joining(", ", "[", "]")); + String aliasesString = aliases.entrySet().stream() + .map(e -> String.format( + "'%s': %s", + e.getKey(), + e.getValue().stream() + .map(p -> "'" + p.artifactName() + "'") + .sorted() + .collect(Collectors.joining(", ", "[", "]"))) + ) + .collect(Collectors.joining(", ", "[", "]")); + String msg = String.format( + "Invalid artifact type '%s'. Valid types are %s and valid aliases are %s", + artifact, producersString, aliasesString); + return new IllegalArgumentException(msg); + } +} diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/maintenance/servicedump/JavaFlightRecorder.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/maintenance/servicedump/JavaFlightRecorder.java deleted file mode 100644 index 4d2b9d10069..00000000000 --- a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/maintenance/servicedump/JavaFlightRecorder.java +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. -package com.yahoo.vespa.hosted.node.admin.maintenance.servicedump; - -import com.yahoo.yolean.concurrent.Sleeper; -import com.yahoo.vespa.hosted.node.admin.container.ContainerOperations; -import com.yahoo.vespa.hosted.node.admin.nodeagent.NodeAgentContext; -import com.yahoo.vespa.hosted.node.admin.task.util.file.UnixPath; - -import java.io.IOException; -import java.time.Duration; -import java.util.List; - -/** - * Creates a Java Flight Recorder dump. - * - * @author bjorncs - */ -class JavaFlightRecorder extends AbstractProducer { - - private final Sleeper sleeper; - - JavaFlightRecorder(ContainerOperations container, Sleeper sleeper) { - super(container); - this.sleeper = sleeper; - } - - static String NAME = "jfr-recording"; - - @Override public String name() { return NAME; } - - @Override - public void produceArtifact(NodeAgentContext ctx, String configId, ServiceDumpReport.DumpOptions options, - UnixPath resultDirectoryInNode) throws IOException { - int pid = findVespaServicePid(ctx, configId); - int seconds = (int) (duration(ctx, options, 30.0)); - UnixPath outputFile = resultDirectoryInNode.resolve("recording.jfr"); - List<String> startCommand = List.of("jcmd", Integer.toString(pid), "JFR.start", "name=host-admin", - "path-to-gc-roots=true", "settings=profile", "filename=" + outputFile, "duration=" + seconds + "s"); - executeCommand(ctx, startCommand, true); - sleeper.sleep(Duration.ofSeconds(seconds).plusSeconds(1)); - int maxRetries = 10; - List<String> checkCommand = List.of("jcmd", Integer.toString(pid), "JFR.check", "name=host-admin"); - for (int i = 0; i < maxRetries; i++) { - boolean stillRunning = executeCommand(ctx, checkCommand, true).getOutputLines().stream() - .anyMatch(l -> l.contains("name=host-admin") && l.contains("running")); - if (!stillRunning) return; - sleeper.sleep(Duration.ofSeconds(1)); - } - throw new IOException("Failed to wait for JFR dump to complete after " + maxRetries + " retries"); - } -} diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/maintenance/servicedump/JvmDumpProducer.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/maintenance/servicedump/JvmDumpProducer.java deleted file mode 100644 index 5cbbf304bb8..00000000000 --- a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/maintenance/servicedump/JvmDumpProducer.java +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. -package com.yahoo.vespa.hosted.node.admin.maintenance.servicedump; - -import com.yahoo.vespa.hosted.node.admin.container.ContainerOperations; -import com.yahoo.vespa.hosted.node.admin.nodeagent.NodeAgentContext; -import com.yahoo.vespa.hosted.node.admin.task.util.file.UnixPath; - -import java.io.IOException; -import java.util.List; - -/** - * Creates a dump of JVM based Vespa services using vespa-jvm-dumper - * - * @author bjorncs - */ -class JvmDumpProducer extends AbstractProducer { - - JvmDumpProducer(ContainerOperations container) { super(container); } - - public static String NAME = "jvm-dump"; - - @Override public String name() { return NAME; } - - @Override - public void produceArtifact(NodeAgentContext context, String configId, ServiceDumpReport.DumpOptions options, - UnixPath resultDirectoryInNode) throws IOException { - UnixPath vespaJvmDumper = new UnixPath(context.pathInNodeUnderVespaHome("bin/vespa-jvm-dumper")); - executeCommand(context, List.of(vespaJvmDumper.toString(), configId, resultDirectoryInNode.toString()), true); - } -} diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/maintenance/servicedump/JvmDumper.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/maintenance/servicedump/JvmDumper.java new file mode 100644 index 00000000000..cf206918568 --- /dev/null +++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/maintenance/servicedump/JvmDumper.java @@ -0,0 +1,103 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.node.admin.maintenance.servicedump; + +import com.yahoo.yolean.concurrent.Sleeper; + +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; + +import static com.yahoo.vespa.hosted.node.admin.maintenance.servicedump.Artifact.Classification.CONFIDENTIAL; +import static com.yahoo.vespa.hosted.node.admin.maintenance.servicedump.Artifact.Classification.INTERNAL; + +/** + * @author bjorncs + */ +class JvmDumper { + private JvmDumper() {} + + static class HeapDump implements ArtifactProducer { + @Override public String artifactName() { return "jvm-heap-dump"; } + @Override public String description() { return "JVM heap dump"; } + + @Override + public List<Artifact> produceArtifacts(Context ctx) { + Path heapDumpFile = ctx.outputDirectoryInNode().resolve("jvm-heap-dump.bin"); + List<String> cmd = List.of( + "jmap", "-dump:live,format=b,file=" + heapDumpFile, Integer.toString(ctx.servicePid())); + ctx.executeCommandInNode(cmd, true); + return List.of( + Artifact.newBuilder().classification(CONFIDENTIAL).fileInNode(heapDumpFile).compressOnUpload().build()); + } + } + + static class Jmap implements ArtifactProducer { + @Override public String artifactName() { return "jvm-jmap"; } + @Override public String description() { return "JVM jmap output"; } + + @Override + public List<Artifact> produceArtifacts(Context ctx) { + Path jmapReport = ctx.outputDirectoryInNode().resolve("jvm-jmap.txt"); + List<String> cmd = List.of("bash", "-c", "jhsdb jmap --heap --pid " + ctx.servicePid() + " > " + jmapReport); + ctx.executeCommandInNode(cmd, true); + return List.of(Artifact.newBuilder().classification(INTERNAL).fileInNode(jmapReport).build()); + } + } + + static class Jstat implements ArtifactProducer { + @Override public String artifactName() { return "jvm-jstat"; } + @Override public String description() { return "JVM jstat output"; } + + @Override + public List<Artifact> produceArtifacts(Context ctx) { + Path jstatReport = ctx.outputDirectoryInNode().resolve("jvm-jstat.txt"); + List<String> cmd = List.of("bash", "-c", "jstat -gcutil " + ctx.servicePid() + " > " + jstatReport); + ctx.executeCommandInNode(cmd, true); + return List.of(Artifact.newBuilder().classification(INTERNAL).fileInNode(jstatReport).build()); + } + } + + static class Jstack implements ArtifactProducer { + @Override public String artifactName() { return "jvm-jstack"; } + @Override public String description() { return "JVM jstack output"; } + + @Override + public List<Artifact> produceArtifacts(Context ctx) { + Path jstackReport = ctx.outputDirectoryInNode().resolve("jvm-jstack.txt"); + ctx.executeCommandInNode(List.of("bash", "-c", "jstack " + ctx.servicePid() + " > " + jstackReport), true); + return List.of(Artifact.newBuilder().classification(INTERNAL).fileInNode(jstackReport).build()); + } + } + + static class JavaFlightRecorder implements ArtifactProducer { + private final Sleeper sleeper; + + JavaFlightRecorder(Sleeper sleeper) { this.sleeper = sleeper; } + + @Override public String artifactName() { return "jvm-jfr"; } + @Override public String description() { return "Java Flight Recorder recording"; } + + @Override + public List<Artifact> produceArtifacts(ArtifactProducer.Context ctx) { + int seconds = (int) (ctx.options().duration().orElse(30.0)); + Path outputFile = ctx.outputDirectoryInNode().resolve("recording.jfr"); + List<String> startCommand = List.of("jcmd", Integer.toString(ctx.servicePid()), "JFR.start", "name=host-admin", + "path-to-gc-roots=true", "settings=profile", "filename=" + outputFile, "duration=" + seconds + "s"); + ctx.executeCommandInNode(startCommand, true); + sleeper.sleep(Duration.ofSeconds(seconds).plusSeconds(1)); + int maxRetries = 10; + List<String> checkCommand = List.of("jcmd", Integer.toString(ctx.servicePid()), "JFR.check", "name=host-admin"); + for (int i = 0; i < maxRetries; i++) { + boolean stillRunning = ctx.executeCommandInNode(checkCommand, true).getOutputLines().stream() + .anyMatch(l -> l.contains("name=host-admin") && l.contains("running")); + if (!stillRunning) { + Artifact a = Artifact.newBuilder() + .classification(CONFIDENTIAL).fileInNode(outputFile).compressOnUpload().build(); + return List.of(a); + } + sleeper.sleep(Duration.ofSeconds(1)); + } + throw new RuntimeException("Failed to wait for JFR dump to complete after " + maxRetries + " retries"); + } + } +} diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/maintenance/servicedump/PerfReportProducer.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/maintenance/servicedump/PerfReportProducer.java deleted file mode 100644 index 1370f09acac..00000000000 --- a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/maintenance/servicedump/PerfReportProducer.java +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. -package com.yahoo.vespa.hosted.node.admin.maintenance.servicedump; - -import com.yahoo.vespa.hosted.node.admin.container.ContainerOperations; -import com.yahoo.vespa.hosted.node.admin.nodeagent.NodeAgentContext; -import com.yahoo.vespa.hosted.node.admin.task.util.file.UnixPath; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; - -/** - * @author bjorncs - */ -class PerfReportProducer extends AbstractProducer { - - public static String NAME = "perf-report"; - - PerfReportProducer(ContainerOperations container) { super(container); } - - @Override public String name() { return NAME; } - - @Override - public void produceArtifact(NodeAgentContext context, String configId, ServiceDumpReport.DumpOptions options, - UnixPath resultDirectoryInNode) throws IOException { - int pid = findVespaServicePid(context, configId); - int duration = (int) duration(context, options, 30.0); - List<String> perfRecordCommand = new ArrayList<>(List.of("perf", "record")); - if (options != null && Boolean.TRUE.equals(options.callGraphRecording())) { - perfRecordCommand.add("-g"); - } - String recordFile = resultDirectoryInNode.resolve("perf-record.bin").toString(); - perfRecordCommand.addAll( - List.of("--output=" + recordFile, - "--pid=" + pid, "sleep", Integer.toString(duration))); - executeCommand(context, perfRecordCommand, true); - String perfReportFile = resultDirectoryInNode.resolve("perf-report.txt").toString(); - executeCommand(context, List.of("bash", "-c", "perf report --input=" + recordFile + " > " + perfReportFile), true); - } -} diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/maintenance/servicedump/PerfReporter.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/maintenance/servicedump/PerfReporter.java new file mode 100644 index 00000000000..07c8b709e04 --- /dev/null +++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/maintenance/servicedump/PerfReporter.java @@ -0,0 +1,39 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.node.admin.maintenance.servicedump; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import static com.yahoo.vespa.hosted.node.admin.maintenance.servicedump.Artifact.Classification.CONFIDENTIAL; +import static com.yahoo.vespa.hosted.node.admin.maintenance.servicedump.Artifact.Classification.INTERNAL; + +/** + * @author bjorncs + */ +class PerfReporter implements ArtifactProducer { + + PerfReporter() {} + + @Override public String artifactName() { return "perf-report"; } + @Override public String description() { return "Perf recording and report"; } + + @Override + public List<Artifact> produceArtifacts(Context ctx) { + int duration = (int)ctx.options().duration().orElse(30.0); + List<String> perfRecordCommand = new ArrayList<>(List.of("perf", "record")); + if (ctx.options().callGraphRecording()) { + perfRecordCommand.add("-g"); + } + Path recordFile = ctx.outputDirectoryInNode().resolve("perf-record.bin"); + perfRecordCommand.addAll( + List.of("--output=" + recordFile, + "--pid=" + ctx.servicePid(), "sleep", Integer.toString(duration))); + ctx.executeCommandInNode(perfRecordCommand, true); + Path reportFile = ctx.outputDirectoryInNode().resolve("perf-report.txt"); + ctx.executeCommandInNode(List.of("bash", "-c", "perf report --input=" + recordFile + " > " + reportFile), true); + return List.of( + Artifact.newBuilder().classification(CONFIDENTIAL).fileInNode(recordFile).compressOnUpload().build(), + Artifact.newBuilder().classification(INTERNAL).fileInNode(reportFile).build()); + } +} diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/maintenance/servicedump/PmapReporter.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/maintenance/servicedump/PmapReporter.java new file mode 100644 index 00000000000..659628d03a0 --- /dev/null +++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/maintenance/servicedump/PmapReporter.java @@ -0,0 +1,23 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.node.admin.maintenance.servicedump; + +import java.nio.file.Path; +import java.util.List; + +import static com.yahoo.vespa.hosted.node.admin.maintenance.servicedump.Artifact.Classification.INTERNAL; + +/** + * @author bjorncs + */ +class PmapReporter implements ArtifactProducer { + @Override public String artifactName() { return "pmap"; } + @Override public String description() { return "Pmap report"; } + + @Override + public List<Artifact> produceArtifacts(Context ctx) { + Path pmapReport = ctx.outputDirectoryInNode().resolve("pmap.txt"); + List<String> cmd = List.of("bash", "-c", "pmap -x " + ctx.servicePid() + " > " + pmapReport); + ctx.executeCommandInNode(cmd, true); + return List.of(Artifact.newBuilder().classification(INTERNAL).fileInNode(pmapReport).build()); + } +} diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/maintenance/servicedump/ServiceDumpReport.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/maintenance/servicedump/ServiceDumpReport.java index 09fac496b19..452f786301b 100644 --- a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/maintenance/servicedump/ServiceDumpReport.java +++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/maintenance/servicedump/ServiceDumpReport.java @@ -111,19 +111,24 @@ class ServiceDumpReport extends BaseReport { private static final String CALL_GRAPH_RECORDING_FIELD = "callGraphRecording"; private static final String DURATION_FIELD = "duration"; + private static final String SEND_PROFILING_SIGNAL_FIELD = "sendProfilingSignal"; private final Boolean callGraphRecording; private final Double duration; + private final Boolean sendProfilingSignal; @JsonCreator public DumpOptions(@JsonProperty(CALL_GRAPH_RECORDING_FIELD) Boolean callGraphRecording, - @JsonProperty(DURATION_FIELD) Double duration) { + @JsonProperty(DURATION_FIELD) Double duration, + @JsonProperty(SEND_PROFILING_SIGNAL_FIELD) Boolean sendProfilingSignal) { this.callGraphRecording = callGraphRecording; this.duration = duration; + this.sendProfilingSignal = sendProfilingSignal; } @JsonGetter(CALL_GRAPH_RECORDING_FIELD) public Boolean callGraphRecording() { return callGraphRecording; } @JsonGetter(DURATION_FIELD) public Double duration() { return duration; } + @JsonGetter(SEND_PROFILING_SIGNAL_FIELD) public Boolean sendProfilingSignal() { return sendProfilingSignal; } } @JsonIgnore public boolean isCompletedOrFailed() { return !isNullTimestamp(failedAt) || !isNullTimestamp(completedAt); } diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/maintenance/servicedump/VespaLogDumper.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/maintenance/servicedump/VespaLogDumper.java new file mode 100644 index 00000000000..24224789877 --- /dev/null +++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/maintenance/servicedump/VespaLogDumper.java @@ -0,0 +1,47 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.node.admin.maintenance.servicedump; + +import com.yahoo.yolean.concurrent.Sleeper; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.logging.Logger; + +import static com.yahoo.vespa.hosted.node.admin.maintenance.servicedump.Artifact.Classification.CONFIDENTIAL; +import static com.yahoo.yolean.Exceptions.uncheck; + +/** + * @author bjorncs + */ +class VespaLogDumper implements ArtifactProducer { + + private static final Logger log = Logger.getLogger(VespaLogDumper.class.getName()); + + private final Sleeper sleeper; + + VespaLogDumper(Sleeper sleeper) { this.sleeper = sleeper; } + + @Override public String artifactName() { return "vespa-log"; } + @Override public String description() { return "Current Vespa logs"; } + + @Override + public List<Artifact> produceArtifacts(Context ctx) { + if (ctx.options().sendProfilingSignal()) { + log.info("Sending SIGPROF to process to include vespa-malloc dump in Vespa log"); + ctx.executeCommandInNode(List.of("kill", "-SIGPROF", Integer.toString(ctx.servicePid())), true); + sleeper.sleep(Duration.ofSeconds(3)); + } + Path vespaLogFile = ctx.pathOnHostFromPathInNode(ctx.pathInNodeUnderVespaHome("logs/vespa/vespa.log")); + Path destination = ctx.pathOnHostFromPathInNode(ctx.outputDirectoryInNode()).resolve("vespa.log"); + if (Files.exists(vespaLogFile)) { + uncheck(() -> Files.copy(vespaLogFile, destination)); + return List.of( + Artifact.newBuilder().classification(CONFIDENTIAL).fileOnHost(destination).compressOnUpload().build()); + } else { + log.info("Log file '" + vespaLogFile + "' does not exist"); + return List.of(); + } + } +} diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/maintenance/servicedump/VespaServiceDumperImpl.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/maintenance/servicedump/VespaServiceDumperImpl.java index a202fb7acd0..23a6ed2aa8c 100644 --- a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/maintenance/servicedump/VespaServiceDumperImpl.java +++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/maintenance/servicedump/VespaServiceDumperImpl.java @@ -1,7 +1,7 @@ // Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. package com.yahoo.vespa.hosted.node.admin.maintenance.servicedump; -import com.yahoo.yolean.concurrent.Sleeper; +import com.yahoo.config.provision.ApplicationId; import com.yahoo.text.Lowercase; import com.yahoo.vespa.hosted.node.admin.configserver.noderepository.NodeAttributes; import com.yahoo.vespa.hosted.node.admin.configserver.noderepository.NodeRepository; @@ -9,18 +9,21 @@ import com.yahoo.vespa.hosted.node.admin.configserver.noderepository.NodeSpec; import com.yahoo.vespa.hosted.node.admin.container.ContainerOperations; import com.yahoo.vespa.hosted.node.admin.maintenance.sync.SyncClient; import com.yahoo.vespa.hosted.node.admin.maintenance.sync.SyncFileInfo; +import com.yahoo.vespa.hosted.node.admin.maintenance.sync.SyncFileInfo.Compression; import com.yahoo.vespa.hosted.node.admin.nodeagent.NodeAgentContext; -import com.yahoo.vespa.hosted.node.admin.task.util.file.FileFinder; import com.yahoo.vespa.hosted.node.admin.task.util.file.UnixPath; +import com.yahoo.vespa.hosted.node.admin.task.util.process.CommandResult; +import com.yahoo.yolean.concurrent.Sleeper; import java.net.URI; +import java.nio.file.Path; import java.time.Clock; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.List; -import java.util.Map; -import java.util.function.Function; +import java.util.Optional; +import java.util.OptionalDouble; import java.util.logging.Level; import java.util.logging.Logger; import java.util.stream.Collectors; @@ -40,25 +43,20 @@ public class VespaServiceDumperImpl implements VespaServiceDumper { private final SyncClient syncClient; private final NodeRepository nodeRepository; private final Clock clock; - private final Map<String, ArtifactProducer> artifactProducers; + private final ArtifactProducers artifactProducers; public VespaServiceDumperImpl(ContainerOperations container, SyncClient syncClient, NodeRepository nodeRepository) { - this(container, syncClient, nodeRepository, Clock.systemUTC(), Sleeper.DEFAULT); + this(ArtifactProducers.createDefault(Sleeper.DEFAULT), container, syncClient, nodeRepository, Clock.systemUTC()); } // For unit testing - VespaServiceDumperImpl(ContainerOperations container, SyncClient syncClient, NodeRepository nodeRepository, - Clock clock, Sleeper sleeper) { + VespaServiceDumperImpl(ArtifactProducers producers, ContainerOperations container, SyncClient syncClient, + NodeRepository nodeRepository, Clock clock) { this.container = container; this.syncClient = syncClient; this.nodeRepository = nodeRepository; this.clock = clock; - List<AbstractProducer> producers = List.of( - new JvmDumpProducer(container), - new PerfReportProducer(container), - new JavaFlightRecorder(container, sleeper)); - this.artifactProducers = producers.stream() - .collect(Collectors.toMap(ArtifactProducer::name, Function.identity())); + this.artifactProducers = producers; } @Override @@ -85,8 +83,8 @@ public class VespaServiceDumperImpl implements VespaServiceDumper { handleFailure(context, request, startedAt, "Request already expired"); return; } - List<String> artifactTypes = request.artifacts(); - if (artifactTypes == null || artifactTypes.isEmpty()) { + List<String> requestedArtifacts = request.artifacts(); + if (requestedArtifacts == null || requestedArtifacts.isEmpty()) { handleFailure(context, request, startedAt, "No artifacts requested"); return; } @@ -104,30 +102,14 @@ public class VespaServiceDumperImpl implements VespaServiceDumper { context.log(log, Level.INFO, "Creating '" + directoryOnHost +"'."); directoryOnHost.createDirectory(); directoryOnHost.setPermissions("rwxrwxrwx"); - List<SyncFileInfo> files = new ArrayList<>(); URI destination = serviceDumpDestination(nodeSpec, createDumpId(request)); - for (String artifactType : artifactTypes) { - ArtifactProducer producer = artifactProducers.get(artifactType); - if (producer == null) { - String supportedValues = String.join(",", artifactProducers.keySet()); - handleFailure(context, request, startedAt, "No artifact producer exists for '" + artifactType + "'. " + - "Following values are allowed: " + supportedValues); - return; - } - context.log(log, "Producing artifact of type '" + artifactType + "'"); - UnixPath producerDirectoryOnHost = directoryOnHost.resolve(artifactType); - producerDirectoryOnHost.createDirectory(); - producerDirectoryOnHost.setPermissions("rwxrwxrwx"); - UnixPath producerDirectoryInNode = directoryInNode.resolve(artifactType); - producer.produceArtifact(context, configId, request.dumpOptions(), producerDirectoryInNode); - collectArtifactFilesToUpload(files, producerDirectoryOnHost, destination.resolve(artifactType + '/'), expiry); + ProducerContext producerCtx = new ProducerContext(context, directoryInNode.toPath(), request); + List<Artifact> producedArtifacts = new ArrayList<>(); + for (ArtifactProducer producer : artifactProducers.resolve(requestedArtifacts)) { + context.log(log, "Producing artifact of type '" + producer.artifactName() + "'"); + producedArtifacts.addAll(producer.produceArtifacts(producerCtx)); } - context.log(log, Level.INFO, "Uploading files with destination " + destination + " and expiry " + expiry); - if (!syncClient.sync(context, files, Integer.MAX_VALUE)) { - handleFailure(context, request, startedAt, "Unable to upload all files"); - return; - } - context.log(log, Level.INFO, "Upload complete"); + uploadArtifacts(context, destination, producedArtifacts, expiry); storeReport(context, ServiceDumpReport.createSuccessReport(request, startedAt, clock.instant(), destination)); } catch (Exception e) { handleFailure(context, request, startedAt, e); @@ -139,10 +121,24 @@ public class VespaServiceDumperImpl implements VespaServiceDumper { } } - private void collectArtifactFilesToUpload(List<SyncFileInfo> files, UnixPath directoryOnHost, URI destination, Instant expiry) { - FileFinder.files(directoryOnHost.toPath()).stream() - .flatMap(file -> SyncFileInfo.forServiceDump(destination, file.path(), expiry).stream()) - .forEach(files::add); + private void uploadArtifacts(NodeAgentContext ctx, URI destination, + List<Artifact> producedArtifacts, Instant expiry) { + ApplicationId owner = ctx.node().owner().orElseThrow(); + List<SyncFileInfo> filesToUpload = producedArtifacts.stream() + .map(a -> { + Compression compression = a.compressOnUpload() ? Compression.ZSTD : Compression.NONE; + Path fileInNode = a.fileInNode().orElse(null); + Path fileOnHost = fileInNode != null ? ctx.pathOnHostFromPathInNode(fileInNode) : a.fileOnHost().orElseThrow(); + String classification = a.classification().map(Artifact.Classification::value).orElse(null); + return SyncFileInfo.forServiceDump(destination, fileOnHost, expiry, compression, owner, classification); + }) + .collect(Collectors.toList()); + ctx.log(log, Level.INFO, + "Uploading " + filesToUpload.size() + " file(s) with destination " + destination + " and expiry " + expiry); + if (!syncClient.sync(ctx, filesToUpload, Integer.MAX_VALUE)) { + throw new RuntimeException("Unable to upload all files"); + } + ctx.log(log, Level.INFO, "Upload complete"); } private static Instant expireAt(Instant startedAt, ServiceDumpReport request) { @@ -181,5 +177,86 @@ public class VespaServiceDumperImpl implements VespaServiceDumper { return archiveUri.resolve(targetDirectory); } + private class ProducerContext implements ArtifactProducer.Context, ArtifactProducer.Context.Options { + + final NodeAgentContext nodeAgentCtx; + final Path outputDirectoryInNode; + final ServiceDumpReport request; + volatile int pid = -1; + + ProducerContext(NodeAgentContext nodeAgentCtx, Path outputDirectoryInNode, ServiceDumpReport request) { + this.nodeAgentCtx = nodeAgentCtx; + this.outputDirectoryInNode = outputDirectoryInNode; + this.request = request; + } + + @Override public String serviceId() { return request.configId(); } + + @Override + public int servicePid() { + if (pid == -1) { + Path findPidBinary = nodeAgentCtx.pathInNodeUnderVespaHome("libexec/vespa/find-pid"); + CommandResult findPidResult = executeCommandInNode(List.of(findPidBinary.toString(), serviceId()), true); + this.pid = Integer.parseInt(findPidResult.getOutput()); + } + return pid; + } + + @Override + public CommandResult executeCommandInNode(List<String> command, boolean logOutput) { + CommandResult result = container.executeCommandInContainerAsRoot(nodeAgentCtx, command.toArray(new String[0])); + String cmdString = command.stream().map(s -> "'" + s + "'").collect(Collectors.joining(" ", "\"", "\"")); + int exitCode = result.getExitCode(); + String output = result.getOutput().trim(); + String prefixedOutput = output.contains("\n") + ? "\n" + output + : (output.isEmpty() ? "<no output>" : output); + if (exitCode > 0) { + String errorMsg = logOutput + ? String.format("Failed to execute %s (exited with code %d): %s", cmdString, exitCode, prefixedOutput) + : String.format("Failed to execute %s (exited with code %d)", cmdString, exitCode); + throw new RuntimeException(errorMsg); + } else { + String logMsg = logOutput + ? String.format("Executed command %s. Exited with code %d and output: %s", cmdString, exitCode, prefixedOutput) + : String.format("Executed command %s. Exited with code %d.", cmdString, exitCode); + nodeAgentCtx.log(log, logMsg); + } + return result; + } + @Override public Path outputDirectoryInNode() { return outputDirectoryInNode; } + + @Override + public Path pathInNodeUnderVespaHome(String relativePath) { + return nodeAgentCtx.pathInNodeUnderVespaHome(relativePath); + } + + @Override + public Path pathOnHostFromPathInNode(Path pathInNode) { + return nodeAgentCtx.pathOnHostFromPathInNode(pathInNode); + } + + @Override public Options options() { return this; } + + @Override + public OptionalDouble duration() { + Double duration = dumpOptions() + .map(ServiceDumpReport.DumpOptions::duration) + .orElse(null); + return duration != null ? OptionalDouble.of(duration) : OptionalDouble.empty(); + } + + @Override + public boolean callGraphRecording() { + return dumpOptions().map(ServiceDumpReport.DumpOptions::callGraphRecording).orElse(false); + } + + @Override + public boolean sendProfilingSignal() { + return dumpOptions().map(ServiceDumpReport.DumpOptions::sendProfilingSignal).orElse(false); + } + + Optional<ServiceDumpReport.DumpOptions> dumpOptions() { return Optional.ofNullable(request.dumpOptions()); } + } } diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/maintenance/sync/SyncFileInfo.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/maintenance/sync/SyncFileInfo.java index 8b05425d2d9..6d87a8cfc4a 100644 --- a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/maintenance/sync/SyncFileInfo.java +++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/maintenance/sync/SyncFileInfo.java @@ -1,10 +1,14 @@ // Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. package com.yahoo.vespa.hosted.node.admin.maintenance.sync; +import com.yahoo.config.provision.ApplicationId; + import java.net.URI; import java.nio.file.Path; import java.time.Instant; -import java.util.List; +import java.time.temporal.ChronoUnit; +import java.util.HashMap; +import java.util.Map; import java.util.Optional; /** @@ -16,12 +20,15 @@ public class SyncFileInfo { private final URI destination; private final Compression uploadCompression; private final Instant expiry; + private final Map<String, String> tags; - private SyncFileInfo(Path source, URI destination, Compression uploadCompression, Instant expiry) { + private SyncFileInfo(Path source, URI destination, Compression uploadCompression, Instant expiry, + Map<String, String> tags) { this.source = source; this.destination = destination; this.uploadCompression = uploadCompression; this.expiry = expiry; + this.tags = Map.copyOf(tags); } /** Source path of the file to sync */ @@ -42,7 +49,9 @@ public class SyncFileInfo { /** File expiry */ public Optional<Instant> expiry() { return Optional.ofNullable(expiry); } - public static Optional<SyncFileInfo> forLogFile(URI uri, Path logFile, boolean rotatedOnly) { + public Map<String, String> tags() { return tags; } + + public static Optional<SyncFileInfo> forLogFile(URI uri, Path logFile, boolean rotatedOnly, ApplicationId owner) { String filename = logFile.getFileName().toString(); Compression compression; String dir = null; @@ -61,17 +70,26 @@ public class SyncFileInfo { } if (dir == null) return Optional.empty(); + Instant expiry = Instant.now().plus(30, ChronoUnit.DAYS); return Optional.of(new SyncFileInfo( - logFile, uri.resolve(dir + logFile.getFileName() + compression.extension), compression, null)); + logFile, uri.resolve(dir + logFile.getFileName() + compression.extension), compression, expiry, defaultTags(owner))); } - public static Optional<SyncFileInfo> forServiceDump(URI directory, Path file, Instant expiry) { + public static SyncFileInfo forServiceDump(URI destinationDir, Path file, Instant expiry, Compression compression, + ApplicationId owner, String assetClassification) { String filename = file.getFileName().toString(); - List<String> filesToCompress = List.of(".bin", ".hprof", ".jfr", ".log"); - Compression compression = filesToCompress.stream().anyMatch(filename::endsWith) ? Compression.ZSTD : Compression.NONE; - if (filename.startsWith(".")) return Optional.empty(); - URI location = directory.resolve(filename + compression.extension); - return Optional.of(new SyncFileInfo(file, location, compression, expiry)); + URI location = destinationDir.resolve(filename + compression.extension); + Map<String, String> tags = defaultTags(owner); + if (assetClassification != null) { + tags.put("vespa:AssetClassification", assetClassification); + } + return new SyncFileInfo(file, location, compression, expiry, tags); + } + + private static Map<String, String> defaultTags(ApplicationId owner) { + var tags = new HashMap<String, String>(); + tags.put("corp:Application", owner.toFullString()); + return tags; } public enum Compression { diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/file/UnixPath.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/file/UnixPath.java index 1121db99399..2ce49ae383a 100644 --- a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/file/UnixPath.java +++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/file/UnixPath.java @@ -149,7 +149,7 @@ public class UnixPath { public UnixPath setGroup(String group) { return setGroup(group, "group"); } public UnixPath setGroupId(int gid) { return setGroup(String.valueOf(gid), "gid"); } - public UnixPath setGroup(String group, String type) { + private UnixPath setGroup(String group, String type) { UserPrincipalLookupService service = path.getFileSystem().getUserPrincipalLookupService(); GroupPrincipal principal = uncheck( () -> service.lookupPrincipalByGroupName(group), diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/fs/ContainerAttributeViews.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/fs/ContainerAttributeViews.java new file mode 100644 index 00000000000..c3246843c75 --- /dev/null +++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/fs/ContainerAttributeViews.java @@ -0,0 +1,81 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.node.admin.task.util.fs; + +import java.io.IOException; +import java.nio.file.ProviderMismatchException; +import java.nio.file.attribute.FileTime; +import java.nio.file.attribute.GroupPrincipal; +import java.nio.file.attribute.PosixFileAttributeView; +import java.nio.file.attribute.PosixFileAttributes; +import java.nio.file.attribute.PosixFilePermission; +import java.nio.file.attribute.UserPrincipal; +import java.util.Map; +import java.util.Set; + +import static com.yahoo.vespa.hosted.node.admin.task.util.fs.ContainerUserPrincipalLookupService.ContainerGroupPrincipal; +import static com.yahoo.vespa.hosted.node.admin.task.util.fs.ContainerUserPrincipalLookupService.ContainerUserPrincipal; + +/** + * @author valerijf + */ +class ContainerAttributeViews { + + static class ContainerPosixFileAttributeView implements PosixFileAttributeView { + private final PosixFileAttributeView posixFileAttributeView; + private final ContainerPosixFileAttributes fileAttributes; + + ContainerPosixFileAttributeView(PosixFileAttributeView posixFileAttributeView, + ContainerPosixFileAttributes fileAttributes) { + this.posixFileAttributeView = posixFileAttributeView; + this.fileAttributes = fileAttributes; + } + + @Override public String name() { return "posix"; } + @Override public UserPrincipal getOwner() { return fileAttributes.owner(); } + @Override public PosixFileAttributes readAttributes() { return fileAttributes; } + + @Override + public void setOwner(UserPrincipal owner) throws IOException { + if (!(owner instanceof ContainerUserPrincipal)) throw new ProviderMismatchException(); + posixFileAttributeView.setOwner(((ContainerUserPrincipal) owner).baseFsPrincipal()); + } + + @Override + public void setGroup(GroupPrincipal group) throws IOException { + if (!(group instanceof ContainerGroupPrincipal)) throw new ProviderMismatchException(); + posixFileAttributeView.setGroup(((ContainerGroupPrincipal) group).baseFsPrincipal()); + } + + @Override + public void setTimes(FileTime lastModifiedTime, FileTime lastAccessTime, FileTime createTime) throws IOException { + posixFileAttributeView.setTimes(lastModifiedTime, lastAccessTime, createTime); + } + + @Override + public void setPermissions(Set<PosixFilePermission> perms) throws IOException { + posixFileAttributeView.setPermissions(perms); + } + } + + static class ContainerPosixFileAttributes implements PosixFileAttributes { + private final Map<String, Object> attributes; + + ContainerPosixFileAttributes(Map<String, Object> attributes) { + this.attributes = attributes; + } + + @SuppressWarnings("unchecked") + @Override public Set<PosixFilePermission> permissions() { return (Set<PosixFilePermission>) attributes.get("permissions"); } + @Override public ContainerUserPrincipal owner() { return (ContainerUserPrincipal) attributes.get("owner"); } + @Override public ContainerGroupPrincipal group() { return (ContainerGroupPrincipal) attributes.get("group"); } + @Override public FileTime lastModifiedTime() { return (FileTime) attributes.get("lastModifiedTime"); } + @Override public FileTime lastAccessTime() { return (FileTime) attributes.get("lastAccessTime"); } + @Override public FileTime creationTime() { return (FileTime) attributes.get("creationTime"); } + @Override public boolean isRegularFile() { return (boolean) attributes.get("isRegularFile"); } + @Override public boolean isDirectory() { return (boolean) attributes.get("isDirectory"); } + @Override public boolean isSymbolicLink() { return (boolean) attributes.get("isSymbolicLink"); } + @Override public boolean isOther() { return (boolean) attributes.get("isOther"); } + @Override public long size() { return (long) attributes.get("size"); } + @Override public Object fileKey() { return attributes.get("fileKey"); } + } +} diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/fs/ContainerFileSystem.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/fs/ContainerFileSystem.java new file mode 100644 index 00000000000..393137f795e --- /dev/null +++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/fs/ContainerFileSystem.java @@ -0,0 +1,84 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.node.admin.task.util.fs; + +import java.io.IOException; +import java.nio.file.FileStore; +import java.nio.file.FileSystem; +import java.nio.file.Path; +import java.nio.file.PathMatcher; +import java.nio.file.WatchService; +import java.nio.file.attribute.UserPrincipalLookupService; +import java.util.Set; + +/** + * @author valerijf + */ +public class ContainerFileSystem extends FileSystem { + + private final ContainerFileSystemProvider containerFsProvider; + + public ContainerFileSystem(ContainerFileSystemProvider containerFsProvider) { + this.containerFsProvider = containerFsProvider; + } + + @Override + public ContainerFileSystemProvider provider() { + return containerFsProvider; + } + + @Override + public boolean isOpen() { + return true; + } + + @Override + public boolean isReadOnly() { + return false; + } + + @Override + public String getSeparator() { + return "/"; + } + + @Override + public Set<String> supportedFileAttributeViews() { + return Set.of("basic", "posix", "unix", "owner"); + } + + @Override + public UserPrincipalLookupService getUserPrincipalLookupService() { + return containerFsProvider.userPrincipalLookupService(); + } + + @Override + public ContainerPath getPath(String first, String... more) { + return ContainerPath.fromPathInContainer(this, Path.of(first, more)); + } + + @Override + public void close() throws IOException { + throw new UnsupportedOperationException(); + } + + @Override + public Iterable<Path> getRootDirectories() { + throw new UnsupportedOperationException(); + } + + @Override + public Iterable<FileStore> getFileStores() { + throw new UnsupportedOperationException(); + } + + @Override + public PathMatcher getPathMatcher(String syntaxAndPattern) { + throw new UnsupportedOperationException(); + } + + @Override + public WatchService newWatchService() { + throw new UnsupportedOperationException(); + } + +} diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/fs/ContainerFileSystemProvider.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/fs/ContainerFileSystemProvider.java new file mode 100644 index 00000000000..79214334c55 --- /dev/null +++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/fs/ContainerFileSystemProvider.java @@ -0,0 +1,265 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.node.admin.task.util.fs; + +import java.io.IOException; +import java.net.URI; +import java.nio.channels.SeekableByteChannel; +import java.nio.file.AccessMode; +import java.nio.file.CopyOption; +import java.nio.file.DirectoryStream; +import java.nio.file.FileStore; +import java.nio.file.FileSystem; +import java.nio.file.FileSystemAlreadyExistsException; +import java.nio.file.LinkOption; +import java.nio.file.OpenOption; +import java.nio.file.Path; +import java.nio.file.ProviderMismatchException; +import java.nio.file.attribute.BasicFileAttributeView; +import java.nio.file.attribute.BasicFileAttributes; +import java.nio.file.attribute.FileAttribute; +import java.nio.file.attribute.FileAttributeView; +import java.nio.file.attribute.GroupPrincipal; +import java.nio.file.attribute.PosixFileAttributeView; +import java.nio.file.attribute.PosixFileAttributes; +import java.nio.file.attribute.UserPrincipal; +import java.nio.file.spi.FileSystemProvider; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; +import java.util.Set; + +import static com.yahoo.vespa.hosted.node.admin.task.util.fs.ContainerAttributeViews.ContainerPosixFileAttributes; +import static com.yahoo.vespa.hosted.node.admin.task.util.fs.ContainerAttributeViews.ContainerPosixFileAttributeView; +import static com.yahoo.vespa.hosted.node.admin.task.util.fs.ContainerUserPrincipalLookupService.ContainerGroupPrincipal; +import static com.yahoo.vespa.hosted.node.admin.task.util.fs.ContainerUserPrincipalLookupService.ContainerUserPrincipal; +import static com.yahoo.yolean.Exceptions.uncheck; + +/** + * @author valerijf + */ +public class ContainerFileSystemProvider extends FileSystemProvider { + + private final ContainerFileSystem containerFs; + private final ContainerUserPrincipalLookupService userPrincipalLookupService; + private final Path containerRootOnHost; + + + public ContainerFileSystemProvider(Path containerRootOnHost, int uidOffset, int gidOffset) { + this.containerFs = new ContainerFileSystem(this); + this.userPrincipalLookupService = new ContainerUserPrincipalLookupService( + containerRootOnHost.getFileSystem().getUserPrincipalLookupService(), uidOffset, gidOffset); + this.containerRootOnHost = containerRootOnHost; + } + + public Path containerRootOnHost() { + return containerRootOnHost; + } + + public ContainerUserPrincipalLookupService userPrincipalLookupService() { + return userPrincipalLookupService; + } + + @Override + public String getScheme() { + return "file"; + } + + @Override + public FileSystem newFileSystem(URI uri, Map<String, ?> env) { + throw new FileSystemAlreadyExistsException(); + } + + @Override + public ContainerFileSystem getFileSystem(URI uri) { + return containerFs; + } + + @Override + public Path getPath(URI uri) { + throw new UnsupportedOperationException(); + } + + @Override + public SeekableByteChannel newByteChannel(Path path, Set<? extends OpenOption> options, FileAttribute<?>... attrs) throws IOException { + Path pathOnHost = pathOnHost(path); + SeekableByteChannel seekableByteChannel = provider(pathOnHost).newByteChannel(pathOnHost, options, attrs); + fixOwnerToContainerRoot(toContainerPath(path)); + return seekableByteChannel; + } + + @Override + public DirectoryStream<Path> newDirectoryStream(Path dir, DirectoryStream.Filter<? super Path> filter) throws IOException { + Path pathOnHost = pathOnHost(dir); + return new ContainerDirectoryStream(provider(pathOnHost).newDirectoryStream(pathOnHost, filter)); + } + + @Override + public void createDirectory(Path dir, FileAttribute<?>... attrs) throws IOException { + Path pathOnHost = pathOnHost(dir); + provider(pathOnHost).createDirectory(pathOnHost); + fixOwnerToContainerRoot(toContainerPath(dir)); + } + + @Override + public void delete(Path path) throws IOException { + Path pathOnHost = pathOnHost(path); + provider(pathOnHost).delete(pathOnHost); + } + + @Override + public void copy(Path source, Path target, CopyOption... options) throws IOException { + // Only called when both 'source' and 'target' have 'this' as the FS provider + Path targetPathOnHost = pathOnHost(target); + provider(targetPathOnHost).copy(pathOnHost(source), targetPathOnHost, options); + fixOwnerToContainerRoot(toContainerPath(target)); + } + + @Override + public void move(Path source, Path target, CopyOption... options) throws IOException { + // Only called when both 'source' and 'target' have 'this' as the FS provider + Path targetPathOnHost = pathOnHost(target); + provider(targetPathOnHost).move(pathOnHost(source), targetPathOnHost, options); + fixOwnerToContainerRoot(toContainerPath(target)); + } + + @Override + public boolean isSameFile(Path path, Path path2) throws IOException { + // 'path' FS provider should be 'this' + if (path2 instanceof ContainerPath) + path2 = pathOnHost(path2); + Path pathOnHost = pathOnHost(path); + return provider(pathOnHost).isSameFile(pathOnHost, path2); + } + + @Override + public boolean isHidden(Path path) throws IOException { + Path pathOnHost = pathOnHost(path); + return provider(pathOnHost).isHidden(pathOnHost); + } + + @Override + public FileStore getFileStore(Path path) { + throw new UnsupportedOperationException(); + } + + @Override + public void checkAccess(Path path, AccessMode... modes) throws IOException { + Path pathOnHost = pathOnHost(path); + provider(pathOnHost).checkAccess(pathOnHost, modes); + } + + @Override + @SuppressWarnings("unchecked") + public <V extends FileAttributeView> V getFileAttributeView(Path path, Class<V> type, LinkOption... options) { + if (!type.isAssignableFrom(PosixFileAttributeView.class)) return null; + Path pathOnHost = pathOnHost(path); + FileSystemProvider provider = pathOnHost.getFileSystem().provider(); + if (type == BasicFileAttributeView.class) // Basic view doesnt have owner/group fields, forward to base FS provider + return provider.getFileAttributeView(pathOnHost, type, options); + + PosixFileAttributeView view = provider.getFileAttributeView(pathOnHost, PosixFileAttributeView.class, options); + return (V) new ContainerPosixFileAttributeView(view, + uncheck(() -> new ContainerPosixFileAttributes(readAttributes(path, "unix:*", options)))); + } + + @Override + @SuppressWarnings("unchecked") + public <A extends BasicFileAttributes> A readAttributes(Path path, Class<A> type, LinkOption... options) throws IOException { + if (!type.isAssignableFrom(PosixFileAttributes.class)) throw new UnsupportedOperationException(); + Path pathOnHost = pathOnHost(path); + if (type == BasicFileAttributes.class) + return pathOnHost.getFileSystem().provider().readAttributes(pathOnHost, type, options); + + // Non-basic requests need to be upgraded to unix:* to get owner,group,uid,gid fields, which are then re-mapped + return (A) new ContainerPosixFileAttributes(readAttributes(path, "unix:*", options)); + } + + @Override + public Map<String, Object> readAttributes(Path path, String attributes, LinkOption... options) throws IOException { + Path pathOnHost = pathOnHost(path); + int index = attributes.indexOf(':'); + if (index < 0 || attributes.startsWith("basic:")) + return provider(pathOnHost).readAttributes(pathOnHost, attributes, options); + + Map<String, Object> attrs = new HashMap<>(provider(pathOnHost).readAttributes(pathOnHost, "unix:*", options)); + int uid = userPrincipalLookupService.hostUidToContainerUid((int) attrs.get("uid")); + int gid = userPrincipalLookupService.hostGidToContainerGid((int) attrs.get("gid")); + attrs.put("uid", uid); + attrs.put("gid", gid); + attrs.put("owner", new ContainerUserPrincipal(uid, (UserPrincipal) attrs.get("owner"))); + attrs.put("group", new ContainerGroupPrincipal(gid, (GroupPrincipal) attrs.get("group"))); + return attrs; + } + + @Override + public void setAttribute(Path path, String attribute, Object value, LinkOption... options) throws IOException { + Path pathOnHost = pathOnHost(path); + provider(pathOnHost).setAttribute(pathOnHost, attribute, fixAttributeValue(attribute, value), options); + } + + private Object fixAttributeValue(String attribute, Object value) { + int index = attribute.indexOf(':'); + if (index > 0) { + switch (attribute.substring(index + 1)) { + case "owner": return cast(value, ContainerUserPrincipal.class).baseFsPrincipal(); + case "group": return cast(value, ContainerGroupPrincipal.class).baseFsPrincipal(); + case "uid": return userPrincipalLookupService.containerUidToHostUid(cast(value, Integer.class)); + case "gid": return userPrincipalLookupService.containerGidToHostGid(cast(value, Integer.class)); + } + } // else basic file attribute + return value; + } + + private void fixOwnerToContainerRoot(ContainerPath path) throws IOException { + setAttribute(path, "unix:uid", 0); + setAttribute(path, "unix:gid", 0); + } + + private class ContainerDirectoryStream implements DirectoryStream<Path> { + private final DirectoryStream<Path> hostDirectoryStream; + + private ContainerDirectoryStream(DirectoryStream<Path> hostDirectoryStream) { + this.hostDirectoryStream = hostDirectoryStream; + } + + @Override + public Iterator<Path> iterator() { + Iterator<Path> hostPathIterator = hostDirectoryStream.iterator(); + return new Iterator<>() { + @Override + public boolean hasNext() { + return hostPathIterator.hasNext(); + } + + @Override + public Path next() { + Path pathOnHost = hostPathIterator.next(); + return ContainerPath.fromPathOnHost(containerFs, pathOnHost); + } + }; + } + + @Override + public void close() throws IOException { + hostDirectoryStream.close(); + } + } + + + static ContainerPath toContainerPath(Path path) { + return cast(path, ContainerPath.class); + } + + private static <T> T cast(Object value, Class<T> type) { + if (type.isInstance(value)) return type.cast(value); + throw new ProviderMismatchException("Expected " + type.getName() + ", was " + value.getClass().getName()); + } + + private static Path pathOnHost(Path path) { + return toContainerPath(path).pathOnHost(); + } + + private static FileSystemProvider provider(Path path) { + return path.getFileSystem().provider(); + } +} diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/fs/ContainerPath.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/fs/ContainerPath.java new file mode 100644 index 00000000000..e967806dc55 --- /dev/null +++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/fs/ContainerPath.java @@ -0,0 +1,223 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.node.admin.task.util.fs; + +import java.io.IOException; +import java.net.URI; +import java.nio.file.FileSystem; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.WatchEvent; +import java.nio.file.WatchKey; +import java.nio.file.WatchService; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +import static com.yahoo.vespa.hosted.node.admin.task.util.fs.ContainerFileSystemProvider.toContainerPath; + +/** + * @author valerijf + */ +public class ContainerPath implements Path { + private final ContainerFileSystem containerFs; + private final Path pathOnHost; + private final String[] parts; + + private ContainerPath(ContainerFileSystem containerFs, Path pathOnHost, String[] parts) { + this.containerFs = Objects.requireNonNull(containerFs); + this.pathOnHost = Objects.requireNonNull(pathOnHost); + this.parts = Objects.requireNonNull(parts); + + if (!pathOnHost.isAbsolute()) + throw new IllegalArgumentException("Path host must be absolute: " + pathOnHost); + Path containerRootOnHost = containerFs.provider().containerRootOnHost(); + if (!pathOnHost.startsWith(containerRootOnHost)) + throw new IllegalArgumentException("Path on host (" + pathOnHost + ") must start with container root on host (" + containerRootOnHost + ")"); + } + + public Path pathOnHost() { + return pathOnHost; + } + + @Override + public FileSystem getFileSystem() { + return containerFs; + } + + @Override + public ContainerPath getRoot() { + return resolve(containerFs, new String[0], Path.of("/")); + } + + @Override + public Path getFileName() { + if (parts.length == 0) return null; + return Path.of(parts[parts.length - 1]); + } + + @Override + public ContainerPath getParent() { + if (parts.length == 0) return null; + return new ContainerPath(containerFs, pathOnHost.getParent(), Arrays.copyOf(parts, parts.length-1)); + } + + @Override + public int getNameCount() { + return parts.length; + } + + @Override + public Path getName(int index) { + return Path.of(parts[index]); + } + + @Override + public Path subpath(int beginIndex, int endIndex) { + if (beginIndex < 0 || beginIndex >= endIndex || endIndex > parts.length) + throw new IllegalArgumentException(); + if (endIndex - beginIndex == 1) return getName(beginIndex); + + String[] rest = new String[endIndex - beginIndex - 1]; + System.arraycopy(parts, beginIndex + 1, rest, 0, rest.length); + return Path.of(parts[beginIndex], rest); + } + + @Override + public ContainerPath resolve(Path other) { + return resolve(containerFs, parts, other); + } + + @Override + public ContainerPath resolveSibling(String other) { + return resolve(Path.of("..", other)); + } + + @Override + public boolean startsWith(Path other) { + if (other.getFileSystem() != containerFs) return false; + String[] otherParts = toContainerPath(other).parts; + if (parts.length < otherParts.length) return false; + + for (int i = 0; i < otherParts.length; i++) { + if ( ! parts[i].equals(otherParts[i])) return false; + } + return true; + } + + @Override + public boolean endsWith(Path other) { + int offset = parts.length - other.getNameCount(); + // If the other path is longer than this, or the other path is absolute and shorter than this + if (offset < 0 || (other.isAbsolute() && offset > 0)) return false; + + for (int i = 0; i < other.getNameCount(); i++) { + if ( ! parts[offset + i].equals(other.getName(i).toString())) return false; + } + return true; + } + + @Override + public boolean isAbsolute() { + // All container paths are normalized and absolute + return true; + } + + @Override + public ContainerPath normalize() { + // All container paths are normalized and absolute + return this; + } + + @Override + public ContainerPath toAbsolutePath() { + // All container paths are normalized and absolute + return this; + } + + @Override + public ContainerPath toRealPath(LinkOption... options) throws IOException { + Path realPathOnHost = pathOnHost.toRealPath(options); + if (realPathOnHost.equals(pathOnHost)) return this; + return fromPathOnHost(containerFs, realPathOnHost); + } + + @Override + public Path relativize(Path other) { + return pathOnHost.relativize(toContainerPath(other).pathOnHost); + } + + @Override + public URI toUri() { + throw new UnsupportedOperationException(); + } + + @Override + public WatchKey register(WatchService watcher, WatchEvent.Kind<?>[] events, WatchEvent.Modifier... modifiers) throws IOException { + return pathOnHost.register(watcher, events, modifiers); + } + + @Override + public int compareTo(Path other) { + return pathOnHost.compareTo(toContainerPath(other)); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ContainerPath paths = (ContainerPath) o; + return containerFs.equals(paths.containerFs) && pathOnHost.equals(paths.pathOnHost) && Arrays.equals(parts, paths.parts); + } + + @Override + public int hashCode() { + int result = Objects.hash(containerFs, pathOnHost); + result = 31 * result + Arrays.hashCode(parts); + return result; + } + + @Override + public String toString() { + return '/' + String.join("/", parts); + } + + private static ContainerPath resolve(ContainerFileSystem containerFs, String[] currentParts, Path other) { + List<String> parts = other.isAbsolute() ? new ArrayList<>() : new ArrayList<>(Arrays.asList(currentParts)); + for (int i = 0; i < other.getNameCount(); i++) { + String part = other.getName(i).toString(); + if (part.isEmpty() || part.equals(".")) continue; + if (part.equals("..")) { + if (!parts.isEmpty()) parts.remove(parts.size() - 1); + continue; + } + parts.add(part); + } + + return new ContainerPath(containerFs, + containerFs.provider().containerRootOnHost().resolve(String.join("/", parts)), + parts.toArray(String[]::new)); + } + + static ContainerPath fromPathInContainer(ContainerFileSystem containerFs, Path pathInContainer) { + if (!pathInContainer.isAbsolute()) + throw new IllegalArgumentException("Path in container must be absolute: " + pathInContainer); + return resolve(containerFs, new String[0], pathInContainer); + } + +static ContainerPath fromPathOnHost(ContainerFileSystem containerFs, Path pathOnHost) { + pathOnHost = pathOnHost.normalize(); + Path containerRootOnHost = containerFs.provider().containerRootOnHost(); + Path pathUnderContainerStorage = containerRootOnHost.relativize(pathOnHost); + + if (pathUnderContainerStorage.getNameCount() == 0 || pathUnderContainerStorage.getName(0).toString().isEmpty()) + return new ContainerPath(containerFs, pathOnHost, new String[0]); + if (pathUnderContainerStorage.getName(0).toString().equals("..")) + throw new IllegalArgumentException("Path " + pathOnHost + " is not under container root " + containerRootOnHost); + + List<String> parts = new ArrayList<>(); + for (int i = 0; i < pathUnderContainerStorage.getNameCount(); i++) + parts.add(pathUnderContainerStorage.getName(i).toString()); + return new ContainerPath(containerFs, pathOnHost, parts.toArray(String[]::new)); +} +} diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/fs/ContainerUserPrincipalLookupService.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/fs/ContainerUserPrincipalLookupService.java new file mode 100644 index 00000000000..893e86ca239 --- /dev/null +++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/fs/ContainerUserPrincipalLookupService.java @@ -0,0 +1,135 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.node.admin.task.util.fs; + +import com.google.common.collect.ImmutableBiMap; + +import java.io.IOException; +import java.nio.file.attribute.GroupPrincipal; +import java.nio.file.attribute.UserPrincipal; +import java.nio.file.attribute.UserPrincipalLookupService; +import java.nio.file.attribute.UserPrincipalNotFoundException; +import java.util.Objects; +import java.util.Optional; + +/** + * @author valerijf + */ +class ContainerUserPrincipalLookupService extends UserPrincipalLookupService { + + /** Total number of UID/GID that are mapped for each container */ + private static final int ID_RANGE = 1 << 16; + + /** + * IDs outside the ID range are translated to the overflow ID before being written to disk: + * https://github.com/torvalds/linux/blob/5bfc75d92efd494db37f5c4c173d3639d4772966/Documentation/admin-guide/sysctl/fs.rst#overflowgid--overflowuid */ + static final int OVERFLOW_ID = 65_534; + + private static final ImmutableBiMap<String, Integer> CONTAINER_IDS_BY_NAME = ImmutableBiMap.<String, Integer>builder() + .put("root", 0) + .put("vespa", 1000) + .build(); + + private final UserPrincipalLookupService baseFsUserPrincipalLookupService; + private final int uidOffset; + private final int gidOffset; + + ContainerUserPrincipalLookupService(UserPrincipalLookupService baseFsUserPrincipalLookupService, int uidOffset, int gidOffset) { + this.baseFsUserPrincipalLookupService = baseFsUserPrincipalLookupService; + this.uidOffset = uidOffset; + this.gidOffset = gidOffset; + } + + public int containerUidToHostUid(int containerUid) { return containerIdToHostId(containerUid, uidOffset); } + public int containerGidToHostGid(int containerGid) { return containerIdToHostId(containerGid, gidOffset); } + public int hostUidToContainerUid(int hostUid) { return hostIdToContainerId(hostUid, uidOffset); } + public int hostGidToContainerGid(int hostGid) { return hostIdToContainerId(hostGid, gidOffset); } + + @Override + public ContainerUserPrincipal lookupPrincipalByName(String name) throws IOException { + int containerUid = resolve(name); + String hostUid = String.valueOf(containerUidToHostUid(containerUid)); + return new ContainerUserPrincipal(containerUid, baseFsUserPrincipalLookupService.lookupPrincipalByName(hostUid)); + } + + @Override + public ContainerGroupPrincipal lookupPrincipalByGroupName(String group) throws IOException { + int containerGid = resolve(group); + String hostGid = String.valueOf(containerGidToHostGid(containerGid)); + return new ContainerGroupPrincipal(containerGid, baseFsUserPrincipalLookupService.lookupPrincipalByGroupName(hostGid)); + } + + private static int resolve(String name) throws UserPrincipalNotFoundException { + Integer id = CONTAINER_IDS_BY_NAME.get(name); + if (id != null) return id; + + try { + return Integer.parseInt(name); + } catch (NumberFormatException ignored) { + throw new UserPrincipalNotFoundException(name); + } + } + + private abstract static class NamedPrincipal implements UserPrincipal { + private final int id; + private final String name; + private final UserPrincipal baseFsPrincipal; + + private NamedPrincipal(int id, UserPrincipal baseFsPrincipal) { + this.id = id; + this.name = Optional.ofNullable(CONTAINER_IDS_BY_NAME.inverse().get(id)).orElseGet(() -> Integer.toString(id)); + this.baseFsPrincipal = Objects.requireNonNull(baseFsPrincipal); + } + + @Override + public final String getName() { + return name; + } + + public int id() { + return id; + } + + public UserPrincipal baseFsPrincipal() { + return baseFsPrincipal; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + NamedPrincipal that = (NamedPrincipal) o; + return id == that.id && baseFsPrincipal.equals(that.baseFsPrincipal); + } + + @Override + public int hashCode() { + return Objects.hash(id, baseFsPrincipal); + } + + @Override + public String toString() { + return "{id=" + id + ", baseFsPrincipal=" + baseFsPrincipal + '}'; + } + } + + static final class ContainerUserPrincipal extends NamedPrincipal { + ContainerUserPrincipal(int id, UserPrincipal baseFsPrincipal) { super(id, baseFsPrincipal); } + } + + static final class ContainerGroupPrincipal extends NamedPrincipal implements GroupPrincipal { + ContainerGroupPrincipal(int id, GroupPrincipal baseFsPrincipal) { super(id, baseFsPrincipal); } + + @Override public GroupPrincipal baseFsPrincipal() { return (GroupPrincipal) super.baseFsPrincipal(); } + } + + private static int containerIdToHostId(int id, int idOffset) { + if (id < 0 || id > ID_RANGE) + throw new IllegalArgumentException("Invalid container id: " + id); + return idOffset + id; + } + + private static int hostIdToContainerId(int id, int idOffset) { + id = id - idOffset; + return id < 0 || id >= ID_RANGE ? OVERFLOW_ID : id; + } +} |