diff --git a/README.md b/README.md index fe0dfe7cf..75c4619a3 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,41 @@ The `--gitlab-server-url` flag accepts both GitLab.com (`https://gitlab.com`) an 5. The `migrate.ps1` script requires PowerShell to run. If not already installed see the [install instructions](https://docs.microsoft.com/en-us/powershell/scripting/install/installing-powershell?view=powershell-7.2) to install PowerShell on Windows, Linux, or Mac. Then run the script. +### GitLab export diagnostics + +GitLab-to-GitHub migration does not require an administrator SSH key. If an export fails before a GitHub migration ID is created, collect API-visible diagnostics with your `GITLAB_PAT`: + +```bash +gh gl2gh diagnose-gitlab-export \ + --gitlab-server-url https://gitlab.example.com \ + --gitlab-group parent/group --gitlab-project project \ + --output diagnostics.md +``` + +Use the same GitLab user that initiated the export: the export API status is user-specific. No GitHub PAT or migration ID is required for this command. + +For self-managed GitLab, optionally add administrator SSH access to collect actual export job/child job IDs, retained errors and matching server log entries: + +```bash +gh gl2gh diagnose-gitlab-export \ + --gitlab-server-url https://gitlab.example.com \ + --gitlab-group parent/group --gitlab-project project \ + --output diagnostics.md \ + --ssh-host gitlab-admin.example.com --ssh-user admin \ + --ssh-key /path/to/private-key --ssh-port 22 +``` + +If GitLab runs in Docker on that SSH host, add `--gitlab-container gitlab`. Omit it if SSH already lands inside the GitLab container. SSH must reach the **OS administrator shell**, not GitLab's Git-over-SSH endpoint. + +- Install the OpenSSH client (`ssh`) on the machine running the CLI. Verify the server's host key through a trusted channel and add it to your OpenSSH `known_hosts` before running the command. Unknown or changed keys are rejected; host verification is never disabled. +- Supply `--ssh-host`, `--ssh-user` and `--ssh-key` together. Encrypted keys must already be unlocked in `ssh-agent`; SSH password/passphrase prompts are disabled. +- The account must be root or have non-interactive `sudo` access to `gitlab-rails` (or `docker exec` for container installations). Rails runner executes an administrator script and Docker access is effectively root access; use an appropriately authorized account. +- Collection is read-only: it does not start/retry exports or change GitLab settings. It collects the latest 10 export jobs across users, up to 100 relations per job, and the last 8 MiB/100 matching entries of each current `exporter.log`, Sidekiq, `exceptions_json.log` and `api_json.log`. Strings and backtraces are abbreviated. Missing files, unavailable version-specific records and truncation appear as warnings in the report. +- Collection targets Linux-package GitLab installations, directly or inside Docker, with a five-minute SSH limit. Rotated logs, other worker nodes and centralized/Kubernetes logging are not collected automatically; use the administrator follow-up instructions when the report is incomplete. +- If SSH collection fails, the command exits with an error and preserves the API-only report. Use `--overwrite` to replace an existing report. + +**Treat reports and verbose CLI logs as sensitive.** Server messages can include customer data, internal paths and credentials. Collection limits which log fields are retained, but does not guarantee secret redaction. Reports are written with owner-only permissions on Linux/macOS; on Windows, secure the output directory with appropriate ACLs. Store all files securely and review/redact them before sharing. The private SSH key stays on the client; only its file path is passed to OpenSSH. + ### Skipping version checks When the CLI is launched, it logs if a newer version of the CLI is available. You can skip this check by setting the `GEI_SKIP_VERSION_CHECK` environment variable to `true`. diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 8b1378917..33460d1f1 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -1 +1 @@ - +- GitLab: Added `diagnose-gitlab-export` to collect export status and project statistics, with optional administrator SSH access to retrieve server-side export errors and logs from Linux-package or Docker installations. diff --git a/src/Octoshift/Services/FileSystemProvider.cs b/src/Octoshift/Services/FileSystemProvider.cs index 21e9de300..2db4e8e39 100644 --- a/src/Octoshift/Services/FileSystemProvider.cs +++ b/src/Octoshift/Services/FileSystemProvider.cs @@ -21,6 +21,22 @@ public class FileSystemProvider public virtual async Task WriteAllTextAsync(string path, string contents) => await File.WriteAllTextAsync(path, contents); + public virtual async Task WritePrivateTextAsync(string path, string contents) + { + var options = new FileStreamOptions { Mode = FileMode.Create, Access = FileAccess.Write }; + if (!OperatingSystem.IsWindows()) + { + options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + } + await using var stream = new FileStream(path, options); + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(stream.SafeFileHandle, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + await using var writer = new StreamWriter(stream); + await writer.WriteAsync(contents); + } + public virtual async ValueTask WriteAsync(FileStream fileStream, ReadOnlyMemory buffer, CancellationToken cancellationToken = default) { if (fileStream is null) diff --git a/src/Octoshift/Services/GitlabApi.cs b/src/Octoshift/Services/GitlabApi.cs index ef667625f..cf85719b8 100644 --- a/src/Octoshift/Services/GitlabApi.cs +++ b/src/Octoshift/Services/GitlabApi.cs @@ -65,6 +65,41 @@ public virtual async Task StartExport(string groupPath, string projectPa ); } + public virtual async Task GetExportDetails(string groupPath, string projectPath) + { + var encodedProjectPath = GetEncodedProjectPath(groupPath, projectPath); + var url = $"{_gitlabBaseUrl}/api/v4/projects/{encodedProjectPath}/export"; + + var exportResponse = await _client.GetAsync(url); + var exportData = JObject.Parse(exportResponse); + + return new GitlabExportDetails( + (long?)exportData["id"], + (string)exportData["export_status"], + (string)exportData["_links"]?["api_url"], + exportData.ToString()); + } + + public virtual async Task GetProjectDetails(string groupPath, string projectPath) + { + var encodedProjectPath = GetEncodedProjectPath(groupPath, projectPath); + var url = $"{_gitlabBaseUrl}/api/v4/projects/{encodedProjectPath}?statistics=true"; + + var projectResponse = await _client.GetAsync(url); + var projectData = JObject.Parse(projectResponse); + var projectStatistics = (JObject)projectData["statistics"]; + + return new GitlabProjectDetails( + (long?)projectData["id"], + (string)projectData["path_with_namespace"], + (string)projectData["web_url"], + (bool?)projectData["archived"], + (string)projectData["visibility"], + (long?)projectStatistics?["repository_size"], + (long?)projectStatistics?["uploads_size"], + (long?)projectStatistics?["job_artifacts_size"]); + } + public virtual async Task DownloadExportArchive(string groupPath, string projectPath, string file) { var encodedProjectPath = GetEncodedProjectPath(groupPath, projectPath); @@ -168,3 +203,15 @@ private static string GetEncodedProjectPath(string groupPath, string projectPath return pathWithNamespace.EscapeDataString(); } } + +public record GitlabExportDetails(long? Id, string ExportStatus, string DownloadUrl, string RawJson); + +public record GitlabProjectDetails( + long? Id, + string PathWithNamespace, + string WebUrl, + bool? Archived, + string Visibility, + long? RepositorySize, + long? UploadsSize, + long? JobArtifactsSize); diff --git a/src/OctoshiftCLI.Tests/gl2gh/Commands/DiagnoseGitlabExport/DiagnoseGitlabExportCommandArgsTests.cs b/src/OctoshiftCLI.Tests/gl2gh/Commands/DiagnoseGitlabExport/DiagnoseGitlabExportCommandArgsTests.cs new file mode 100644 index 000000000..63cfefaad --- /dev/null +++ b/src/OctoshiftCLI.Tests/gl2gh/Commands/DiagnoseGitlabExport/DiagnoseGitlabExportCommandArgsTests.cs @@ -0,0 +1,28 @@ +using FluentAssertions; +using OctoshiftCLI.GitlabToGithub.Commands.DiagnoseGitlabExport; +using OctoshiftCLI.Services; +using Xunit; + +namespace OctoshiftCLI.Tests.GitlabToGithub.Commands.DiagnoseGitlabExport; + +public class DiagnoseGitlabExportCommandArgsTests +{ + private readonly OctoLogger _log = new(); + + [Theory] + [InlineData(null, "group", "project", "--gitlab-server-url must be provided.")] + [InlineData("https://gitlab.contoso.com", null, "project", "--gitlab-group must be provided.")] + [InlineData("https://gitlab.contoso.com", "group", null, "--gitlab-project must be provided.")] + public void Validate_Requires_Gitlab_Project_Inputs(string gitlabServerUrl, string gitlabGroup, string gitlabProject, string expectedMessage) + { + var args = new DiagnoseGitlabExportCommandArgs + { + GitlabServerUrl = gitlabServerUrl, + GitlabGroup = gitlabGroup, + GitlabProject = gitlabProject + }; + + var ex = Assert.Throws(() => args.Validate(_log)); + ex.Message.Should().Be(expectedMessage); + } +} diff --git a/src/OctoshiftCLI.Tests/gl2gh/Commands/DiagnoseGitlabExport/DiagnoseGitlabExportCommandHandlerTests.cs b/src/OctoshiftCLI.Tests/gl2gh/Commands/DiagnoseGitlabExport/DiagnoseGitlabExportCommandHandlerTests.cs new file mode 100644 index 000000000..db55e1a84 --- /dev/null +++ b/src/OctoshiftCLI.Tests/gl2gh/Commands/DiagnoseGitlabExport/DiagnoseGitlabExportCommandHandlerTests.cs @@ -0,0 +1,125 @@ +using System.Threading.Tasks; +using Moq; +using OctoshiftCLI.GitlabToGithub.Commands.DiagnoseGitlabExport; +using OctoshiftCLI.GitlabToGithub.Services; +using OctoshiftCLI.Services; +using Xunit; + +namespace OctoshiftCLI.Tests.GitlabToGithub.Commands.DiagnoseGitlabExport; + +public class DiagnoseGitlabExportCommandHandlerTests +{ + private readonly Mock _mockOctoLogger = TestHelpers.CreateMock(); + private readonly Mock _mockGitlabApi = TestHelpers.CreateMock(); + private readonly Mock _mockFileSystemProvider = TestHelpers.CreateMock(); + private readonly Mock _mockSshCollector = new(); + + private readonly DiagnoseGitlabExportCommandHandler _handler; + + public DiagnoseGitlabExportCommandHandlerTests() + { + _handler = new DiagnoseGitlabExportCommandHandler(_mockOctoLogger.Object, _mockGitlabApi.Object, _mockFileSystemProvider.Object, _mockSshCollector.Object); + } + + [Fact] + public async Task Handle_Writes_Report_With_Export_Status_And_Gitlab_Admin_Commands() + { + var args = new DiagnoseGitlabExportCommandArgs + { + GitlabServerUrl = "https://gitlab.contoso.com", + GitlabGroup = "parent/group", + GitlabProject = "project", + Output = "diagnostics.md" + }; + string report = null; + + _mockGitlabApi.Setup(m => m.GetServerVersion()).ReturnsAsync(("18.11.0-ee", true)); + _mockGitlabApi.Setup(m => m.GetProjectDetails("parent/group", "project")) + .ReturnsAsync(new GitlabProjectDetails(123, "parent/group/project", "https://gitlab.contoso.com/parent/group/project", false, "private", 42, 43, 44)); + _mockGitlabApi.Setup(m => m.GetExportDetails("parent/group", "project")) + .ReturnsAsync(new GitlabExportDetails(123, "failed", null, "{\"export_status\":\"failed\"}")); + _mockFileSystemProvider.Setup(m => m.WritePrivateTextAsync("diagnostics.md", It.IsAny())) + .Callback((_, contents) => report = contents) + .Returns(Task.CompletedTask); + + await _handler.Handle(args); + + Assert.Contains("Export status: failed", report); + Assert.Contains("p.export_jobs", report); + Assert.DoesNotContain("p.import_state", report); + Assert.DoesNotContain("Export ID:", report); + Assert.Contains("same user that initiated", report); + Assert.Contains("/var/log/gitlab/sidekiq/current", report); + Assert.Contains("/var/log/gitlab/gitlab-rails/exporter.log", report); + _mockSshCollector.Verify(m => m.Collect(It.IsAny()), Times.Never); + _mockOctoLogger.Verify(m => m.LogWarning(It.Is(s => s.Contains("GitLab reported the project export as failed"))), Times.Once); + _mockOctoLogger.Verify(m => m.LogSuccess("Wrote GitLab export diagnostics to diagnostics.md."), Times.Once); + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + public async Task Handle_Collects_Ssh_Only_When_Requested_And_Preserves_Api_Report_On_Failure(bool fail, bool wrongProject) + { + var args = new DiagnoseGitlabExportCommandArgs + { + GitlabServerUrl = "http://gitlab", + GitlabGroup = "group", + GitlabProject = "project", + Output = "diagnostics.md", + SshHost = "admin-host" + }; + _mockGitlabApi.Setup(m => m.GetServerVersion()).ReturnsAsync(("18.3.1", false)); + _mockGitlabApi.Setup(m => m.GetProjectDetails("group", "project")) + .ReturnsAsync(new GitlabProjectDetails(1, "group/project", "http://gitlab/group/project", false, "private", 1, 0, 0)); + _mockGitlabApi.Setup(m => m.GetExportDetails("group", "project")) + .ReturnsAsync(new GitlabExportDetails(1, "failed", null, "{\"export_status\":\"failed\"}")); + string report = null; + _mockFileSystemProvider.Setup(m => m.WritePrivateTextAsync(args.Output, It.IsAny())) + .Callback((_, contents) => report = contents).Returns(Task.CompletedTask); + if (fail) + { + _mockSshCollector.Setup(m => m.Collect(args)).ThrowsAsync(new OctoshiftCliException("SSH failed")); + } + else + { + _mockSshCollector.Setup(m => m.Collect(args)) + .ReturnsAsync($"{{\"project_id\":{(wrongProject ? 2 : 1)},\"warnings\":[\"log missing\"],\"error\":\"Permission denied ```\"}}"); + } + if (fail || wrongProject) + { + await Assert.ThrowsAsync(() => _handler.Handle(args)); + Assert.Contains("Collection failed", report); + _mockOctoLogger.Verify(m => m.LogSuccess(It.IsAny()), Times.Never); + } + else + { + await _handler.Handle(args); + Assert.Contains("Permission denied \\u0060\\u0060\\u0060", report); + _mockOctoLogger.Verify(m => m.LogWarning(It.Is(s => s.Contains("collection warnings"))), Times.Once); + } + Assert.Contains("Export status: failed", report); + Assert.Contains("## Server-side diagnostics (SSH)", report); + _mockSshCollector.Verify(m => m.Collect(args), Times.Once); + _mockFileSystemProvider.Verify(m => m.WritePrivateTextAsync(args.Output, It.IsAny()), Times.Exactly(2)); + } + + [Fact] + public async Task Handle_Throws_When_Output_Exists_Without_Overwrite() + { + var args = new DiagnoseGitlabExportCommandArgs + { + GitlabServerUrl = "https://gitlab.contoso.com", + GitlabGroup = "parent/group", + GitlabProject = "project", + Output = "diagnostics.md" + }; + + _mockFileSystemProvider.Setup(m => m.FileExists("diagnostics.md")).Returns(true); + + var ex = await Assert.ThrowsAsync(() => _handler.Handle(args)); + + Assert.Equal("File diagnostics.md already exists! Use --overwrite to overwrite this file.", ex.Message); + } +} diff --git a/src/OctoshiftCLI.Tests/gl2gh/Commands/DiagnoseGitlabExport/DiagnoseGitlabExportCommandTests.cs b/src/OctoshiftCLI.Tests/gl2gh/Commands/DiagnoseGitlabExport/DiagnoseGitlabExportCommandTests.cs new file mode 100644 index 000000000..4f902ed46 --- /dev/null +++ b/src/OctoshiftCLI.Tests/gl2gh/Commands/DiagnoseGitlabExport/DiagnoseGitlabExportCommandTests.cs @@ -0,0 +1,68 @@ +using System; +using FluentAssertions; +using Moq; +using OctoshiftCLI.GitlabToGithub.Commands.DiagnoseGitlabExport; +using OctoshiftCLI.GitlabToGithub.Factories; +using OctoshiftCLI.GitlabToGithub.Services; +using OctoshiftCLI.Services; +using Xunit; + +namespace OctoshiftCLI.Tests.GitlabToGithub.Commands.DiagnoseGitlabExport; + +public class DiagnoseGitlabExportCommandTests +{ + private const string GITLAB_SERVER_URL = "https://gitlab.contoso.com"; + private const string GITLAB_PAT = "gitlab-pat"; + + private readonly Mock _mockServiceProvider = new(); + private readonly Mock _mockGitlabApiFactory = TestHelpers.CreateMock(); + private readonly Mock _mockOctoLogger = TestHelpers.CreateMock(); + private readonly Mock _mockFileSystemProvider = TestHelpers.CreateMock(); + + private readonly DiagnoseGitlabExportCommand _command = []; + + public DiagnoseGitlabExportCommandTests() + { + _mockServiceProvider.Setup(m => m.GetService(typeof(OctoLogger))).Returns(_mockOctoLogger.Object); + _mockServiceProvider.Setup(m => m.GetService(typeof(GitlabApiFactory))).Returns(_mockGitlabApiFactory.Object); + _mockServiceProvider.Setup(m => m.GetService(typeof(FileSystemProvider))).Returns(_mockFileSystemProvider.Object); + _mockServiceProvider.Setup(m => m.GetService(typeof(GitlabSshDiagnosticsCollector))).Returns(new GitlabSshDiagnosticsCollector()); + } + + [Fact] + public void Should_Have_Options() + { + _command.Should().NotBeNull(); + _command.Name.Should().Be("diagnose-gitlab-export"); + _command.Options.Count.Should().Be(13); + + TestHelpers.VerifyCommandOption(_command.Options, "gitlab-server-url", false); + TestHelpers.VerifyCommandOption(_command.Options, "gitlab-group", false); + TestHelpers.VerifyCommandOption(_command.Options, "gitlab-project", false); + TestHelpers.VerifyCommandOption(_command.Options, "gitlab-pat", false); + TestHelpers.VerifyCommandOption(_command.Options, "output", false); + TestHelpers.VerifyCommandOption(_command.Options, "overwrite", false); + TestHelpers.VerifyCommandOption(_command.Options, "no-ssl-verify", false); + TestHelpers.VerifyCommandOption(_command.Options, "verbose", false); + TestHelpers.VerifyCommandOption(_command.Options, "ssh-host", false); + TestHelpers.VerifyCommandOption(_command.Options, "ssh-user", false); + TestHelpers.VerifyCommandOption(_command.Options, "ssh-key", false); + TestHelpers.VerifyCommandOption(_command.Options, "ssh-port", false); + TestHelpers.VerifyCommandOption(_command.Options, "gitlab-container", false); + } + + [Fact] + public void It_Creates_The_GitlabApi_With_The_Provided_Server_Url_And_Pat() + { + var args = new DiagnoseGitlabExportCommandArgs + { + GitlabServerUrl = GITLAB_SERVER_URL, + GitlabPat = GITLAB_PAT, + NoSslVerify = true + }; + + _command.BuildHandler(args, _mockServiceProvider.Object); + + _mockGitlabApiFactory.Verify(m => m.Create(GITLAB_SERVER_URL, GITLAB_PAT, true)); + } +} diff --git a/src/OctoshiftCLI.Tests/gl2gh/Services/GitlabSshDiagnosticsCollectorTests.cs b/src/OctoshiftCLI.Tests/gl2gh/Services/GitlabSshDiagnosticsCollectorTests.cs new file mode 100644 index 000000000..da387e997 --- /dev/null +++ b/src/OctoshiftCLI.Tests/gl2gh/Services/GitlabSshDiagnosticsCollectorTests.cs @@ -0,0 +1,157 @@ +using System; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using OctoshiftCLI.GitlabToGithub.Commands.DiagnoseGitlabExport; +using OctoshiftCLI.GitlabToGithub.Services; +using OctoshiftCLI.Services; +using Xunit; + +namespace OctoshiftCLI.Tests.GitlabToGithub.Services; + +public sealed class GitlabSshDiagnosticsCollectorTests : IDisposable +{ + private readonly string _key = Path.GetTempFileName(); + + private DiagnoseGitlabExportCommandArgs Args() => new() + { + GitlabServerUrl = "http://gitlab", + GitlabGroup = "parent/group", + GitlabProject = "project", + SshHost = "gitlab-admin", + SshUser = "admin", + SshKey = _key + }; + + [Theory] + [InlineData("gitlab-admin")] + [InlineData("127.0.0.1")] + [InlineData("::1")] + public void Validates_Ssh_Options(string host) + { + var args = Args(); + args.SshHost = host; + args.Validate(new OctoLogger()); + } + + [Theory] + [InlineData("host")] + [InlineData("user")] + [InlineData("key")] + public void Requires_All_Ssh_Inputs(string missing) + { + var args = Args(); + if (missing == "host") + { + args.SshHost = null; + } + + if (missing == "user") + { + args.SshUser = null; + } + + if (missing == "key") + { + args.SshKey = null; + } + + Assert.Throws(() => args.Validate(new OctoLogger())); + } + + [Theory] + [InlineData("-oProxyCommand=bad", "admin", "gitlab", 22)] + [InlineData("host;bad", "admin", "gitlab", 22)] + [InlineData("host\nbad", "admin", "gitlab", 22)] + [InlineData("host", "root;bad", "gitlab", 22)] + [InlineData("host", "admin", "gitlab';bad", 22)] + [InlineData("host", "admin", "-bad", 22)] + [InlineData("host", "admin", "", 22)] + [InlineData("host", "admin", "gitlab", 0)] + [InlineData("host", "admin", "gitlab", 65536)] + public void Rejects_Unsafe_Or_Invalid_Inputs(string host, string user, string container, int port) + { + var args = Args(); + args.SshHost = host; + args.SshUser = user; + args.GitlabContainer = container; + args.SshPort = port; + Assert.Throws(() => args.Validate(new OctoLogger())); + } + + [Fact] + public void Rejects_Missing_Key_File() + { + var args = Args(); + args.SshKey = Path.Combine(_key, "missing"); + Assert.Throws(() => args.Validate(new OctoLogger())); + } + + [Theory] + [InlineData(null, "gitlab-rails runner -")] + [InlineData("gitlab-ce", "docker exec -i -- 'gitlab-ce' gitlab-rails runner -")] + public void Uses_Strict_OpenSsh_With_No_Local_Shell(string container, string remote) + { + var args = Args(); + args.GitlabContainer = container; + args.SshPort = 2222; + args.SshKey = "/path with spaces/key"; + var start = GitlabSshDiagnosticsCollector.BuildStartInfo(args); + Assert.Equal("ssh", start.FileName); + Assert.False(start.UseShellExecute); + Assert.True(start.RedirectStandardInput); + Assert.Contains("StrictHostKeyChecking=yes", start.ArgumentList); + Assert.Contains("BatchMode=yes", start.ArgumentList); + Assert.Contains("IdentitiesOnly=yes", start.ArgumentList); + Assert.Contains("ForwardAgent=no", start.ArgumentList); + Assert.Contains("/path with spaces/key", start.ArgumentList); + Assert.Contains("2222", start.ArgumentList); + Assert.Equal($"if [ \"$(id -u)\" -eq 0 ]; then {remote}; else sudo -n {remote}; fi", start.ArgumentList.Last()); + } + + [Fact] + public void Encodes_Project_Path_As_Data_Not_Ruby_Code() + { + var args = Args(); + args.GitlabProject = "quotes'\"$()```"; + var encoded = GitlabSshDiagnosticsCollector.EncodeProjectPath(args); + Assert.Equal($"{args.GitlabGroup}/{args.GitlabProject}", Encoding.UTF8.GetString(Convert.FromBase64String(encoded))); + Assert.DoesNotContain("'", encoded); + Assert.DoesNotContain("$", encoded); + } + + [Fact] + public async Task Bounds_Ssh_Output() + { + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(new string('x', (4 * 1024 * 1024) + 1))); + using var reader = new StreamReader(stream); + await Assert.ThrowsAsync(() => GitlabSshDiagnosticsCollector.ReadBounded(reader, CancellationToken.None)); + } + + [Fact] + public async Task Reads_Ssh_Output() + { + using var stream = new MemoryStream(Encoding.UTF8.GetBytes("{\"logs\":[]}")); + using var reader = new StreamReader(stream); + Assert.Equal("{\"logs\":[]}", await GitlabSshDiagnosticsCollector.ReadBounded(reader, CancellationToken.None)); + } + + [Fact] + public async Task Writes_Private_Report_Including_When_Overwriting() + { + var provider = new FileSystemProvider(); + await provider.WritePrivateTextAsync(_key, "diagnostics"); + Assert.Equal("diagnostics", await File.ReadAllTextAsync(_key)); + if (!OperatingSystem.IsWindows()) + { + Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite, File.GetUnixFileMode(_key)); + File.SetUnixFileMode(_key, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.OtherRead); + await provider.WritePrivateTextAsync(_key, "updated"); + Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite, File.GetUnixFileMode(_key)); + } + } + + public void Dispose() => File.Delete(_key); +} diff --git a/src/gl2gh/Commands/DiagnoseGitlabExport/DiagnoseGitlabExportCommand.cs b/src/gl2gh/Commands/DiagnoseGitlabExport/DiagnoseGitlabExportCommand.cs new file mode 100644 index 000000000..c2caf7af0 --- /dev/null +++ b/src/gl2gh/Commands/DiagnoseGitlabExport/DiagnoseGitlabExportCommand.cs @@ -0,0 +1,87 @@ +using System; +using System.CommandLine; +using Microsoft.Extensions.DependencyInjection; +using OctoshiftCLI.Commands; +using OctoshiftCLI.GitlabToGithub.Factories; +using OctoshiftCLI.GitlabToGithub.Services; +using OctoshiftCLI.Services; + +namespace OctoshiftCLI.GitlabToGithub.Commands.DiagnoseGitlabExport; + +public class DiagnoseGitlabExportCommand : CommandBase +{ + public DiagnoseGitlabExportCommand() : base( + name: "diagnose-gitlab-export", + description: "Collects GitLab project export diagnostics, optionally including server-side errors over administrator SSH.") + { + AddOption(GitlabServerUrl); + AddOption(GitlabGroup); + AddOption(GitlabProject); + AddOption(GitlabPat); + AddOption(Output); + AddOption(Overwrite); + AddOption(NoSslVerify); + AddOption(Verbose); + AddOption(SshHost); + AddOption(SshUser); + AddOption(SshKey); + AddOption(SshPort); + AddOption(GitlabContainer); + } + + public Option GitlabServerUrl { get; } = new( + name: "--gitlab-server-url", + description: "The full URL of the GitLab server, e.g. https://gitlab.mycompany.com"); + + public Option GitlabGroup { get; } = new( + name: "--gitlab-group", + description: "The GitLab group (full namespace path) that contains the project."); + + public Option GitlabProject { get; } = new( + name: "--gitlab-project", + description: "The GitLab project to diagnose."); + + public Option GitlabPat { get; } = new( + name: "--gitlab-pat", + description: "The GitLab PAT. If not passed, it will read the PAT from the GITLAB_PAT environment variable."); + + public Option Output { get; } = new( + name: "--output", + description: "Local Markdown file to write diagnostics to."); + + public Option Overwrite { get; } = new( + name: "--overwrite", + description: "Overwrite the output file if it exists."); + + public Option NoSslVerify { get; } = new( + name: "--no-ssl-verify", + description: "Disables SSL verification when communicating with your GitLab instance."); + + public Option Verbose { get; } = new("--verbose"); + + public Option SshHost { get; } = new("--ssh-host", "Optional GitLab OS administrator SSH host. Enables server-side diagnostics."); + public Option SshUser { get; } = new("--ssh-user", "OS account with root or non-interactive sudo access, not a GitLab Git-over-SSH user."); + public Option SshKey { get; } = new("--ssh-key", "Path to the administrator's private SSH key. Encrypted keys must be unlocked in ssh-agent."); + public Option SshPort { get; } = new("--ssh-port", () => 22, "Administrator SSH port."); + public Option GitlabContainer { get; } = new("--gitlab-container", "Optional Docker container name on the SSH host containing GitLab."); + + public override DiagnoseGitlabExportCommandHandler BuildHandler(DiagnoseGitlabExportCommandArgs args, IServiceProvider sp) + { + if (args is null) + { + throw new ArgumentNullException(nameof(args)); + } + + if (sp is null) + { + throw new ArgumentNullException(nameof(sp)); + } + + var log = sp.GetRequiredService(); + var gitlabApiFactory = sp.GetRequiredService(); + var gitlabApi = gitlabApiFactory.Create(args.GitlabServerUrl, args.GitlabPat, args.NoSslVerify); + var fileSystemProvider = sp.GetRequiredService(); + + return new DiagnoseGitlabExportCommandHandler(log, gitlabApi, fileSystemProvider, sp.GetRequiredService()); + } +} diff --git a/src/gl2gh/Commands/DiagnoseGitlabExport/DiagnoseGitlabExportCommandArgs.cs b/src/gl2gh/Commands/DiagnoseGitlabExport/DiagnoseGitlabExportCommandArgs.cs new file mode 100644 index 000000000..a07e6e564 --- /dev/null +++ b/src/gl2gh/Commands/DiagnoseGitlabExport/DiagnoseGitlabExportCommandArgs.cs @@ -0,0 +1,79 @@ +using System.IO; +using System.Net; +using System.Text.RegularExpressions; +using OctoshiftCLI.Commands; +using OctoshiftCLI.Extensions; +using OctoshiftCLI.Services; + +namespace OctoshiftCLI.GitlabToGithub.Commands.DiagnoseGitlabExport; + +public class DiagnoseGitlabExportCommandArgs : CommandArgs +{ + public string GitlabServerUrl { get; set; } + public string GitlabGroup { get; set; } + public string GitlabProject { get; set; } + [Secret] + public string GitlabPat { get; set; } + public string Output { get; set; } + public bool Overwrite { get; set; } + public bool NoSslVerify { get; set; } + public string SshHost { get; set; } + public string SshUser { get; set; } + public string SshKey { get; set; } + public int SshPort { get; set; } = 22; + public string GitlabContainer { get; set; } + + public override void Validate(OctoLogger log) + { + if (GitlabServerUrl.IsNullOrWhiteSpace()) + { + throw new OctoshiftCliException("--gitlab-server-url must be provided."); + } + + if (GitlabGroup.IsNullOrWhiteSpace()) + { + throw new OctoshiftCliException("--gitlab-group must be provided."); + } + + if (GitlabProject.IsNullOrWhiteSpace()) + { + throw new OctoshiftCliException("--gitlab-project must be provided."); + } + + if (Output.HasValue() && Directory.Exists(Output)) + { + throw new OctoshiftCliException("--output must be a file path, not a directory."); + } + + if (SshHost is null && SshUser is null && SshKey is null && GitlabContainer is null && SshPort == 22) + { + return; + } + + if (string.IsNullOrWhiteSpace(SshHost) || string.IsNullOrWhiteSpace(SshUser) || string.IsNullOrWhiteSpace(SshKey)) + { + throw new OctoshiftCliException("--ssh-host, --ssh-user and --ssh-key must be provided together."); + } + + if ((!IPAddress.TryParse(SshHost, out _) && !Regex.IsMatch(SshHost, @"\A[a-zA-Z0-9][a-zA-Z0-9._-]*\z")) || + !Regex.IsMatch(SshUser, @"\A[a-zA-Z0-9_][a-zA-Z0-9_.-]*\z")) + { + throw new OctoshiftCliException("--ssh-host must be a hostname or IP address, and --ssh-user must be an OS account name."); + } + + if (SshPort is < 1 or > 65535) + { + throw new OctoshiftCliException("--ssh-port must be between 1 and 65535."); + } + + if (!File.Exists(SshKey)) + { + throw new OctoshiftCliException("--ssh-key must point to an existing private key file."); + } + + if (GitlabContainer is not null && !Regex.IsMatch(GitlabContainer, @"\A[a-zA-Z0-9][a-zA-Z0-9_.-]*\z")) + { + throw new OctoshiftCliException("--gitlab-container must be a Docker container name or ID."); + } + } +} diff --git a/src/gl2gh/Commands/DiagnoseGitlabExport/DiagnoseGitlabExportCommandHandler.cs b/src/gl2gh/Commands/DiagnoseGitlabExport/DiagnoseGitlabExportCommandHandler.cs new file mode 100644 index 000000000..e84e68856 --- /dev/null +++ b/src/gl2gh/Commands/DiagnoseGitlabExport/DiagnoseGitlabExportCommandHandler.cs @@ -0,0 +1,143 @@ +using System; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using OctoshiftCLI.Commands; +using OctoshiftCLI.Extensions; +using OctoshiftCLI.GitlabToGithub.Services; +using OctoshiftCLI.Services; + +namespace OctoshiftCLI.GitlabToGithub.Commands.DiagnoseGitlabExport; + +public class DiagnoseGitlabExportCommandHandler : ICommandHandler +{ + private readonly OctoLogger _log; + private readonly GitlabApi _gitlabApi; + private readonly FileSystemProvider _fileSystemProvider; + private readonly GitlabSshDiagnosticsCollector _sshCollector; + + public DiagnoseGitlabExportCommandHandler(OctoLogger log, GitlabApi gitlabApi, FileSystemProvider fileSystemProvider, GitlabSshDiagnosticsCollector sshCollector) + { + _log = log; + _gitlabApi = gitlabApi; + _fileSystemProvider = fileSystemProvider; + _sshCollector = sshCollector; + } + + public async Task Handle(DiagnoseGitlabExportCommandArgs args) + { + if (args is null) + { + throw new ArgumentNullException(nameof(args)); + } + + var output = args.Output.HasValue() + ? args.Output + : $"gitlab-export-diagnostics-{SanitizeFileName(args.GitlabGroup)}-{SanitizeFileName(args.GitlabProject)}.md"; + + if (_fileSystemProvider.FileExists(output) && !args.Overwrite) + { + throw new OctoshiftCliException($"File {output} already exists! Use --overwrite to overwrite this file."); + } + + _log.LogInformation("Collecting GitLab export diagnostics..."); + + var (version, enterprise) = await _gitlabApi.GetServerVersion(); + var projectDetails = await _gitlabApi.GetProjectDetails(args.GitlabGroup, args.GitlabProject); + var exportDetails = await _gitlabApi.GetExportDetails(args.GitlabGroup, args.GitlabProject); + + var report = BuildReport(args, version, enterprise, projectDetails, exportDetails); + await _fileSystemProvider.WritePrivateTextAsync(output, report); + + if (args.SshHost.HasValue()) + { + var sshSummary = $"\n## Server-side diagnostics (SSH)\n\n- SSH host: {args.SshHost}:{args.SshPort}\n- SSH user: {args.SshUser}\n- GitLab container: {args.GitlabContainer ?? "(direct installation)"}\n"; + _log.LogWarning("Collecting administrator diagnostics over SSH. The report may contain sensitive project data, paths and error messages; review and redact it before sharing."); + try + { + var diagnostics = await _sshCollector.Collect(args); + var data = JObject.Parse(diagnostics); + if ((long?)data["project_id"] != projectDetails.Id) + { + throw new OctoshiftCliException("The SSH project ID does not match the GitLab API project ID. Verify that SSH reaches the intended GitLab installation."); + } + if (data["warnings"] is JArray { Count: > 0 }) + { + _log.LogWarning("Server-side diagnostics have collection warnings. See the warnings in the report for missing logs and collection limits."); + } + report += $"{sshSummary}\n```json\n{diagnostics.Replace("`", "\\u0060")}\n```\n"; + await _fileSystemProvider.WritePrivateTextAsync(output, report); + } + catch (OctoshiftCliException) + { + await _fileSystemProvider.WritePrivateTextAsync(output, report + sshSummary + "\nCollection failed. API-only evidence is preserved above; see the CLI error for details.\n"); + _log.LogWarning($"SSH collection failed. API-only diagnostics were preserved in {output}."); + throw; + } + } + + if (string.Equals(exportDetails.ExportStatus, "failed", StringComparison.OrdinalIgnoreCase)) + { + _log.LogWarning("GitLab reported the project export as failed before GitHub received an archive. Ask a GitLab administrator to inspect the project export job on the GitLab instance using the commands in the report."); + } + + _log.LogSuccess($"Wrote GitLab export diagnostics to {output}."); + } + + private static string BuildReport( + DiagnoseGitlabExportCommandArgs args, + string version, + bool enterprise, + GitlabProjectDetails projectDetails, + GitlabExportDetails exportDetails) + { + var projectPath = $"{args.GitlabGroup}/{args.GitlabProject}"; + var encodedProjectPath = GitlabSshDiagnosticsCollector.EncodeProjectPath(args); + var builder = new StringBuilder(); + + builder.AppendLine("# GitLab export diagnostics"); + builder.AppendLine(); + builder.AppendLine("## Summary"); + builder.AppendLine(); + builder.AppendLine($"- GitLab server: {args.GitlabServerUrl.TrimEnd('/')}"); + builder.AppendLine($"- GitLab version: {ValueOrUnknown(version)} ({(enterprise ? "Enterprise" : "Community")} Edition)"); + builder.AppendLine($"- Project path: {projectPath}"); + builder.AppendLine($"- Project ID: {ValueOrUnknown(projectDetails.Id)}"); + builder.AppendLine($"- Export status: {ValueOrUnknown(exportDetails.ExportStatus)}"); + builder.AppendLine("- API export status is scoped to the authenticated GitLab user. Use the same user that initiated the export; another user may see `none`."); + builder.AppendLine(); + builder.AppendLine("## Project details"); + builder.AppendLine(); + builder.AppendLine($"- Web URL: {ValueOrUnknown(projectDetails.WebUrl)}"); + builder.AppendLine($"- Visibility: {ValueOrUnknown(projectDetails.Visibility)}"); + builder.AppendLine($"- Archived: {ValueOrUnknown(projectDetails.Archived)}"); + builder.AppendLine($"- Repository size: {ValueOrUnknown(projectDetails.RepositorySize)} bytes"); + builder.AppendLine($"- Uploads size: {ValueOrUnknown(projectDetails.UploadsSize)} bytes"); + builder.AppendLine($"- Job artifacts size: {ValueOrUnknown(projectDetails.JobArtifactsSize)} bytes"); + builder.AppendLine(); + builder.AppendLine("## GitLab admin follow-up commands"); + builder.AppendLine(); + builder.AppendLine("Run these commands on the GitLab instance to retrieve the server-side export job error. GitLab does not expose these logs through the project export API."); + builder.AppendLine(); + builder.AppendLine("```bash"); + builder.AppendLine($"sudo gitlab-rails runner \"p = Project.find_by_full_path(Base64.decode64('{encodedProjectPath}')); puts p.export_jobs.order(created_at: :desc).limit(10).map {{ |j| j.attributes.slice('id', 'jid', 'status', 'user_id', 'created_at', 'updated_at').merge('relations' => j.relation_exports.limit(100).map {{ |r| r.attributes.slice('relation', 'jid', 'status', 'export_error') }}) }}.to_json\""); + builder.AppendLine("sudo grep -F '' /var/log/gitlab/sidekiq/current"); + builder.AppendLine("sudo tail -n 200 /var/log/gitlab/gitlab-rails/exporter.log"); + builder.AppendLine("sudo tail -n 200 /var/log/gitlab/gitlab-rails/exceptions_json.log"); + builder.AppendLine("```"); + builder.AppendLine("These Linux-package commands use export job records available on recent GitLab versions. For Docker, execute them inside the GitLab container. Correlate by project, export/child job IDs and attempt time; include rotated or centralized logs and other worker nodes when needed."); + builder.AppendLine(); + builder.AppendLine("## Raw GitLab export API response"); + builder.AppendLine(); + builder.AppendLine("```json"); + builder.AppendLine(exportDetails.RawJson.Replace("`", "\\u0060")); + builder.AppendLine("```"); + + return builder.ToString(); + } + + private static string SanitizeFileName(string value) => Regex.Replace(value, "[^A-Za-z0-9_.-]+", "-").Trim('-'); + + private static string ValueOrUnknown(object value) => value?.ToString() ?? "unknown"; +} diff --git a/src/gl2gh/Program.cs b/src/gl2gh/Program.cs index 1fa4fadbb..88846f412 100644 --- a/src/gl2gh/Program.cs +++ b/src/gl2gh/Program.cs @@ -12,6 +12,7 @@ using OctoshiftCLI.Extensions; using OctoshiftCLI.Factories; using OctoshiftCLI.GitlabToGithub.Factories; +using OctoshiftCLI.GitlabToGithub.Services; using OctoshiftCLI.Services; [assembly: InternalsVisibleTo("OctoshiftCLI.Tests")] @@ -45,6 +46,7 @@ public static async Task Main(string[] args) .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton(sp => sp.GetRequiredService()) diff --git a/src/gl2gh/Services/GitlabExportDiagnostics.rb b/src/gl2gh/Services/GitlabExportDiagnostics.rb new file mode 100644 index 000000000..053cee9c9 --- /dev/null +++ b/src/gl2gh/Services/GitlabExportDiagnostics.rb @@ -0,0 +1,104 @@ +require 'base64' +require 'json' +require 'time' +require 'socket' + +project_path = Base64.decode64('__PROJECT_PATH_BASE64__') +project = Project.find_by_full_path(project_path) +abort 'The project was not found on the SSH GitLab installation.' unless project + +warnings = [] +jids = [] +jobs = [] +if project.respond_to?(:export_jobs) + project.export_jobs.order(created_at: :desc).limit(10).each do |job| + details = job.attributes.slice('id', 'jid', 'status', 'user_id', 'created_at', 'updated_at') + jids << job.jid + if job.respond_to?(:relation_exports) + relations = job.relation_exports.order(id: :desc).limit(101).to_a + warnings << "Export job #{job.id}: only the latest 100 relations were collected." if relations.length > 100 + details['relations'] = relations.first(100).map do |relation| + jids << relation.jid + relation.attributes.slice('relation', 'jid', 'status', 'export_error') + end + else + warnings << "Export job #{job.id}: relation exports are unavailable on this GitLab version." + end + jobs << details + end + warnings << 'No retained export jobs were found. Older attempts may have expired.' if jobs.empty? +else + warnings << 'Export job records are unavailable on this GitLab version; inspect the matching logs.' +end +jids = jids.compact.reject(&:empty?) + +# Only current logs are scanned, with a bounded tail and a project/job allowlist. +paths = [ + '/var/log/gitlab/gitlab-rails/exporter.log', + '/var/log/gitlab/sidekiq/current', + '/var/log/gitlab/gitlab-rails/exceptions_json.log', + '/var/log/gitlab/gitlab-rails/api_json.log' +] +fields = %w[time severity message project_id project_path meta.project jid class + job_status retry_count correlation_id relation project_export_job_id + export_error exception.class exception.message exception.backtrace + error_class error_message error_backtrace] +logs = paths.map do |path| + entry = { path: path, matches: [], tail_truncated: false, matches_truncated: false, invalid_lines: 0 } + begin + File.open(path, 'rb') do |file| + size = file.stat.size + offset = [size - 8 * 1024 * 1024, 0].max + file.seek(offset) + file.gets if offset > 0 + entry[:tail_truncated] = offset > 0 + # A snapshot avoids following a busy log forever. + content = file.read([size - file.pos, 0].max) + content.each_line do |line| + begin + row = JSON.parse(line) + rescue JSON::ParserError + entry[:invalid_lines] += 1 + next + end + next unless row.is_a?(Hash) + next unless row['project_id'].to_s == project.id.to_s || + row['project_path'] == project.full_path || row['meta.project'] == project.full_path || + jids.include?(row['jid']) + + selected = row.slice(*fields).transform_values do |value| + if value.is_a?(Array) + value.first(20).map { |frame| frame.to_s[0, 256] } + else + value.to_s[0, 1024] + end + end + entry[:matches] << selected + if entry[:matches].length > 100 + entry[:matches].shift + entry[:matches_truncated] = true + end + end + end + rescue Errno::ENOENT, Errno::EACCES => error + entry[:error] = error.message + warnings << "#{path}: #{error.message}" + end + warnings << "#{path}: only the last 8 MiB were scanned." if entry[:tail_truncated] + warnings << "#{path}: only the last 100 matching entries were retained." if entry[:matches_truncated] + warnings << "#{path}: #{entry[:invalid_lines]} non-JSON lines could not be correlated." if entry[:invalid_lines] > 0 + entry +end +warnings << 'No matching log entries were found. Check the worker node and rotated or centralized logs.' if logs.all? { |log| log[:matches].empty? } + +puts JSON.generate({ + collected_at: Time.now.utc.iso8601, + hostname: Socket.gethostname, + gitlab_version: Gitlab::VERSION, + project_id: project.id, + project_path: project.full_path, + scope: 'Latest 10 export jobs (all users); latest 100 relations per job; last 8 MiB and 100 matches per current log. Rotated logs are not included. Log strings/backtraces are abbreviated.', + warnings: warnings, + export_jobs: jobs, + logs: logs +}) diff --git a/src/gl2gh/Services/GitlabSshDiagnosticsCollector.cs b/src/gl2gh/Services/GitlabSshDiagnosticsCollector.cs new file mode 100644 index 000000000..42b59cbaf --- /dev/null +++ b/src/gl2gh/Services/GitlabSshDiagnosticsCollector.cs @@ -0,0 +1,151 @@ +using System; +using System.ComponentModel; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using OctoshiftCLI.GitlabToGithub.Commands.DiagnoseGitlabExport; + +namespace OctoshiftCLI.GitlabToGithub.Services; + +public class GitlabSshDiagnosticsCollector +{ + private const int MAX_OUTPUT_CHARACTERS = 4 * 1024 * 1024; + + public virtual async Task Collect(DiagnoseGitlabExportCommandArgs args) + { + ArgumentNullException.ThrowIfNull(args); + using var resource = typeof(GitlabSshDiagnosticsCollector).Assembly.GetManifestResourceStream("GitlabExportDiagnostics.rb"); + using var reader = new StreamReader(resource); + var script = (await reader.ReadToEndAsync()).Replace("__PROJECT_PATH_BASE64__", EncodeProjectPath(args)); + + using var process = new Process { StartInfo = BuildStartInfo(args) }; + try + { + process.Start(); + } + catch (Win32Exception ex) + { + throw new OctoshiftCliException($"Could not start OpenSSH (ssh). Install the OpenSSH client and ensure it is on PATH. {ex.Message}"); + } + + using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(5)); + var stdout = ReadBounded(process.StandardOutput, timeout.Token, timeout.Cancel); + var stderr = ReadBounded(process.StandardError, timeout.Token, timeout.Cancel); + try + { + var input = WriteScript(process, script, timeout.Token); + await Task.WhenAll(input, stdout, stderr, process.WaitForExitAsync(timeout.Token)); + if (process.ExitCode != 0) + { + throw new OctoshiftCliException($"SSH diagnostics failed (exit {process.ExitCode}). Check the administrator key, trusted known_hosts entry, sudo permissions and container name. {await stderr}"); + } + + if (await input is IOException writeError) + { + throw new OctoshiftCliException($"Could not send the diagnostics script over SSH. {writeError.Message}"); + } + + JObject diagnostics; + try + { + diagnostics = JObject.Parse(await stdout); + } + catch (JsonReaderException) + { + throw new OctoshiftCliException("SSH diagnostics did not return valid JSON. Verify the remote GitLab installation and ensure shell startup scripts do not write to stdout."); + } + if (diagnostics["project_path"]?.Value() != $"{args.GitlabGroup}/{args.GitlabProject}" || + diagnostics["logs"] is not JArray || diagnostics["warnings"] is not JArray warnings) + { + throw new OctoshiftCliException("SSH diagnostics returned an unexpected response. Verify that SSH reaches the intended GitLab installation."); + } + if (!string.IsNullOrWhiteSpace(await stderr)) + { + warnings.Add($"SSH stderr: {await stderr}"); + } + return diagnostics.ToString(); + } + catch (OperationCanceledException) + { + throw new OctoshiftCliException("SSH diagnostics exceeded the five-minute limit. Check GitLab server load and SSH connectivity."); + } + finally + { + timeout.Cancel(); + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(); + } + } + } + + internal static ProcessStartInfo BuildStartInfo(DiagnoseGitlabExportCommandArgs args) + { + var startInfo = new ProcessStartInfo("ssh") + { + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + foreach (var argument in new[] + { + "-T", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes", + "-o", "IdentitiesOnly=yes", "-o", "ClearAllForwardings=yes", "-o", "ForwardAgent=no", + "-o", "ConnectTimeout=15", "-o", "ServerAliveInterval=15", "-o", "ServerAliveCountMax=2", + "-p", args.SshPort.ToString(CultureInfo.InvariantCulture), "-i", args.SshKey, + "-l", args.SshUser, "--", args.SshHost + }) + { + startInfo.ArgumentList.Add(argument); + } + + var command = args.GitlabContainer is null + ? "gitlab-rails runner -" + : $"docker exec -i -- '{args.GitlabContainer}' gitlab-rails runner -"; + startInfo.ArgumentList.Add($"if [ \"$(id -u)\" -eq 0 ]; then {command}; else sudo -n {command}; fi"); + return startInfo; + } + + internal static string EncodeProjectPath(DiagnoseGitlabExportCommandArgs args) => + Convert.ToBase64String(Encoding.UTF8.GetBytes($"{args.GitlabGroup}/{args.GitlabProject}")); + + private static async Task WriteScript(Process process, string script, CancellationToken token) + { + try + { + await process.StandardInput.WriteAsync(script.AsMemory(), token); + process.StandardInput.Close(); + return null; + } + catch (IOException ex) + { + // SSH may reject authentication before reading stdin; report its stderr first. + return ex; + } + } + + internal static async Task ReadBounded(StreamReader reader, CancellationToken token, Action cancel = null) + { + var result = new StringBuilder(); + var buffer = new char[4096]; + int count; + while ((count = await reader.ReadAsync(buffer.AsMemory(), token)) != 0) + { + if (result.Length + count > MAX_OUTPUT_CHARACTERS) + { + cancel?.Invoke(); + throw new OctoshiftCliException("SSH diagnostics exceeded the 4,194,304-character output limit. Collect a narrower set of logs directly on the server."); + } + result.Append(buffer, 0, count); + } + return result.ToString(); + } +} diff --git a/src/gl2gh/gl2gh.csproj b/src/gl2gh/gl2gh.csproj index 2088f3769..8a4e35742 100644 --- a/src/gl2gh/gl2gh.csproj +++ b/src/gl2gh/gl2gh.csproj @@ -18,6 +18,7 @@ +