remote: implement a naive action-scoped file system

This change accompanies d480c5f [1] in that it registers
an action-scoped filesystem that supports lazily fetching
action inputs that are remotely stored outputs of an upstream
action. While in theory any native java action in Bazel could
do arbitrary file system operations in practice this is mostly
useful for
 * AbstractFileWriteAction (calls Path.getInputStream())
 * TemplateExpansionAction (calls Path.getInputStream())
 * SymlinkAction (calls Path.(isFile|isExecutable|createSymbolicLink)())
 * SolibSymlinkAction (calls Path.createSymbolicLink())

For Starlark actions we found that only ctx.actions.expand_template(...)
directly interacts with the file system (via TemplateExpansionAction).
All other methods on ctx.actions create spawns internally which are
covered by d480c5f.

Progress towards #6862.

[1] https://github.com/bazelbuild/bazel/commit/d480c5f5a4f38a4053ed3e3bcc4eaef343923d2d

Closes #7926.

PiperOrigin-RevId: 241717157
diff --git a/src/main/java/com/google/devtools/build/lib/remote/BUILD b/src/main/java/com/google/devtools/build/lib/remote/BUILD
index 2a84ed2..020c2cb 100644
--- a/src/main/java/com/google/devtools/build/lib/remote/BUILD
+++ b/src/main/java/com/google/devtools/build/lib/remote/BUILD
@@ -34,6 +34,7 @@
         "//src/main/java/com/google/devtools/build/lib/remote/options",
         "//src/main/java/com/google/devtools/build/lib/remote/util",
         "//src/main/java/com/google/devtools/build/lib/vfs",
+        "//src/main/java/com/google/devtools/build/lib/vfs:output_service",
         "//src/main/java/com/google/devtools/common/options",
         "//third_party:auth",
         "//third_party:guava",
diff --git a/src/main/java/com/google/devtools/build/lib/remote/RemoteActionFileSystem.java b/src/main/java/com/google/devtools/build/lib/remote/RemoteActionFileSystem.java
new file mode 100644
index 0000000..0491c2d
--- /dev/null
+++ b/src/main/java/com/google/devtools/build/lib/remote/RemoteActionFileSystem.java
@@ -0,0 +1,373 @@
+// Copyright 2019 The Bazel Authors. All rights reserved.
+//
+// Licensed 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 com.google.devtools.build.lib.remote;
+
+import com.google.common.base.Preconditions;
+import com.google.devtools.build.lib.actions.ActionInputMap;
+import com.google.devtools.build.lib.actions.FileArtifactValue;
+import com.google.devtools.build.lib.actions.FileArtifactValue.RemoteFileArtifactValue;
+import com.google.devtools.build.lib.vfs.DelegateFileSystem;
+import com.google.devtools.build.lib.vfs.Dirent;
+import com.google.devtools.build.lib.vfs.FileStatus;
+import com.google.devtools.build.lib.vfs.FileSystem;
+import com.google.devtools.build.lib.vfs.Path;
+import com.google.devtools.build.lib.vfs.PathFragment;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Collection;
+import javax.annotation.Nullable;
+
+/**
+ * This is a basic implementation and incomplete implementation of an action file system that's been
+ * tuned to what native (non-spawn) actions in Bazel currently use.
+ *
+ * <p>The implementation mostly delegates to the local file system except for the case where an
+ * action input is a remotely stored action output. Most notably {@link #getInputStream(Path)} and
+ * {@link #createSymbolicLink(Path, PathFragment)}.
+ *
+ * <p>This implementation only supports creating local action outputs.
+ */
+public class RemoteActionFileSystem extends DelegateFileSystem {
+
+  private final Path execRoot;
+  private final Path outputBase;
+  private final ActionInputMap inputArtifactData;
+  private final RemoteActionInputFetcher inputFetcher;
+
+  public RemoteActionFileSystem(
+      FileSystem localDelegate,
+      PathFragment execRootFragment,
+      String relativeOutputPath,
+      ActionInputMap inputArtifactData,
+      RemoteActionInputFetcher inputFetcher) {
+    super(localDelegate);
+    this.execRoot = getPath(Preconditions.checkNotNull(execRootFragment, "execRootFragment"));
+    this.outputBase =
+        execRoot.getRelative(Preconditions.checkNotNull(relativeOutputPath, "relativeOutputPath"));
+    this.inputArtifactData = Preconditions.checkNotNull(inputArtifactData, "inputArtifactData");
+    this.inputFetcher = Preconditions.checkNotNull(inputFetcher, "inputFetcher");
+  }
+
+  @Override
+  public String getFileSystemType(Path path) {
+    return "remoteActionFS";
+  }
+
+  @Override
+  public boolean delete(Path path) throws IOException {
+    RemoteFileArtifactValue m = getRemoteInputMetadata(path);
+    if (m != null) {
+      return super.delete(path);
+    }
+    return true;
+  }
+
+  @Override
+  protected InputStream getInputStream(Path path) throws IOException {
+    downloadFileIfRemote(path);
+    return super.getInputStream(path);
+  }
+
+  @Override
+  public void setLastModifiedTime(Path path, long newTime) throws IOException {
+    RemoteFileArtifactValue m = getRemoteInputMetadata(path);
+    if (m == null) {
+      super.setLastModifiedTime(path, newTime);
+    }
+  }
+
+  @Override
+  protected byte[] getFastDigest(Path path) throws IOException {
+    RemoteFileArtifactValue m = getRemoteInputMetadata(path);
+    if (m != null) {
+      return m.getDigest();
+    }
+    return super.getFastDigest(path);
+  }
+
+  @Override
+  protected byte[] getDigest(Path path) throws IOException {
+    RemoteFileArtifactValue m = getRemoteInputMetadata(path);
+    if (m != null) {
+      return m.getDigest();
+    }
+    return super.getDigest(path);
+  }
+
+  // -------------------- File Permissions --------------------
+
+  @Override
+  protected boolean isReadable(Path path) throws IOException {
+    FileArtifactValue m = getRemoteInputMetadata(path);
+    return m != null || super.isReadable(path);
+  }
+
+  @Override
+  protected boolean isWritable(Path path) throws IOException {
+    FileArtifactValue m = getRemoteInputMetadata(path);
+    return m != null || super.isWritable(path);
+  }
+
+  @Override
+  protected boolean isExecutable(Path path) throws IOException {
+    FileArtifactValue m = getRemoteInputMetadata(path);
+    return m != null || super.isExecutable(path);
+  }
+
+  @Override
+  protected void setReadable(Path path, boolean readable) throws IOException {
+    RemoteFileArtifactValue m = getRemoteInputMetadata(path);
+    if (m == null) {
+      super.setReadable(path, readable);
+    }
+  }
+
+  @Override
+  public void setWritable(Path path, boolean writable) throws IOException {
+    RemoteFileArtifactValue m = getRemoteInputMetadata(path);
+    if (m == null) {
+      super.setWritable(path, writable);
+    }
+  }
+
+  @Override
+  protected void setExecutable(Path path, boolean executable) throws IOException {
+    RemoteFileArtifactValue m = getRemoteInputMetadata(path);
+    if (m == null) {
+      super.setExecutable(path, executable);
+    }
+  }
+
+  @Override
+  protected void chmod(Path path, int mode) throws IOException {
+    RemoteFileArtifactValue m = getRemoteInputMetadata(path);
+    if (m == null) {
+      super.chmod(path, mode);
+    }
+  }
+
+  // -------------------- Symlinks --------------------
+
+  @Override
+  protected PathFragment readSymbolicLink(Path path) throws IOException {
+    FileArtifactValue m = getRemoteInputMetadata(path);
+    if (m != null) {
+      // We don't support symlinks as remote action outputs.
+      throw new IOException(path + " is not a symbolic link");
+    }
+    return super.readSymbolicLink(path);
+  }
+
+  @Override
+  protected void createSymbolicLink(Path linkPath, PathFragment targetFragment) throws IOException {
+    /*
+     * TODO(buchgr): Optimize the case where we are creating a symlink to a remote output. This does
+     * add a non-trivial amount of complications though (as symlinks tend to do).
+     */
+    downloadFileIfRemote(execRoot.getRelative(targetFragment));
+    super.createSymbolicLink(linkPath, targetFragment);
+  }
+
+  // -------------------- Implementations based on stat() --------------------
+
+  @Override
+  protected long getLastModifiedTime(Path path, boolean followSymlinks) throws IOException {
+    FileStatus stat = stat(path, followSymlinks);
+    return stat.getLastModifiedTime();
+  }
+
+  @Override
+  protected long getFileSize(Path path, boolean followSymlinks) throws IOException {
+    FileStatus stat = stat(path, followSymlinks);
+    return stat.getSize();
+  }
+
+  @Override
+  protected boolean isFile(Path path, boolean followSymlinks) {
+    FileStatus stat = statNullable(path, followSymlinks);
+    return stat != null && stat.isFile();
+  }
+
+  @Override
+  protected boolean isSymbolicLink(Path path) {
+    FileStatus stat = statNullable(path, /* followSymlinks= */ false);
+    return stat != null && stat.isSymbolicLink();
+  }
+
+  @Override
+  protected boolean isDirectory(Path path, boolean followSymlinks) {
+    FileStatus stat = statNullable(path, followSymlinks);
+    return stat != null && stat.isDirectory();
+  }
+
+  @Override
+  protected boolean isSpecialFile(Path path, boolean followSymlinks) {
+    FileStatus stat = statNullable(path, followSymlinks);
+    return stat != null && stat.isDirectory();
+  }
+
+  @Override
+  protected boolean exists(Path path, boolean followSymlinks) {
+    try {
+      return statIfFound(path, followSymlinks) != null;
+    } catch (IOException e) {
+      return false;
+    }
+  }
+
+  @Override
+  public boolean exists(Path path) {
+    return exists(path, /* followSymlinks= */ true);
+  }
+
+  @Nullable
+  @Override
+  protected FileStatus statIfFound(Path path, boolean followSymlinks) throws IOException {
+    try {
+      return stat(path, followSymlinks);
+    } catch (FileNotFoundException e) {
+      return null;
+    }
+  }
+
+  @Nullable
+  @Override
+  protected FileStatus statNullable(Path path, boolean followSymlinks) {
+    try {
+      return stat(path, followSymlinks);
+    } catch (IOException e) {
+      return null;
+    }
+  }
+
+  @Override
+  protected FileStatus stat(Path path, boolean followSymlinks) throws IOException {
+    RemoteFileArtifactValue m = getRemoteInputMetadata(path);
+    if (m != null) {
+      return statFromRemoteMetadata(m);
+    }
+    return super.stat(path, followSymlinks);
+  }
+
+  private FileStatus statFromRemoteMetadata(RemoteFileArtifactValue m) {
+    return new FileStatus() {
+      @Override
+      public boolean isFile() {
+        return m.getType().isFile();
+      }
+
+      @Override
+      public boolean isDirectory() {
+        return m.getType().isDirectory();
+      }
+
+      @Override
+      public boolean isSymbolicLink() {
+        return m.getType().isSymlink();
+      }
+
+      @Override
+      public boolean isSpecialFile() {
+        return m.getType().isSpecialFile();
+      }
+
+      @Override
+      public long getSize() {
+        return m.getSize();
+      }
+
+      @Override
+      public long getLastModifiedTime() {
+        return m.getModifiedTime();
+      }
+
+      @Override
+      public long getLastChangeTime() {
+        return m.getModifiedTime();
+      }
+
+      @Override
+      public long getNodeId() {
+        throw new UnsupportedOperationException();
+      }
+    };
+  }
+
+  // -------------------- Implementation Helpers --------------------
+
+  private String execPathString(Path path) {
+    return path.relativeTo(execRoot).getPathString();
+  }
+
+  @Nullable
+  private RemoteFileArtifactValue getRemoteInputMetadata(Path path) {
+    if (!path.startsWith(outputBase)) {
+      return null;
+    }
+    return getRemoteInputMetadata(execPathString(path));
+  }
+
+  @Nullable
+  private RemoteFileArtifactValue getRemoteInputMetadata(String execPathString) {
+    FileArtifactValue m = inputArtifactData.getMetadata(execPathString);
+    if (m != null && m.isRemote()) {
+      return (RemoteFileArtifactValue) m;
+    }
+    return null;
+  }
+
+  private void downloadFileIfRemote(Path path) throws IOException {
+    FileArtifactValue m = getRemoteInputMetadata(path);
+    if (m != null) {
+      try {
+        inputFetcher.downloadFile(toDelegatePath(path), m);
+      } catch (InterruptedException e) {
+        Thread.currentThread().interrupt();
+        throw new IOException(
+            String.format("Received interrupt while fetching file '%s'", path), e);
+      }
+    }
+  }
+
+  /*
+   * -------------------- TODO(buchgr): Not yet implemented --------------------
+   *
+   * The below methods have not (yet) been properly implemented due to time constraints mostly and
+   * with little risk as they currently don't seem to be used by native actions in Bazel. However,
+   * before making the --experimental_remote_download_outputs flag non-experimental we should make
+   * sure to fully implement this file system.
+   */
+
+  @Override
+  protected Collection<String> getDirectoryEntries(Path path) throws IOException {
+    return super.getDirectoryEntries(path);
+  }
+
+  @Override
+  protected void createFSDependentHardLink(Path linkPath, Path originalPath) throws IOException {
+    super.createFSDependentHardLink(linkPath, originalPath);
+  }
+
+  @Override
+  protected Collection<Dirent> readdir(Path path, boolean followSymlinks) throws IOException {
+    return super.readdir(path, followSymlinks);
+  }
+
+  @Override
+  protected void createHardLink(Path linkPath, Path originalPath) throws IOException {
+    super.createHardLink(linkPath, originalPath);
+  }
+}
diff --git a/src/main/java/com/google/devtools/build/lib/remote/RemoteActionInputFetcher.java b/src/main/java/com/google/devtools/build/lib/remote/RemoteActionInputFetcher.java
index 4eb4e2a..7f3960a 100644
--- a/src/main/java/com/google/devtools/build/lib/remote/RemoteActionInputFetcher.java
+++ b/src/main/java/com/google/devtools/build/lib/remote/RemoteActionInputFetcher.java
@@ -13,9 +13,14 @@
 // limitations under the License.
 package com.google.devtools.build.lib.remote;
 
+import build.bazel.remote.execution.v2.Digest;
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Preconditions;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.MoreExecutors;
 import com.google.devtools.build.lib.actions.ActionInput;
 import com.google.devtools.build.lib.actions.ActionInputPrefetcher;
 import com.google.devtools.build.lib.actions.FileArtifactValue;
@@ -33,6 +38,9 @@
 import java.util.HashSet;
 import java.util.Map;
 import java.util.Set;
+import java.util.concurrent.ExecutionException;
+import java.util.logging.Level;
+import java.util.logging.Logger;
 import javax.annotation.concurrent.GuardedBy;
 
 /**
@@ -43,13 +51,17 @@
  */
 class RemoteActionInputFetcher implements ActionInputPrefetcher {
 
+  private static final Logger logger = Logger.getLogger(RemoteActionInputFetcher.class.getName());
+
   private final Object lock = new Object();
 
+  /** Set of successfully downloaded output files. */
   @GuardedBy("lock")
   private final Set<Path> downloadedPaths = new HashSet<>();
 
+  @VisibleForTesting
   @GuardedBy("lock")
-  private final Map<Path, ListenableFuture<Void>> downloadsInProgress = new HashMap<>();
+  final Map<Path, ListenableFuture<Void>> downloadsInProgress = new HashMap<>();
 
   private final AbstractRemoteActionCache remoteCache;
   private final Path execRoot;
@@ -95,19 +107,7 @@
             if (downloadedPaths.contains(path)) {
               continue;
             }
-
-            ListenableFuture<Void> download = downloadsInProgress.get(path);
-            if (download == null) {
-              Context prevCtx = ctx.attach();
-              try {
-                download =
-                    remoteCache.downloadFile(
-                        path, DigestUtil.buildDigest(metadata.getDigest(), metadata.getSize()));
-                downloadsInProgress.put(path, download);
-              } finally {
-                ctx.detach(prevCtx);
-              }
-            }
+            ListenableFuture<Void> download = downloadFileAsync(path, metadata);
             downloadsToWaitFor.putIfAbsent(path, download);
           }
         }
@@ -115,32 +115,22 @@
 
       IOException ioException = null;
       InterruptedException interruptedException = null;
-      try {
-        for (Map.Entry<Path, ListenableFuture<Void>> entry : downloadsToWaitFor.entrySet()) {
-          try {
-            Utils.getFromFuture(entry.getValue());
-            entry.getKey().setExecutable(true);
-          } catch (IOException e) {
-            if (e instanceof CacheNotFoundException) {
-              e =
-                  new IOException(
-                      String.format(
-                          "Failed to fetch file with hash '%s' because it does not exist remotely."
-                              + " --experimental_remote_download_outputs=minimal does not work if"
-                              + " your remote cache evicts files during builds.",
-                          ((CacheNotFoundException) e).getMissingDigest().getHash()));
-            }
-            ioException = ioException == null ? e : ioException;
-          } catch (InterruptedException e) {
-            interruptedException = interruptedException == null ? e : interruptedException;
+      for (Map.Entry<Path, ListenableFuture<Void>> entry : downloadsToWaitFor.entrySet()) {
+        try {
+          Utils.getFromFuture(entry.getValue());
+        } catch (IOException e) {
+          if (e instanceof CacheNotFoundException) {
+            e =
+                new IOException(
+                    String.format(
+                        "Failed to fetch file with hash '%s' because it does not exist remotely."
+                            + " --experimental_remote_outputs=minimal does not work if"
+                            + " your remote cache evicts files during builds.",
+                        ((CacheNotFoundException) e).getMissingDigest().getHash()));
           }
-        }
-      } finally {
-        synchronized (lock) {
-          for (Path path : downloadsToWaitFor.keySet()) {
-            downloadsInProgress.remove(path);
-            downloadedPaths.add(path);
-          }
+          ioException = ioException == null ? e : ioException;
+        } catch (InterruptedException e) {
+          interruptedException = interruptedException == null ? e : interruptedException;
         }
       }
 
@@ -158,4 +148,72 @@
       return ImmutableSet.copyOf(downloadedPaths);
     }
   }
+
+  void downloadFile(Path path, FileArtifactValue metadata)
+      throws IOException, InterruptedException {
+    try {
+      downloadFileAsync(path, metadata).get();
+    } catch (ExecutionException e) {
+      if (e.getCause() instanceof IOException) {
+        throw (IOException) e.getCause();
+      }
+      throw new IOException(e.getCause());
+    }
+  }
+
+  private ListenableFuture<Void> downloadFileAsync(Path path, FileArtifactValue metadata)
+      throws IOException {
+    synchronized (lock) {
+      if (downloadedPaths.contains(path)) {
+        return Futures.immediateFuture(null);
+      }
+
+      ListenableFuture<Void> download = downloadsInProgress.get(path);
+      if (download == null) {
+        Context prevCtx = ctx.attach();
+        try {
+          Digest digest = DigestUtil.buildDigest(metadata.getDigest(), metadata.getSize());
+          download = remoteCache.downloadFile(path, digest);
+          downloadsInProgress.put(path, download);
+          Futures.addCallback(
+              download,
+              new FutureCallback<Void>() {
+                @Override
+                public void onSuccess(Void v) {
+                  synchronized (lock) {
+                    downloadsInProgress.remove(path);
+                    downloadedPaths.add(path);
+                  }
+
+                  try {
+                    path.setReadable(true);
+                    path.setExecutable(true);
+                  } catch (IOException e) {
+                    logger.log(Level.WARNING, "Failed to chmod +xr on " + path, e);
+                  }
+                }
+
+                @Override
+                public void onFailure(Throwable t) {
+                  synchronized (lock) {
+                    downloadsInProgress.remove(path);
+                  }
+                  try {
+                    path.delete();
+                  } catch (IOException e) {
+                    logger.log(
+                        Level.WARNING,
+                        "Failed to delete output file after incomplete download: " + path,
+                        e);
+                  }
+                }
+              },
+              MoreExecutors.directExecutor());
+        } finally {
+          ctx.detach(prevCtx);
+        }
+      }
+      return download;
+    }
+  }
 }
diff --git a/src/main/java/com/google/devtools/build/lib/remote/RemoteModule.java b/src/main/java/com/google/devtools/build/lib/remote/RemoteModule.java
index 4f3732c..35d05d6 100644
--- a/src/main/java/com/google/devtools/build/lib/remote/RemoteModule.java
+++ b/src/main/java/com/google/devtools/build/lib/remote/RemoteModule.java
@@ -43,6 +43,7 @@
 import com.google.devtools.build.lib.util.ExitCode;
 import com.google.devtools.build.lib.util.io.AsynchronousFileOutputStream;
 import com.google.devtools.build.lib.vfs.DigestHashFunction;
+import com.google.devtools.build.lib.vfs.OutputService;
 import com.google.devtools.build.lib.vfs.Path;
 import com.google.devtools.common.options.OptionsBase;
 import com.google.devtools.common.options.OptionsParsingResult;
@@ -75,6 +76,8 @@
 
   private RemoteActionContextProvider actionContextProvider;
   private RemoteActionInputFetcher actionInputFetcher;
+  private RemoteOutputsMode remoteOutputsMode;
+  private RemoteOutputService remoteOutputService;
 
   private final BuildEventArtifactUploaderFactoryDelegate
       buildEventArtifactUploaderFactoryDelegate = new BuildEventArtifactUploaderFactoryDelegate();
@@ -126,6 +129,7 @@
   public void beforeCommand(CommandEnvironment env) throws AbruptExitException {
     Preconditions.checkState(actionContextProvider == null, "actionContextProvider must be null");
     Preconditions.checkState(actionInputFetcher == null, "actionInputFetcher must be null");
+    Preconditions.checkState(remoteOutputsMode == null, "remoteOutputsMode must be null");
 
     RemoteOptions remoteOptions = env.getOptions().getOptions(RemoteOptions.class);
     if (remoteOptions == null) {
@@ -133,6 +137,8 @@
       return;
     }
 
+    remoteOutputsMode = remoteOptions.remoteOutputsMode;
+
     AuthAndTLSOptions authAndTlsOptions = env.getOptions().getOptions(AuthAndTLSOptions.class);
     DigestHashFunction hashFn = env.getRuntime().getFileSystem().getDigestFunction();
     DigestUtil digestUtil = new DigestUtil(hashFn);
@@ -361,6 +367,8 @@
     buildEventArtifactUploaderFactoryDelegate.reset();
     actionContextProvider = null;
     actionInputFetcher = null;
+    remoteOutputsMode = null;
+    remoteOutputService = null;
 
     if (failure != null) {
       throw new AbruptExitException(ExitCode.LOCAL_ENVIRONMENTAL_ERROR, failure);
@@ -404,6 +412,8 @@
   @Override
   public void executorInit(CommandEnvironment env, BuildRequest request, ExecutorBuilder builder) {
     Preconditions.checkState(actionInputFetcher == null, "actionInputFetcher must be null");
+    Preconditions.checkNotNull(remoteOutputsMode, "remoteOutputsMode must not be null");
+
     if (actionContextProvider == null) {
       return;
     }
@@ -420,10 +430,20 @@
           new RemoteActionInputFetcher(
               actionContextProvider.getRemoteCache(), env.getExecRoot(), ctx);
       builder.setActionInputPrefetcher(actionInputFetcher);
+      remoteOutputService.setActionInputFetcher(actionInputFetcher);
     }
   }
 
   @Override
+  public OutputService getOutputService() {
+    Preconditions.checkState(remoteOutputService == null, "remoteOutputService must be null");
+    if (remoteOutputsMode != null && !remoteOutputsMode.downloadAllOutputs()) {
+      remoteOutputService = new RemoteOutputService();
+    }
+    return remoteOutputService;
+  }
+
+  @Override
   public Iterable<Class<? extends OptionsBase>> getCommandOptions(Command command) {
     return "build".equals(command.name())
         ? ImmutableList.of(RemoteOptions.class, AuthAndTLSOptions.class)
diff --git a/src/main/java/com/google/devtools/build/lib/remote/RemoteOutputService.java b/src/main/java/com/google/devtools/build/lib/remote/RemoteOutputService.java
new file mode 100644
index 0000000..29a3ef1
--- /dev/null
+++ b/src/main/java/com/google/devtools/build/lib/remote/RemoteOutputService.java
@@ -0,0 +1,119 @@
+// Copyright 2019 The Bazel Authors. All rights reserved.
+//
+// Licensed 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 com.google.devtools.build.lib.remote;
+
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableList;
+import com.google.devtools.build.lib.actions.Action;
+import com.google.devtools.build.lib.actions.ActionInputMap;
+import com.google.devtools.build.lib.actions.Artifact;
+import com.google.devtools.build.lib.actions.Artifact.SourceArtifact;
+import com.google.devtools.build.lib.actions.cache.MetadataHandler;
+import com.google.devtools.build.lib.events.EventHandler;
+import com.google.devtools.build.lib.vfs.BatchStat;
+import com.google.devtools.build.lib.vfs.FileSystem;
+import com.google.devtools.build.lib.vfs.ModifiedFileSet;
+import com.google.devtools.build.lib.vfs.OutputService;
+import com.google.devtools.build.lib.vfs.Path;
+import com.google.devtools.build.lib.vfs.PathFragment;
+import com.google.devtools.build.lib.vfs.Root;
+import java.util.UUID;
+import java.util.function.Function;
+import javax.annotation.Nullable;
+
+/** Output service implementation for the remote module */
+public class RemoteOutputService implements OutputService {
+
+  @Nullable private RemoteActionInputFetcher actionInputFetcher;
+
+  void setActionInputFetcher(RemoteActionInputFetcher actionInputFetcher) {
+    this.actionInputFetcher = Preconditions.checkNotNull(actionInputFetcher, "actionInputFetcher");
+  }
+
+  @Override
+  public ActionFileSystemType actionFileSystemType() {
+    return actionInputFetcher != null
+        ? ActionFileSystemType.STAGE_REMOTE_FILES
+        : ActionFileSystemType.DISABLED;
+  }
+
+  @Nullable
+  @Override
+  public FileSystem createActionFileSystem(
+      FileSystem sourceDelegate,
+      PathFragment execRootFragment,
+      String relativeOutputPath,
+      ImmutableList<Root> sourceRoots,
+      ActionInputMap inputArtifactData,
+      Iterable<Artifact> outputArtifacts,
+      Function<PathFragment, SourceArtifact> sourceArtifactFactory) {
+    Preconditions.checkNotNull(actionInputFetcher, "actionInputFetcher");
+    return new RemoteActionFileSystem(
+        sourceDelegate,
+        execRootFragment,
+        relativeOutputPath,
+        inputArtifactData,
+        actionInputFetcher);
+  }
+
+  @Override
+  public String getFilesSystemName() {
+    return "remoteActionFS";
+  }
+
+  @Override
+  public ModifiedFileSet startBuild(
+      EventHandler eventHandler, UUID buildId, boolean finalizeActions) {
+    return ModifiedFileSet.EVERYTHING_MODIFIED;
+  }
+
+  @Override
+  public void finalizeBuild(boolean buildSuccessful) {
+    // Intentionally left empty.
+  }
+
+  @Override
+  public void finalizeAction(Action action, MetadataHandler metadataHandler) {
+    // Intentionally left empty.
+  }
+
+  @Nullable
+  @Override
+  public BatchStat getBatchStatter() {
+    return null;
+  }
+
+  @Override
+  public boolean canCreateSymlinkTree() {
+    /* TODO(buchgr): Optimize symlink creation for remote execution */
+    return false;
+  }
+
+  @Override
+  public void createSymlinkTree(
+      Path inputManifest, Path outputManifest, boolean filesetTree, PathFragment symlinkTreeRoot) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void clean() {
+    // Intentionally left empty.
+  }
+
+  @Override
+  public boolean isRemoteFile(Artifact file) {
+    return false;
+  }
+}