service: Implement SetBinhost.

From my inspection, it seems we only use binhost
conf files to set: BINHOST_KEY=URI. The old code
allowed for multiple configuration lines, but this
seems overly complicated.

As a safety measure, this implementation performs
sanity checks to make sure no unexpected config
is clobbered.

TEST=./run_tests service/binhost_unittest
BUG=chromium:920418,b:127691580

Change-Id: I5ee06bf3043002e1de6a0645dc546f8a892ad1d2
Reviewed-on: https://chromium-review.googlesource.com/c/chromiumos/chromite/+/1534547
Reviewed-by: Jason Clinton <jclinton@chromium.org>
Commit-Queue: Evan Hernandez <evanhernandez@chromium.org>
Tested-by: Evan Hernandez <evanhernandez@chromium.org>
Trybot-Ready: Evan Hernandez <evanhernandez@chromium.org>
diff --git a/service/binhost.py b/service/binhost.py
new file mode 100644
index 0000000..377491b
--- /dev/null
+++ b/service/binhost.py
@@ -0,0 +1,73 @@
+# -*- coding: utf-8 -*-
+# Copyright 2019 The Chromium OS Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+"""The Binhost API interacts with Portage binhosts and Packages files."""
+
+from __future__ import print_function
+
+import os
+
+from chromite.lib import constants
+from chromite.lib import cros_build_lib
+from chromite.lib import osutils
+
+
+def _ValidateBinhostConf(path, key):
+  """Validates the binhost conf file defines only one environment variable.
+
+  This function is effectively a sanity check that ensures unexpected
+  configuration is not clobbered by conf overwrites.
+
+  Args:
+    path: Path to the file to validate.
+    key: Expected binhost key.
+
+  Raises:
+    ValueError: If file defines != 1 environment variable.
+  """
+  if not os.path.exists(path):
+    # If the conf file does not exist, e.g. with new targets, then whatever.
+    return
+
+  kvs = cros_build_lib.LoadKeyValueFile(path)
+
+  if not kvs:
+    raise ValueError(
+        'Found empty .conf file %s when a non-empty one was expected.' % path)
+  elif len(kvs) > 1:
+    raise ValueError(
+        'Conf file %s must define exactly 1 variable. '
+        'Instead found: %r' % (path, kvs))
+  elif key not in kvs:
+    raise KeyError('Did not find key %s in %s' % (key, path))
+
+
+def SetBinhost(target, key, uri, private=True):
+  """Set binhost configuration for the given build target.
+
+  A binhost is effectively a key (Portage env variable) pointing to a URL
+  that contains binaries. The configuration is set in .conf files at static
+  directories on a build target by build target (and host by host) basis.
+
+  This function updates the .conf file by completely rewriting it.
+
+  Args:
+    target: The build target to set configuration for.
+    key: The binhost key to set, e.g. POSTSUBMIT_BINHOST.
+    uri: The new value for the binhost key, e.g. gs://chromeos-prebuilt/foo/bar.
+    private: Whether or not the build target is private.
+
+  Returns:
+    Path to the updated .conf file.
+  """
+  conf_root = os.path.join(
+      constants.SOURCE_ROOT,
+      constants.PRIVATE_BINHOST_CONF_DIR if private else
+      constants.PUBLIC_BINHOST_CONF_DIR, 'target')
+  conf_file = '%s-%s.conf' % (target, key)
+  conf_path = os.path.join(conf_root, conf_file)
+  _ValidateBinhostConf(conf_path, key)
+  osutils.WriteFile(conf_path, '%s="%s"' % (key, uri))
+  return conf_path
diff --git a/service/binhost_unittest b/service/binhost_unittest
new file mode 120000
index 0000000..72196ce
--- /dev/null
+++ b/service/binhost_unittest
@@ -0,0 +1 @@
+../scripts/wrapper.py
\ No newline at end of file
diff --git a/service/binhost_unittest.py b/service/binhost_unittest.py
new file mode 100644
index 0000000..d1c8fae
--- /dev/null
+++ b/service/binhost_unittest.py
@@ -0,0 +1,75 @@
+# -*- coding: utf-8 -*-path + os.sep)
+# Copyright 2019 The Chromium OS Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+"""Unittests for the binhost.py service."""
+
+from __future__ import print_function
+
+import os
+
+from chromite.lib import constants
+from chromite.lib import cros_test_lib
+from chromite.lib import osutils
+from chromite.service import binhost
+
+class BinhostTest(cros_test_lib.MockTempDirTestCase):
+  """Unit test definitions."""
+
+  def setUp(self):
+    self.PatchObject(constants, 'SOURCE_ROOT', new=self.tempdir)
+
+    self.public_conf_dir = os.path.join(
+        self.tempdir, constants.PUBLIC_BINHOST_CONF_DIR, 'target')
+    osutils.SafeMakedirs(self.public_conf_dir)
+
+    self.private_conf_dir = os.path.join(
+        self.tempdir, constants.PRIVATE_BINHOST_CONF_DIR, 'target')
+    osutils.SafeMakedirs(self.private_conf_dir)
+
+  def tearDown(self):
+    osutils.EmptyDir(self.tempdir)
+
+  def testSetBinhostPublic(self):
+    """SetBinhost returns correct public path and updates conf file."""
+    actual = binhost.SetBinhost(
+        'coral', 'BINHOST_KEY', 'gs://prebuilts', private=False)
+    expected = os.path.join(self.public_conf_dir, 'coral-BINHOST_KEY.conf')
+    self.assertEqual(actual, expected)
+    self.assertEqual(osutils.ReadFile(actual), 'BINHOST_KEY="gs://prebuilts"')
+
+  def testSetBinhostPrivate(self):
+    """SetBinhost returns correct private path and updates conf file."""
+    actual = binhost.SetBinhost('coral', 'BINHOST_KEY', 'gs://prebuilts')
+    expected = os.path.join(self.private_conf_dir, 'coral-BINHOST_KEY.conf')
+    self.assertEqual(actual, expected)
+    self.assertEqual(osutils.ReadFile(actual), 'BINHOST_KEY="gs://prebuilts"')
+
+  def testSetBinhostEmptyConf(self):
+    """SetBinhost rejects existing but empty conf files."""
+    conf_path = os.path.join(self.private_conf_dir, 'multi-BINHOST_KEY.conf')
+    osutils.WriteFile(conf_path, ' ')
+    with self.assertRaises(ValueError):
+      binhost.SetBinhost('multi', 'BINHOST_KEY', 'gs://blah')
+
+  def testSetBinhostMultilineConf(self):
+    """SetBinhost rejects existing multiline conf files."""
+    conf_path = os.path.join(self.private_conf_dir, 'multi-BINHOST_KEY.conf')
+    osutils.WriteFile(conf_path, '\n'.join(['A="foo"', 'B="bar"']))
+    with self.assertRaises(ValueError):
+      binhost.SetBinhost('multi', 'BINHOST_KEY', 'gs://blah')
+
+  def testSetBinhhostBadConfLine(self):
+    """SetBinhost rejects existing conf files with malformed lines."""
+    conf_path = os.path.join(self.private_conf_dir, 'bad-BINHOST_KEY.conf')
+    osutils.WriteFile(conf_path, 'bad line')
+    with self.assertRaises(ValueError):
+      binhost.SetBinhost('bad', 'BINHOST_KEY', 'gs://blah')
+
+  def testSetBinhostMismatchedKey(self):
+    """SetBinhost rejects existing conf files with a mismatched key."""
+    conf_path = os.path.join(self.private_conf_dir, 'bad-key-GOOD_KEY.conf')
+    osutils.WriteFile(conf_path, 'BAD_KEY="https://foo.bar"')
+    with self.assertRaises(KeyError):
+      binhost.SetBinhost('bad-key', 'GOOD_KEY', 'gs://blah')