Repository: buildbot/buildbot

Issue:
Gitpoller not working well with secret providers that strip newlines from secrets
Hello and happy new year!

In my `master.cfg` file, i have a `SecretInAFile` provider...

```python
c["secretsProviders"] = [
    buildbot.plugins.secrets.SecretInAFile(dirname="/workspace/secrets/")    # note that strip==True by default
]
```

... and a `GitPoller` which uses a secret as ssh private key:

```python
c["change_source"] = [
    buildbot.plugins.changes.GitPoller(
        ...
        ssh_privkey=buildbot.plugins.util.Secret("id_ed25519"),
    )
]
```

However, the `GitPoller` fails to authenticate: 

```
File "/usr/local/lib/python3.11/dist-packages/buildbot/changes/gitpoller.py", line 478, in _dovccmdImpl
    raise GitError(f'command {full_args} in {path} on repourl {self.repourl} failed '
buildbot.changes.gitpoller.GitError: command ['-c', 'core.sshCommand=ssh -o "BatchMode=yes" -i "/workspace/buildbot/./gitpoller/default/********/.buildbot-ssh9ffxzc86/ssh-key" -o "UserKnownHostsFile=/workspace/buildbot/./gitpoller/default/********/.buildbot-ssh9ffxzc86/ssh-known-hosts"', 'ls-remote', '--refs', 'git@github.com:********/********.git'] in None on repourl git@github.com:********/********.git failed with exit code 128: Load key "/workspace/buildbot/./gitpoller/default/********/.buildbot-ssh9ffxzc86/ssh-key": error in libcrypto
git@github.com: Permission denied (publickey).
fatal: Could not read from remote repository.

Please make sure you have the correct access rights
and the repository exists.
```

The private key itself is working (i have verified that it is) but it seems that when buildbot is copying the key, **it strips a trailing newline and then OpenSSH cannot read it anymore.** 

It seems that the `Git` BuildStep has already solved the same problem with [a function called ensureSshKeyNewline](https://github.com/buildbot/buildbot/blob/03f3395caed18b3d772585c0006803f79fa3eca8/master/buildbot/util/git.py#L128), and i think that `GitPoller` should do the same thing... 

In my `master.cfg` file, I could work around this issue by passing `strip=False` to the `SecretInAFile` provider. 

In case you need it, here are the buildbot/python/openssh/git versions i am using:

```
Buildbot version: 3.10.1
Twisted version: 23.10.0

Python 3.11.7
OpenSSH_8.9p1 Ubuntu-3ubuntu0.5, OpenSSL 3.0.2 15 Mar 2022
git version 2.34.1
```


Discussion:

Resolving patch:
diff --git a/master/buildbot/changes/gitpoller.py b/master/buildbot/changes/gitpoller.py
--- a/master/buildbot/changes/gitpoller.py
+++ b/master/buildbot/changes/gitpoller.py
@@ -27,6 +27,7 @@
 from buildbot.util import private_tempdir
 from buildbot.util import runprocess
 from buildbot.util.git import GitMixin
+from buildbot.util.git import ensureSshKeyNewline
 from buildbot.util.git import getSshKnownHostsContents
 from buildbot.util.misc import writeLocalFile
 from buildbot.util.state import StateMixin
@@ -493,7 +494,7 @@ def _downloadSshPrivateKey(self, keyPath):
         # We change the permissions of the key file to be user-readable only so
         # that ssh does not complain. This is not used for security because the
         # parent directory will have proper permissions.
-        writeLocalFile(keyPath, self.sshPrivateKey, mode=stat.S_IRUSR)
+        writeLocalFile(keyPath, ensureSshKeyNewline(self.sshPrivateKey), mode=stat.S_IRUSR)
 
     def _downloadSshKnownHosts(self, path):
         if self.sshKnownHosts is not None:


Test patch:
diff --git a/master/buildbot/test/unit/changes/test_gitpoller.py b/master/buildbot/test/unit/changes/test_gitpoller.py
--- a/master/buildbot/test/unit/changes/test_gitpoller.py
+++ b/master/buildbot/test/unit/changes/test_gitpoller.py
@@ -1844,7 +1844,7 @@ def test_poll_initial_2_10(self, write_local_file_mock, temp_dir_mock):
 
         temp_dir_path = os.path.join('basedir', 'gitpoller-work', '.buildbot-ssh@@@')
         self.assertEqual(temp_dir_mock.dirs, [(temp_dir_path, 0o700), (temp_dir_path, 0o700)])
-        write_local_file_mock.assert_called_with(key_path, 'ssh-key', mode=0o400)
+        write_local_file_mock.assert_called_with(key_path, 'ssh-key\n', mode=0o400)
 
     @mock.patch(
         'buildbot.util.private_tempdir.PrivateTemporaryDirectory',
@@ -1894,7 +1894,7 @@ def test_poll_initial_2_3(self, write_local_file_mock, temp_dir_mock):
 
         temp_dir_path = os.path.join('basedir', 'gitpoller-work', '.buildbot-ssh@@@')
         self.assertEqual(temp_dir_mock.dirs, [(temp_dir_path, 0o700), (temp_dir_path, 0o700)])
-        write_local_file_mock.assert_called_with(key_path, 'ssh-key', mode=0o400)
+        write_local_file_mock.assert_called_with(key_path, 'ssh-key\n', mode=0o400)
 
     @mock.patch(
         'buildbot.util.private_tempdir.PrivateTemporaryDirectory',
@@ -1937,7 +1937,7 @@ def test_poll_failFetch_git_2_10(self, write_local_file_mock, temp_dir_mock):
 
         temp_dir_path = os.path.join('basedir', 'gitpoller-work', '.buildbot-ssh@@@')
         self.assertEqual(temp_dir_mock.dirs, [(temp_dir_path, 0o700), (temp_dir_path, 0o700)])
-        write_local_file_mock.assert_called_with(key_path, 'ssh-key', mode=0o400)
+        write_local_file_mock.assert_called_with(key_path, 'ssh-key\n', mode=0o400)
 
 
 class TestGitPollerWithSshHostKey(TestGitPollerBase):
@@ -2004,9 +2004,9 @@ def test_poll_initial_2_10(self, write_local_file_mock, temp_dir_mock):
         self.assertEqual(temp_dir_mock.dirs, [(temp_dir_path, 0o700), (temp_dir_path, 0o700)])
 
         expected_file_writes = [
-            mock.call(key_path, 'ssh-key', mode=0o400),
+            mock.call(key_path, 'ssh-key\n', mode=0o400),
             mock.call(known_hosts_path, '* ssh-host-key'),
-            mock.call(key_path, 'ssh-key', mode=0o400),
+            mock.call(key_path, 'ssh-key\n', mode=0o400),
             mock.call(known_hosts_path, '* ssh-host-key'),
         ]
 
@@ -2016,7 +2016,7 @@ def test_poll_initial_2_10(self, write_local_file_mock, temp_dir_mock):
 class TestGitPollerWithSshKnownHosts(TestGitPollerBase):
     def createPoller(self):
         return gitpoller.GitPoller(
-            self.REPOURL, sshPrivateKey='ssh-key', sshKnownHosts='ssh-known-hosts'
+            self.REPOURL, sshPrivateKey='ssh-key\n', sshKnownHosts='ssh-known-hosts'
         )
 
     @mock.patch(
@@ -2079,9 +2079,9 @@ def test_poll_initial_2_10(self, write_local_file_mock, temp_dir_mock):
         self.assertEqual(temp_dir_mock.dirs, [(temp_dir_path, 0o700), (temp_dir_path, 0o700)])
 
         expected_file_writes = [
-            mock.call(key_path, 'ssh-key', mode=0o400),
+            mock.call(key_path, 'ssh-key\n', mode=0o400),
             mock.call(known_hosts_path, 'ssh-known-hosts'),
-            mock.call(key_path, 'ssh-key', mode=0o400),
+            mock.call(key_path, 'ssh-key\n', mode=0o400),
             mock.call(known_hosts_path, 'ssh-known-hosts'),
         ]