From 7736f2f88df96fda58100e9348edfe5312e8cd55 Mon Sep 17 00:00:00 2001 From: Martin Helmich Date: Mon, 11 Dec 2023 11:59:59 +0100 Subject: [PATCH 01/10] Refactor SSH key handling --- src/Recipes/SSHUserRecipe.php | 69 ++++++++++++++++-------- src/Util/SSH/SSHConfig.php | 24 +++++++++ src/Util/SSH/SSHConfigRenderer.php | 34 ++++++++++++ src/Util/SSH/SSHHost.php | 16 ++++++ tests/Util/SSH/SSHConfigRendererTest.php | 49 +++++++++++++++++ 5 files changed, 171 insertions(+), 21 deletions(-) create mode 100644 src/Util/SSH/SSHConfig.php create mode 100644 src/Util/SSH/SSHConfigRenderer.php create mode 100644 src/Util/SSH/SSHHost.php create mode 100644 tests/Util/SSH/SSHConfigRendererTest.php diff --git a/src/Recipes/SSHUserRecipe.php b/src/Recipes/SSHUserRecipe.php index 16cddc3..1ff77a0 100644 --- a/src/Recipes/SSHUserRecipe.php +++ b/src/Recipes/SSHUserRecipe.php @@ -2,6 +2,7 @@ namespace Mittwald\Deployer\Recipes; +use Deployer\Host\Host; use Mittwald\ApiClient\Generated\V2\Clients\SSHSFTPUser\CreateSshUser\CreateSshUser201Response; use Mittwald\ApiClient\Generated\V2\Clients\SSHSFTPUser\CreateSshUser\CreateSshUserRequest; use Mittwald\ApiClient\Generated\V2\Clients\SSHSFTPUser\CreateSshUser\CreateSshUserRequestBody; @@ -11,8 +12,12 @@ use Mittwald\ApiClient\Generated\V2\Schemas\Sshuser\PublicKey; use Mittwald\ApiClient\Generated\V2\Schemas\Sshuser\SshUser; use Mittwald\Deployer\Error\UnexpectedResponseException; +use Mittwald\Deployer\Util\SSH\SSHConfig; +use Mittwald\Deployer\Util\SSH\SSHConfigRenderer; +use Mittwald\Deployer\Util\SSH\SSHHost; use function Deployer\{after, currentHost, has, info, parse, runLocally, selectedHosts, Support\parse_home_dir, task}; use function Mittwald\Deployer\get_str; +use function Mittwald\Deployer\get_str_nullable; class SSHUserRecipe { @@ -65,11 +70,6 @@ private static function lookupOrCreateSSHUser(): SshUser } } - if (has('mittwald_ssh_private_key')) { - static::assertLocalSSHDirectory(); - file_put_contents('./.mw-deployer/id_rsa', get_str('mittwald_ssh_private_key')); - } - $sshPublicKey = (function (): string { if (has('mittwald_ssh_public_key_file')) { return file_get_contents(parse_home_dir(get_str('mittwald_ssh_public_key_file'))); @@ -104,7 +104,7 @@ private static function lookupOrCreateSSHUser(): SshUser public static function assertSSHConfig(): void { - $config = ""; + $sshConfig = new SSHConfig('./.mw-deployer/sshconfig'); foreach (selectedHosts() as $host) { /** @var string|null $internal */ @@ -113,33 +113,60 @@ public static function assertSSHConfig(): void continue; } - $name = $host->getAlias() ?? $host->getHostname(); - $config .= "Host {$name}\n\tHostName {$internal}\nStrictHostKeyChecking accept-new\n"; - - if (has('mittwald_ssh_private_key_file')) { - $config .= parse("\tIdentityFile {{mittwald_ssh_private_key_file}}\n"); - } else if (has('mittwald_ssh_private_key')) { - $config .= "\tIdentityFile ./.mw-deployer/id_rsa\n"; - } else { - /** @var string $privateKeyFile */ - $privateKeyFile = str_replace('.pub', '', get_str('ssh_copy_id')); - $config .= "\tIdentityFile {$privateKeyFile}\n"; - } + $sshHost = new SSHHost(name: $host->getAlias() ?? $host->getHostname() ?? "unknown", hostname: $internal); + $sshHost = $sshHost->withIdentityFile(static::determineSSHPrivateKeyForHost($host)); - $config .= "\n"; + $sshConfig = $sshConfig->withHost($sshHost); } static::assertLocalSSHDirectory(); - file_put_contents('./.mw-deployer/sshconfig', $config); + $renderer = new SSHConfigRenderer($sshConfig); + $renderer->renderToFile(); + + static::assertLocalSSHPrivateKey(); foreach (selectedHosts() as $host) { if ($host->has('mittwald_internal_hostname')) { - $host->set('config_file', './.mw-deployer/sshconfig'); + $host->set('config_file', $sshConfig->filename); } } } + private static function determineSSHPrivateKeyForHost(Host $host): string { + /** @var mixed $privateKeyFile */ + $privateKeyFile = $host->get('mittwald_ssh_private_key_file'); + if (is_string($privateKeyFile)) { + return $privateKeyFile; + } + + /** @var mixed $privateKeyContents */ + $privateKeyContents = $host->get('mittwald_ssh_private_key'); + if (is_string($privateKeyContents)) { + return './.mw-deployer/id_rsa'; + } + + /** @var mixed $publicKeyFile */ + $publicKeyFile = $host->get('ssh_copy_id'); + if (is_string($publicKeyFile)) { + /** @var string $privateKeyFile */ + $privateKeyFile = str_replace('.pub', '', $publicKeyFile); + + return $privateKeyFile; + } + + throw new \InvalidArgumentException('could not determine SSH private key for host; please set one of "mittwald_ssh_private_key_file", "mittwald_ssh_private_key", or "ssh_copy_id".'); + } + + private static function assertLocalSSHPrivateKey(): void + { + static::assertLocalSSHDirectory(); + + if (has('mittwald_ssh_private_key')) { + file_put_contents('./.mw-deployer/id_rsa', get_str('mittwald_ssh_private_key')); + } + } + private static function assertLocalSSHDirectory(): void { if (!is_dir('./.mw-deployer')) { diff --git a/src/Util/SSH/SSHConfig.php b/src/Util/SSH/SSHConfig.php new file mode 100644 index 0000000..9563609 --- /dev/null +++ b/src/Util/SSH/SSHConfig.php @@ -0,0 +1,24 @@ +hosts[] = $host; + + return $c; + } +} \ No newline at end of file diff --git a/src/Util/SSH/SSHConfigRenderer.php b/src/Util/SSH/SSHConfigRenderer.php new file mode 100644 index 0000000..d671c2a --- /dev/null +++ b/src/Util/SSH/SSHConfigRenderer.php @@ -0,0 +1,34 @@ +config->hosts as $host) { + $output .= "Host {$host->name}\n"; + $output .= " HostName {$host->hostname}\n"; + + if ($host->identityFile !== null) { + $output .= " IdentityFile {$host->identityFile}\n"; + } + + $output .= "\n"; + } + + return $output; + } + + public function renderToFile(): void + { + file_put_contents($this->config->filename, $this->render()); + } +} \ No newline at end of file diff --git a/src/Util/SSH/SSHHost.php b/src/Util/SSH/SSHHost.php new file mode 100644 index 0000000..70fdd73 --- /dev/null +++ b/src/Util/SSH/SSHHost.php @@ -0,0 +1,16 @@ +name, $this->hostname, identityFile: $identityFile); + } +} \ No newline at end of file diff --git a/tests/Util/SSH/SSHConfigRendererTest.php b/tests/Util/SSH/SSHConfigRendererTest.php new file mode 100644 index 0000000..deddc83 --- /dev/null +++ b/tests/Util/SSH/SSHConfigRendererTest.php @@ -0,0 +1,49 @@ +withHost((new SSHHost('test', 'test.example.com'))->withIdentityFile('~/.ssh/id_rsa')); + $rendered = (new SSHConfigRenderer($config))->render(); + + $expected = <<assertEquals($expected, trim($rendered)); + } + public function testRendersConfigCorrectlyWithMultipleHosts(): void + { + $config = (new SSHConfig("./.mw-deployer/sshconfig")) + ->withHost((new SSHHost('test', 'test.example.com'))->withIdentityFile('~/.ssh/id_rsa')) + ->withHost((new SSHHost('test2', 'test2.example.com'))->withIdentityFile('~/.ssh/id_rsa')); + $rendered = (new SSHConfigRenderer($config))->render(); + + $expected = <<assertEquals($expected, trim($rendered)); + } +} \ No newline at end of file From 63e6de2dad4f3a5c4661f2344024f386f0641557 Mon Sep 17 00:00:00 2001 From: Martin Helmich Date: Mon, 11 Dec 2023 13:00:04 +0100 Subject: [PATCH 02/10] Also set the StrictHostKeyChecking property --- src/Recipes/SSHUserRecipe.php | 83 ++++++++++++++++++++---- src/Util/SSH/SSHConfigRenderer.php | 3 +- tests/Util/SSH/SSHConfigRendererTest.php | 3 + 3 files changed, 75 insertions(+), 14 deletions(-) diff --git a/src/Recipes/SSHUserRecipe.php b/src/Recipes/SSHUserRecipe.php index 1ff77a0..24bbe31 100644 --- a/src/Recipes/SSHUserRecipe.php +++ b/src/Recipes/SSHUserRecipe.php @@ -3,11 +3,17 @@ namespace Mittwald\Deployer\Recipes; use Deployer\Host\Host; +use Mittwald\ApiClient\Client\EmptyResponse; use Mittwald\ApiClient\Generated\V2\Clients\SSHSFTPUser\CreateSshUser\CreateSshUser201Response; use Mittwald\ApiClient\Generated\V2\Clients\SSHSFTPUser\CreateSshUser\CreateSshUserRequest; use Mittwald\ApiClient\Generated\V2\Clients\SSHSFTPUser\CreateSshUser\CreateSshUserRequestBody; +use Mittwald\ApiClient\Generated\V2\Clients\SSHSFTPUser\GetSshUser\GetSshUser200Response; +use Mittwald\ApiClient\Generated\V2\Clients\SSHSFTPUser\GetSshUser\GetSshUserRequest; use Mittwald\ApiClient\Generated\V2\Clients\SSHSFTPUser\ListSshUsers\ListSshUsers200Response; use Mittwald\ApiClient\Generated\V2\Clients\SSHSFTPUser\ListSshUsers\ListSshUsersRequest; +use Mittwald\ApiClient\Generated\V2\Clients\SSHSFTPUser\UpdateSshUser\UpdateSshUserRequest; +use Mittwald\ApiClient\Generated\V2\Clients\SSHSFTPUser\UpdateSshUser\UpdateSshUserRequestBody; +use Mittwald\ApiClient\Generated\V2\Schemas\Project\Project; use Mittwald\ApiClient\Generated\V2\Schemas\Sshuser\AuthenticationAlternative2; use Mittwald\ApiClient\Generated\V2\Schemas\Sshuser\PublicKey; use Mittwald\ApiClient\Generated\V2\Schemas\Sshuser\SshUser; @@ -15,14 +21,22 @@ use Mittwald\Deployer\Util\SSH\SSHConfig; use Mittwald\Deployer\Util\SSH\SSHConfigRenderer; use Mittwald\Deployer\Util\SSH\SSHHost; -use function Deployer\{after, currentHost, has, info, parse, runLocally, selectedHosts, Support\parse_home_dir, task}; +use function Deployer\{after, currentHost, has, info, runLocally, selectedHosts, set, Support\parse_home_dir, task}; use function Mittwald\Deployer\get_str; -use function Mittwald\Deployer\get_str_nullable; class SSHUserRecipe { public static function setup(): void { + set('mittwald_ssh_public_key', function (): string { + if (has('mittwald_ssh_public_key_file')) { + return file_get_contents(parse_home_dir(get_str('mittwald_ssh_public_key_file'))); + } + + // Need to do this in case `ssh_copy_id` contains a tilde that needs to be expanded + return runLocally('cat {{ssh_copy_id}}'); + }); + task('mittwald:sshconfig', function (): void { static::assertSSHConfig(); }) @@ -66,20 +80,62 @@ private static function lookupOrCreateSSHUser(): SshUser foreach ($sshUsers as $sshUser) { if ($sshUser->getDescription() === 'deployer') { info("using existing SSH user deployer"); - return $sshUser; + + return static::assertPublicKeyOnExistingSSHUser($sshUser); } } - $sshPublicKey = (function (): string { - if (has('mittwald_ssh_public_key_file')) { - return file_get_contents(parse_home_dir(get_str('mittwald_ssh_public_key_file'))); - } else if (has('mittwald_ssh_public_key')) { - return get_str('mittwald_ssh_public_key'); - } else { - // Need to do this in case `ssh_copy_id` contains a tilde that needs to be expanded - return runLocally('cat {{ssh_copy_id}}'); + return static::createSSHUser($project); + } + + private static function assertPublicKeyOnExistingSSHUser(SshUser $sshUser): SshUser + { + $client = BaseRecipe::getClient()->sSHSFTPUser(); + + $sshPublicKey = get_str('mittwald_ssh_public_key'); + $sshPublicKeyParts = explode(" ", $sshPublicKey); + $sshPublicKeyPartsWithoutComment = array_slice($sshPublicKeyParts, 0, 2); + $sshPublicKeyWithoutComment = implode(" ", $sshPublicKeyPartsWithoutComment); + + $existingPublicKeys = $sshUser->getPublicKeys() ?? []; + + foreach ($existingPublicKeys as $existingPublicKey) { + if ($existingPublicKey->getKey() === $sshPublicKeyWithoutComment) { + info("SSH user deployer already has the correct SSH public key"); + return $sshUser; } - })(); + } + + $newPublicKeys = [ + ...$existingPublicKeys, + new PublicKey("deployer", $sshPublicKeyWithoutComment), + ]; + + $updateReq = new UpdateSshUserRequest( + $sshUser->getId(), + (new UpdateSshUserRequestBody())->withPublicKeys($newPublicKeys), + ); + $updateRes = $client->updateSshUser($updateReq); + + if (!$updateRes instanceof EmptyResponse) { + throw new UnexpectedResponseException('could not update SSH user', $updateRes); + } + + $getReq = new GetSshUserRequest($sshUser->getId()); + $getRes = $client->getSshUser($getReq); + + if (!$getRes instanceof GetSshUser200Response) { + throw new UnexpectedResponseException('could not get SSH user', $getRes); + } + + return $getRes->getBody(); + } + + private static function createSSHUser(Project $project): SshUser + { + $client = BaseRecipe::getClient()->sSHSFTPUser(); + + $sshPublicKey = get_str('mittwald_ssh_public_key'); $sshPublicKeyParts = explode(" ", $sshPublicKey); $sshPublicKeyPartsWithoutComment = array_slice($sshPublicKeyParts, 0, 2); @@ -133,7 +189,8 @@ public static function assertSSHConfig(): void } } - private static function determineSSHPrivateKeyForHost(Host $host): string { + private static function determineSSHPrivateKeyForHost(Host $host): string + { /** @var mixed $privateKeyFile */ $privateKeyFile = $host->get('mittwald_ssh_private_key_file'); if (is_string($privateKeyFile)) { diff --git a/src/Util/SSH/SSHConfigRenderer.php b/src/Util/SSH/SSHConfigRenderer.php index d671c2a..e3a8f91 100644 --- a/src/Util/SSH/SSHConfigRenderer.php +++ b/src/Util/SSH/SSHConfigRenderer.php @@ -3,7 +3,7 @@ namespace Mittwald\Deployer\Util\SSH; -class SSHConfigRenderer +readonly class SSHConfigRenderer { public function __construct(private SSHConfig $config) { @@ -16,6 +16,7 @@ public function render(): string foreach ($this->config->hosts as $host) { $output .= "Host {$host->name}\n"; $output .= " HostName {$host->hostname}\n"; + $output .= " StrictHostKeyChecking accept-new\n"; if ($host->identityFile !== null) { $output .= " IdentityFile {$host->identityFile}\n"; diff --git a/tests/Util/SSH/SSHConfigRendererTest.php b/tests/Util/SSH/SSHConfigRendererTest.php index deddc83..10abcd0 100644 --- a/tests/Util/SSH/SSHConfigRendererTest.php +++ b/tests/Util/SSH/SSHConfigRendererTest.php @@ -22,6 +22,7 @@ public function testRendersConfigCorrectlyWithSingleHost(): void $expected = << Date: Mon, 11 Dec 2023 13:48:15 +0100 Subject: [PATCH 03/10] Refactor SSH public key handling --- src/Recipes/SSHUserRecipe.php | 45 ++++++++++++++++++++++++----------- src/Util/SSH/SSHPublicKey.php | 24 +++++++++++++++++++ 2 files changed, 55 insertions(+), 14 deletions(-) create mode 100644 src/Util/SSH/SSHPublicKey.php diff --git a/src/Recipes/SSHUserRecipe.php b/src/Recipes/SSHUserRecipe.php index 24bbe31..9b188b6 100644 --- a/src/Recipes/SSHUserRecipe.php +++ b/src/Recipes/SSHUserRecipe.php @@ -21,6 +21,7 @@ use Mittwald\Deployer\Util\SSH\SSHConfig; use Mittwald\Deployer\Util\SSH\SSHConfigRenderer; use Mittwald\Deployer\Util\SSH\SSHHost; +use Mittwald\Deployer\Util\SSH\SSHPublicKey; use function Deployer\{after, currentHost, has, info, runLocally, selectedHosts, set, Support\parse_home_dir, task}; use function Mittwald\Deployer\get_str; @@ -81,34 +82,45 @@ private static function lookupOrCreateSSHUser(): SshUser if ($sshUser->getDescription() === 'deployer') { info("using existing SSH user deployer"); - return static::assertPublicKeyOnExistingSSHUser($sshUser); + return static::assertSSHUserHasPublicKey($sshUser); } } return static::createSSHUser($project); } - private static function assertPublicKeyOnExistingSSHUser(SshUser $sshUser): SshUser + private static function assertSSHUserHasPublicKey(SshUser $sshUser): SshUser { - $client = BaseRecipe::getClient()->sSHSFTPUser(); + $sshPublicKey = SSHPublicKey::fromString(get_str('mittwald_ssh_public_key')); - $sshPublicKey = get_str('mittwald_ssh_public_key'); - $sshPublicKeyParts = explode(" ", $sshPublicKey); - $sshPublicKeyPartsWithoutComment = array_slice($sshPublicKeyParts, 0, 2); - $sshPublicKeyWithoutComment = implode(" ", $sshPublicKeyPartsWithoutComment); + if (static::hasSSHUserPublicKey($sshUser, $sshPublicKey)) { + info("SSH user deployer already has the correct SSH public key"); + return $sshUser; + } - $existingPublicKeys = $sshUser->getPublicKeys() ?? []; + static::addPublicKeyToSSHUser($sshUser, $sshPublicKey); + + return static::getSSHUser($sshUser->getId()); + } + private static function hasSSHUserPublicKey(SshUser $sshUser, SSHPublicKey $publicKey): bool + { + $existingPublicKeys = $sshUser->getPublicKeys() ?? []; foreach ($existingPublicKeys as $existingPublicKey) { - if ($existingPublicKey->getKey() === $sshPublicKeyWithoutComment) { - info("SSH user deployer already has the correct SSH public key"); - return $sshUser; + if ($existingPublicKey->getKey() === $publicKey->publicKey) { + return true; } } + return false; + } + + private static function addPublicKeyToSSHUser(SshUser $sshUser, SSHPublicKey $publicKey): void + { + $client = BaseRecipe::getClient()->sSHSFTPUser(); $newPublicKeys = [ - ...$existingPublicKeys, - new PublicKey("deployer", $sshPublicKeyWithoutComment), + ...$sshUser->getPublicKeys() ?? [], + new PublicKey("deployer", $publicKey->publicKey), ]; $updateReq = new UpdateSshUserRequest( @@ -120,8 +132,13 @@ private static function assertPublicKeyOnExistingSSHUser(SshUser $sshUser): SshU if (!$updateRes instanceof EmptyResponse) { throw new UnexpectedResponseException('could not update SSH user', $updateRes); } + } + + private static function getSSHUser(string $id): SshUser + { + $client = BaseRecipe::getClient()->sSHSFTPUser(); - $getReq = new GetSshUserRequest($sshUser->getId()); + $getReq = new GetSshUserRequest($id); $getRes = $client->getSshUser($getReq); if (!$getRes instanceof GetSshUser200Response) { diff --git a/src/Util/SSH/SSHPublicKey.php b/src/Util/SSH/SSHPublicKey.php new file mode 100644 index 0000000..a4cb1cc --- /dev/null +++ b/src/Util/SSH/SSHPublicKey.php @@ -0,0 +1,24 @@ + Date: Mon, 11 Dec 2023 13:57:28 +0100 Subject: [PATCH 04/10] Fix issues in SSHPublicKey class --- src/Util/SSH/SSHPublicKey.php | 4 ++-- tests/Util/SSH/SSHPublicKeyTest.php | 35 +++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 tests/Util/SSH/SSHPublicKeyTest.php diff --git a/src/Util/SSH/SSHPublicKey.php b/src/Util/SSH/SSHPublicKey.php index a4cb1cc..6689e4a 100644 --- a/src/Util/SSH/SSHPublicKey.php +++ b/src/Util/SSH/SSHPublicKey.php @@ -12,13 +12,13 @@ public function __construct(public string $publicKey, public string $comment) public static function fromString(string $publicKey): self { $sshPublicKeyParts = explode(" ", $publicKey, limit: 3); - if (count($sshPublicKeyParts) !== 3) { + if (count($sshPublicKeyParts) < 2) { throw new \InvalidArgumentException("Invalid SSH public key"); } $sshPublicKeyPartsWithoutComment = array_slice($sshPublicKeyParts, offset: 0, length: 2); $sshPublicKeyWithoutComment = implode(" ", $sshPublicKeyPartsWithoutComment); - return new self($sshPublicKeyWithoutComment, $sshPublicKeyParts[2]); + return new self($sshPublicKeyWithoutComment, $sshPublicKeyParts[2] ?? ""); } } \ No newline at end of file diff --git a/tests/Util/SSH/SSHPublicKeyTest.php b/tests/Util/SSH/SSHPublicKeyTest.php new file mode 100644 index 0000000..abab9cb --- /dev/null +++ b/tests/Util/SSH/SSHPublicKeyTest.php @@ -0,0 +1,35 @@ +assertEquals("ssh-rsa FOOBAR", $publicKey->publicKey); + $this->assertEquals("foo@bar", $publicKey->comment); + } + + public function testFromStringCorrectlyDeconstructsInputWithoutComment(): void + { + $publicKey = SSHPublicKey::fromString("ssh-rsa FOOBAR"); + + $this->assertEquals("ssh-rsa FOOBAR", $publicKey->publicKey); + $this->assertEquals("", $publicKey->comment); + } + + public function testFromStringThrowsExceptionInInvalidInput(): void + { + $this->expectException(InvalidArgumentException::class); + SSHPublicKey::fromString("ssh-rsa"); + } +} \ No newline at end of file From c92bcd04d4c866e4e768cefa39a2b8e48ead55d1 Mon Sep 17 00:00:00 2001 From: Martin Helmich Date: Mon, 11 Dec 2023 14:01:07 +0100 Subject: [PATCH 05/10] Some more extracting --- src/Recipes/SSHUserRecipe.php | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/Recipes/SSHUserRecipe.php b/src/Recipes/SSHUserRecipe.php index 9b188b6..85d444f 100644 --- a/src/Recipes/SSHUserRecipe.php +++ b/src/Recipes/SSHUserRecipe.php @@ -67,8 +67,20 @@ public static function assertSSHUser(): void private static function lookupOrCreateSSHUser(): SshUser { - $client = BaseRecipe::getClient()->sSHSFTPUser(); $project = BaseRecipe::getProject(); + $existingUser = static::findExistingUser($project); + + if ($existingUser !== null) { + info("using existing SSH user deployer"); + return static::assertSSHUserHasPublicKey($existingUser); + } + + return static::createSSHUser($project); + } + + private static function findExistingUser(Project $project): SshUser|null + { + $client = BaseRecipe::getClient()->sSHSFTPUser(); $sshUsersReq = new ListSshUsersRequest($project->getId()); $sshUsersRes = $client->listSshUsers($sshUsersReq); @@ -80,13 +92,11 @@ private static function lookupOrCreateSSHUser(): SshUser $sshUsers = $sshUsersRes->getBody(); foreach ($sshUsers as $sshUser) { if ($sshUser->getDescription() === 'deployer') { - info("using existing SSH user deployer"); - - return static::assertSSHUserHasPublicKey($sshUser); + return $sshUser; } } - return static::createSSHUser($project); + return null; } private static function assertSSHUserHasPublicKey(SshUser $sshUser): SshUser From 06122cd19e1e1f6a0e5cb3972713732114cdca32 Mon Sep 17 00:00:00 2001 From: Martin Helmich Date: Tue, 12 Dec 2023 11:38:33 +0100 Subject: [PATCH 06/10] Bump api-client to v2.0.0 --- composer.json | 2 +- composer.lock | 14 +++---- src/Client/AppClient.php | 16 ------- src/Recipes/AppRecipe.php | 25 ++--------- src/Recipes/BaseRecipe.php | 3 +- src/Recipes/DomainRecipe.php | 17 +------- src/Recipes/SSHUserRecipe.php | 78 ++++++++++++++--------------------- 7 files changed, 47 insertions(+), 108 deletions(-) diff --git a/composer.json b/composer.json index 755b26d..a4297c6 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ ], "require": { "php": "^8.2", - "mittwald/api-client": "^1.0", + "mittwald/api-client": "^2.0", "composer/semver": "^3.4" }, "require-dev": { diff --git a/composer.lock b/composer.lock index c470fc1..258dcf7 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "04fb93f338054e693b824188aecc9eb0", + "content-hash": "af2fc6eadefe4146c9a46510fee1f05b", "packages": [ { "name": "composer/semver", @@ -484,16 +484,16 @@ }, { "name": "mittwald/api-client", - "version": "v1.0.42", + "version": "v2.0.0", "source": { "type": "git", "url": "https://github.com/mittwald/api-client-php.git", - "reference": "f275b758a523d246ba449d577f2ba77a5e0ddfc0" + "reference": "1db9668fa49bfcc60386f8fb30deb970ee8bfe17" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/mittwald/api-client-php/zipball/f275b758a523d246ba449d577f2ba77a5e0ddfc0", - "reference": "f275b758a523d246ba449d577f2ba77a5e0ddfc0", + "url": "https://api.github.com/repos/mittwald/api-client-php/zipball/1db9668fa49bfcc60386f8fb30deb970ee8bfe17", + "reference": "1db9668fa49bfcc60386f8fb30deb970ee8bfe17", "shasum": "" }, "require": { @@ -524,9 +524,9 @@ "description": "Client library for the mittwald mStudio v2 API", "support": { "issues": "https://github.com/mittwald/api-client-php/issues", - "source": "https://github.com/mittwald/api-client-php/tree/v1.0.42" + "source": "https://github.com/mittwald/api-client-php/tree/v2.0.0" }, - "time": "2023-11-28T00:13:48+00:00" + "time": "2023-12-12T10:18:32+00:00" }, { "name": "psr/http-client", diff --git a/src/Client/AppClient.php b/src/Client/AppClient.php index f8ba8cb..1da5bb1 100644 --- a/src/Client/AppClient.php +++ b/src/Client/AppClient.php @@ -5,11 +5,7 @@ use Composer\Semver\Comparator; use Mittwald\ApiClient\Client\EmptyResponse; use Mittwald\ApiClient\Generated\V2\Clients\App\AppClient as GeneratedAppClient; -use Mittwald\ApiClient\Generated\V2\Clients\App\GetAppinstallation\GetAppinstallation200Response; -use Mittwald\ApiClient\Generated\V2\Clients\App\GetAppinstallation\GetAppinstallationRequest; -use Mittwald\ApiClient\Generated\V2\Clients\App\ListSystemsoftwares\ListSystemsoftwares200Response; use Mittwald\ApiClient\Generated\V2\Clients\App\ListSystemsoftwares\ListSystemsoftwaresRequest; -use Mittwald\ApiClient\Generated\V2\Clients\App\ListSystemsoftwareversions\ListSystemsoftwareversions200Response; use Mittwald\ApiClient\Generated\V2\Clients\App\ListSystemsoftwareversions\ListSystemsoftwareversionsRequest; use Mittwald\ApiClient\Generated\V2\Clients\App\PatchAppinstallation\PatchAppinstallationRequest; use Mittwald\ApiClient\Generated\V2\Clients\App\PatchAppinstallation\PatchAppinstallationRequestBody; @@ -33,11 +29,6 @@ public function __construct(private readonly GeneratedAppClient $inner) */ public function setSystemSoftwareVersions(string $appInstallationId, array $systemSoftwareConstraints): void { - $appInstallationResponse = $this->inner->getAppinstallation(new GetAppinstallationRequest($appInstallationId)); - if (!$appInstallationResponse instanceof GetAppinstallation200Response) { - throw new \Exception('could not get app installation'); - } - $systemSoftwareSpec = []; foreach ($systemSoftwareConstraints as $name => $constraint) { @@ -70,10 +61,6 @@ public function resolveSystemSoftwareByConstraint(string $systemSoftwareName, st $systemSoftwareVersionConstraint = static::normalizeVersionConstraint($systemSoftwareVersionConstraint); $systemSoftwareResponse = $this->inner->listSystemsoftwares(new ListSystemsoftwaresRequest()); - if (!$systemSoftwareResponse instanceof ListSystemsoftwares200Response) { - throw new \Exception('could not list system software'); - } - $systemSoftware = (function () use ($systemSoftwareResponse, $systemSoftwareName): SystemSoftware { foreach ($systemSoftwareResponse->getBody() as $systemSoftware) { if (strtolower($systemSoftware->getName()) === strtolower($systemSoftwareName)) { @@ -87,9 +74,6 @@ public function resolveSystemSoftwareByConstraint(string $systemSoftwareName, st (new ListSystemsoftwareversionsRequest($systemSoftware->getId())) ->withVersionRange($systemSoftwareVersionConstraint) ); - if (!$versionResponse instanceof ListSystemsoftwareVersions200Response) { - throw new \Exception('could not list system software versions'); - } $newest = null; diff --git a/src/Recipes/AppRecipe.php b/src/Recipes/AppRecipe.php index fd5a7e4..7fdc63f 100644 --- a/src/Recipes/AppRecipe.php +++ b/src/Recipes/AppRecipe.php @@ -3,19 +3,14 @@ namespace Mittwald\Deployer\Recipes; use Mittwald\ApiClient\Client\EmptyResponse; -use Mittwald\ApiClient\Generated\V2\Clients\App\GetAppinstallation\GetAppinstallation200Response; use Mittwald\ApiClient\Generated\V2\Clients\App\GetAppinstallation\GetAppinstallationRequest; -use Mittwald\ApiClient\Generated\V2\Clients\App\ListAppinstallations\ListAppinstallations200Response; use Mittwald\ApiClient\Generated\V2\Clients\App\ListAppinstallations\ListAppinstallationsRequest; use Mittwald\ApiClient\Generated\V2\Clients\App\PatchAppinstallation\PatchAppinstallationRequest; use Mittwald\ApiClient\Generated\V2\Clients\App\PatchAppinstallation\PatchAppinstallationRequestBody; -use Mittwald\ApiClient\Generated\V2\Clients\Project\GetProject\GetProject200Response; use Mittwald\ApiClient\Generated\V2\Clients\Project\GetProject\GetProjectRequest; -use Mittwald\ApiClient\Generated\V2\Clients\Project\ListProjects\ListProjects200Response; use Mittwald\ApiClient\Generated\V2\Clients\Project\ListProjects\ListProjectsRequest; use Mittwald\ApiClient\Generated\V2\Schemas\App\AppInstallation; use Mittwald\Deployer\Client\AppClient; -use Mittwald\Deployer\Error\UnexpectedResponseException; use Mittwald\Deployer\Util\SanityCheck; use function Deployer\{after, currentHost, get, info, parse, run, set, Support\starts_with, task}; use function Mittwald\Deployer\get_array; @@ -53,9 +48,6 @@ public static function setup(): void } $projectResponse = $client->listProjects(new ListProjectsRequest()); - if (!$projectResponse instanceof ListProjects200Response) { - throw new UnexpectedResponseException('Could not list projects', $projectResponse); - } foreach ($projectResponse->getBody() as $project) { if ($project->getShortId() === get('mittwald_project_id') || $project->getId() === get('mittwald_project_id')) { @@ -72,9 +64,6 @@ public static function setup(): void $projectRequest = new GetProjectRequest($projectId); $projectResponse = $client->project()->getProject($projectRequest); - if (!$projectResponse instanceof GetProject200Response) { - throw new UnexpectedResponseException('could not get projects', $projectResponse); - } return $projectResponse->getBody()->toJson(); }); @@ -85,22 +74,16 @@ public static function setup(): void if ($appID = get_str_nullable('mittwald_app_id')) { SanityCheck::assertAppInstallationID($appID); - $appResponse = $client->getAppinstallation(new GetAppinstallationRequest($appID)); - if (!$appResponse instanceof GetAppinstallation200Response) { - throw new UnexpectedResponseException('could not get app', $appResponse); - } - - return $appResponse->getBody()->toJson(); + return $client + ->getAppinstallation(new GetAppinstallationRequest($appID)) + ->getBody() + ->toJson(); } if ($deployPath = get_str_nullable('deploy_path')) { $project = BaseRecipe::getProject(); $appsResponse = $client->listAppinstallations(new ListAppinstallationsRequest($project->getId())); - if (!$appsResponse instanceof ListAppinstallations200Response) { - throw new UnexpectedResponseException('could not list apps', $appsResponse); - } - $webBasePath = $project->getDirectories()["Web"]; foreach ($appsResponse->getBody() as $app) { diff --git a/src/Recipes/BaseRecipe.php b/src/Recipes/BaseRecipe.php index 266e872..77d92c1 100644 --- a/src/Recipes/BaseRecipe.php +++ b/src/Recipes/BaseRecipe.php @@ -1,6 +1,7 @@ ingressListIngresses((new IngressListIngressesRequest())->withProjectId($project->getId())); - if (!$virtualHostResponse instanceof IngressListIngresses200Response) { - throw new UnexpectedResponseException('could not list virtual hosts', $virtualHostResponse); - } - $virtualHost = (function () use ($virtualHostResponse, $domain) { foreach ($virtualHostResponse->getBody() as $virtualHost) { if ($virtualHost->getHostname() === $domain) { @@ -74,10 +66,6 @@ private static function assertVirtualHost(string $domain): void $project->getId(), ))); $response = $client->ingressCreateIngress($request); - - if (!$response instanceof IngressCreateIngress201Response) { - throw new UnexpectedResponseException('could not create virtual host', $response); - } } else { $updatedPaths = (clone $virtualHost)->getPaths(); @@ -105,11 +93,8 @@ private static function assertVirtualHost(string $domain): void info("virtual host {$domain} exists, updating it"); $request = new IngressUpdateIngressPathsRequest($virtualHost->getId(), $updatedPaths); - $response = $client->ingressUpdateIngressPaths($request); - if (!$response instanceof EmptyResponse) { - throw new UnexpectedResponseException('could not update virtual host', $response); - } + $client->ingressUpdateIngressPaths($request); } else { info("virtual host {$domain} exists, no update required"); } diff --git a/src/Recipes/SSHUserRecipe.php b/src/Recipes/SSHUserRecipe.php index 85d444f..1dc7e19 100644 --- a/src/Recipes/SSHUserRecipe.php +++ b/src/Recipes/SSHUserRecipe.php @@ -4,12 +4,9 @@ use Deployer\Host\Host; use Mittwald\ApiClient\Client\EmptyResponse; -use Mittwald\ApiClient\Generated\V2\Clients\SSHSFTPUser\CreateSshUser\CreateSshUser201Response; use Mittwald\ApiClient\Generated\V2\Clients\SSHSFTPUser\CreateSshUser\CreateSshUserRequest; use Mittwald\ApiClient\Generated\V2\Clients\SSHSFTPUser\CreateSshUser\CreateSshUserRequestBody; -use Mittwald\ApiClient\Generated\V2\Clients\SSHSFTPUser\GetSshUser\GetSshUser200Response; use Mittwald\ApiClient\Generated\V2\Clients\SSHSFTPUser\GetSshUser\GetSshUserRequest; -use Mittwald\ApiClient\Generated\V2\Clients\SSHSFTPUser\ListSshUsers\ListSshUsers200Response; use Mittwald\ApiClient\Generated\V2\Clients\SSHSFTPUser\ListSshUsers\ListSshUsersRequest; use Mittwald\ApiClient\Generated\V2\Clients\SSHSFTPUser\UpdateSshUser\UpdateSshUserRequest; use Mittwald\ApiClient\Generated\V2\Clients\SSHSFTPUser\UpdateSshUser\UpdateSshUserRequestBody; @@ -29,6 +26,8 @@ class SSHUserRecipe { public static function setup(): void { + set('mittwald_ssh_username', 'deployer'); + set('mittwald_ssh_public_key', function (): string { if (has('mittwald_ssh_public_key_file')) { return file_get_contents(parse_home_dir(get_str('mittwald_ssh_public_key_file'))); @@ -68,7 +67,7 @@ public static function assertSSHUser(): void private static function lookupOrCreateSSHUser(): SshUser { $project = BaseRecipe::getProject(); - $existingUser = static::findExistingUser($project); + $existingUser = static::findExistingSSHUserByName($project); if ($existingUser !== null) { info("using existing SSH user deployer"); @@ -78,20 +77,16 @@ private static function lookupOrCreateSSHUser(): SshUser return static::createSSHUser($project); } - private static function findExistingUser(Project $project): SshUser|null + private static function findExistingSSHUserByName(Project $project): SshUser|null { $client = BaseRecipe::getClient()->sSHSFTPUser(); + $username = get_str('mittwald_ssh_username'); $sshUsersReq = new ListSshUsersRequest($project->getId()); - $sshUsersRes = $client->listSshUsers($sshUsersReq); - - if (!$sshUsersRes instanceof ListSshUsers200Response) { - throw new UnexpectedResponseException('could not list SSH users', $sshUsersRes); - } + $sshUsers = $client->listSshUsers($sshUsersReq)->getBody(); - $sshUsers = $sshUsersRes->getBody(); foreach ($sshUsers as $sshUser) { - if ($sshUser->getDescription() === 'deployer') { + if ($sshUser->getDescription() === $username) { return $sshUser; } } @@ -148,44 +143,46 @@ private static function getSSHUser(string $id): SshUser { $client = BaseRecipe::getClient()->sSHSFTPUser(); - $getReq = new GetSshUserRequest($id); - $getRes = $client->getSshUser($getReq); - - if (!$getRes instanceof GetSshUser200Response) { - throw new UnexpectedResponseException('could not get SSH user', $getRes); - } - - return $getRes->getBody(); + return $client->getSshUser(new GetSshUserRequest($id))->getBody(); } private static function createSSHUser(Project $project): SshUser { $client = BaseRecipe::getClient()->sSHSFTPUser(); - $sshPublicKey = get_str('mittwald_ssh_public_key'); - - $sshPublicKeyParts = explode(" ", $sshPublicKey); - $sshPublicKeyPartsWithoutComment = array_slice($sshPublicKeyParts, 0, 2); - $sshPublicKeyWithoutComment = implode(" ", $sshPublicKeyPartsWithoutComment); + $sshPublicKey = SSHPublicKey::fromString(get_str('mittwald_ssh_public_key')); info("creating SSH user deployer"); - info("using SSH public key {$sshPublicKeyWithoutComment}"); + info("using SSH public key {$sshPublicKey->publicKey}"); $createUserAuth = new AuthenticationAlternative2([ - new PublicKey("deployer", $sshPublicKeyWithoutComment), + new PublicKey("deployer", $sshPublicKey->publicKey), ]); - $createUserReq = new CreateSshUserRequest($project->getId(), (new CreateSshUserRequestBody($createUserAuth, 'deployer'))); - $createUserRes = $client->createSshUser($createUserReq); - - if (!$createUserRes instanceof CreateSshUser201Response) { - throw new UnexpectedResponseException('could not create SSH user', $createUserRes); - } + $createUserReq = new CreateSshUserRequest($project->getId(), (new CreateSshUserRequestBody($createUserAuth, get_str('mittwald_ssh_username')))); - return $createUserRes->getBody(); + return $client->createSshUser($createUserReq)->getBody(); } public static function assertSSHConfig(): void + { + static::assertLocalSSHDirectory(); + + $sshConfig = static::buildSSHConfigForSelectedHosts(); + + $renderer = new SSHConfigRenderer($sshConfig); + $renderer->renderToFile(); + + static::assertLocalSSHPrivateKey(); + + foreach (selectedHosts() as $host) { + if ($host->has('mittwald_internal_hostname')) { + $host->set('config_file', $sshConfig->filename); + } + } + } + + private static function buildSSHConfigForSelectedHosts(): SSHConfig { $sshConfig = new SSHConfig('./.mw-deployer/sshconfig'); @@ -202,18 +199,7 @@ public static function assertSSHConfig(): void $sshConfig = $sshConfig->withHost($sshHost); } - static::assertLocalSSHDirectory(); - - $renderer = new SSHConfigRenderer($sshConfig); - $renderer->renderToFile(); - - static::assertLocalSSHPrivateKey(); - - foreach (selectedHosts() as $host) { - if ($host->has('mittwald_internal_hostname')) { - $host->set('config_file', $sshConfig->filename); - } - } + return $sshConfig; } private static function determineSSHPrivateKeyForHost(Host $host): string From 527c23ae23b05b3b131e511596a1589b529c2678 Mon Sep 17 00:00:00 2001 From: Martin Helmich Date: Thu, 14 Dec 2023 09:38:50 +0100 Subject: [PATCH 07/10] Add test for SSH recipe --- src/Client/MockClient.php | 168 +++++++++++++++++++++++ src/Recipes/BaseRecipe.php | 8 ++ src/Recipes/SSHUserRecipe.php | 6 +- tests/Recipes/SSHUserRecipeTest.php | 200 ++++++++++++++++++++++++++++ 4 files changed, 377 insertions(+), 5 deletions(-) create mode 100644 src/Client/MockClient.php create mode 100644 tests/Recipes/SSHUserRecipeTest.php diff --git a/src/Client/MockClient.php b/src/Client/MockClient.php new file mode 100644 index 0000000..88e7c4b --- /dev/null +++ b/src/Client/MockClient.php @@ -0,0 +1,168 @@ +project = $test->getMockBuilder(ProjectClient::class)->getMock(); + $this->backup = $test->getMockBuilder(BackupClient::class)->getMock(); + $this->sshSFTPUser = $test->getMockBuilder(SSHSFTPUserClient::class)->getMock(); + $this->cronjob = $test->getMockBuilder(CronjobClient::class)->getMock(); + $this->app = $test->getMockBuilder(AppClient::class)->getMock(); + $this->projectFileSystem = $test->getMockBuilder(ProjectFileSystemClient::class)->getMock(); + $this->contract = $test->getMockBuilder(ContractClient::class)->getMock(); + $this->database = $test->getMockBuilder(DatabaseClient::class)->getMock(); + $this->domain = $test->getMockBuilder(DomainClient::class)->getMock(); + $this->conversation = $test->getMockBuilder(ConversationClient::class)->getMock(); + $this->customer = $test->getMockBuilder(CustomerClient::class)->getMock(); + $this->user = $test->getMockBuilder(UserClient::class)->getMock(); + $this->notification = $test->getMockBuilder(NotificationClient::class)->getMock(); + $this->file = $test->getMockBuilder(FileClient::class)->getMock(); + $this->mail = $test->getMockBuilder(MailClient::class)->getMock(); + $this->article = $test->getMockBuilder(ArticleClient::class)->getMock(); + $this->container = $test->getMockBuilder(ContainerClient::class)->getMock(); + $this->pageInsights = $test->getMockBuilder(PageInsightsClient::class)->getMock(); + $this->relocation = $test->getMockBuilder(RelocationClient::class)->getMock(); + } + + public function project(): ProjectClient + { + return $this->project; + } + + public function backup(): BackupClient + { + return $this->backup; + } + + public function sshSFTPUser(): SSHSFTPUserClient + { + return $this->sshSFTPUser; + } + + public function cronjob(): CronjobClient + { + return $this->cronjob; + } + + public function app(): AppClient + { + return $this->app; + } + + public function projectFileSystem(): ProjectFileSystemClient + { + return $this->projectFileSystem; + } + + public function contract(): ContractClient + { + return $this->contract; + } + + public function database(): DatabaseClient + { + return $this->database; + } + + public function domain(): DomainClient + { + return $this->domain; + } + + public function conversation(): ConversationClient + { + return $this->conversation; + } + + public function customer(): CustomerClient + { + return $this->customer; + } + + public function user(): UserClient + { + return $this->user; + } + + public function notification(): NotificationClient + { + return $this->notification; + } + + public function file(): FileClient + { + return $this->file; + } + + public function mail(): MailClient + { + return $this->mail; + } + + public function article(): ArticleClient + { + return $this->article; + } + + public function container(): ContainerClient + { + return $this->container; + } + + public function pageInsights(): PageInsightsClient + { + return $this->pageInsights; + } + + public function relocation(): RelocationClient + { + return $this->relocation; + } + +} \ No newline at end of file diff --git a/src/Recipes/BaseRecipe.php b/src/Recipes/BaseRecipe.php index 77d92c1..a0554a8 100644 --- a/src/Recipes/BaseRecipe.php +++ b/src/Recipes/BaseRecipe.php @@ -4,6 +4,8 @@ use Mittwald\ApiClient\Generated\V2\Client; use Mittwald\ApiClient\Generated\V2\Schemas\Project\Project; use Mittwald\ApiClient\MittwaldAPIV2Client; +use function Deployer\get; +use function Deployer\has; use function Mittwald\Deployer\get_array; use function Mittwald\Deployer\get_str; @@ -11,6 +13,12 @@ class BaseRecipe { public static function getClient(): Client { + if (has('mittwald_client')) { + $client = get('mittwald_client'); + assert($client instanceof Client); + return $client; + } + return MittwaldAPIV2Client::newWithToken(get_str('mittwald_token')); } diff --git a/src/Recipes/SSHUserRecipe.php b/src/Recipes/SSHUserRecipe.php index 1dc7e19..f6a68f7 100644 --- a/src/Recipes/SSHUserRecipe.php +++ b/src/Recipes/SSHUserRecipe.php @@ -132,11 +132,7 @@ private static function addPublicKeyToSSHUser(SshUser $sshUser, SSHPublicKey $pu $sshUser->getId(), (new UpdateSshUserRequestBody())->withPublicKeys($newPublicKeys), ); - $updateRes = $client->updateSshUser($updateReq); - - if (!$updateRes instanceof EmptyResponse) { - throw new UnexpectedResponseException('could not update SSH user', $updateRes); - } + $client->updateSshUser($updateReq); } private static function getSSHUser(string $id): SshUser diff --git a/tests/Recipes/SSHUserRecipeTest.php b/tests/Recipes/SSHUserRecipeTest.php new file mode 100644 index 0000000..7fc1364 --- /dev/null +++ b/tests/Recipes/SSHUserRecipeTest.php @@ -0,0 +1,200 @@ +getMockBuilder(ProcessRunner::class)->disableOriginalConstructor()->getMock(); + $processRunner->expects($this->any()) + ->method('run') + ->with($this->anything(), 'cat ~/.ssh/id_rsa.pub', $this->anything()) + ->willReturn('ssh-rsa FOOBAR test@local'); + + // The constructor also resets the Deployer singleton, so we only need + // to instantiate it once. + $depl = new Deployer(new Application()); + $depl->processRunner = $processRunner; + $depl->output = new NullOutput(); + + Context::push(new Context(new Host('test'))); + + task('deploy:symlink', function () { + }); + + AppRecipe::setup(); + SSHUserRecipe::setup(); + + $this->mockClient = new MockClient($this); + $this->mockClient->app->expects($this->any()) + ->method('getAppinstallation') + ->willReturnCallback(function (GetAppinstallationRequest $req): GetAppinstallationOKResponse { + return new GetAppinstallationOKResponse( + (new AppInstallation( + 'APP_ID', + new VersionStatus('1.0.0'), + 'description', + false, + $req->getAppInstallationId(), + '/foo', + 'a-XXXXXX', + )) + ->withProjectId('PROJECT_ID'), + ); + }); + $this->mockClient->project->expects($this->any()) + ->method('getProject') + ->willReturnCallback(fn(GetProjectRequest $req): GetProjectOKResponse => new GetProjectOKResponse( + new Project( + new \DateTime(), + 'CUSTOMER_ID', + 'Description', + ['Web' => '/html'], + true, + $req->getProjectId(), + true, + ProjectReadinessStatus::ready, + 'p-XXXXXX', + ), + )); + + $depl->config->set('mittwald_client', $this->mockClient); + $depl->config->set('mittwald_token', 'TOKEN'); + $depl->config->set('mittwald_app_id', 'INSTALLATION_ID'); + $depl->config->set('ssh_copy_id', '~/.ssh/id_rsa.pub'); + } + + public function testAssertSSHUserCreatesSSHUserWhenItDoesNotExist(): void + { + $this->mockClient->sshSFTPUser->expects($this->once()) + ->method('listSshUsers') + ->willReturn(new ListSshUsersOKResponse([])); + $this->mockClient->sshSFTPUser->expects($this->once()) + ->method('createSshUser') + ->with(new Callback(function (CreateSshUserRequest $request): bool { + $sshUser = $request->getBody(); + $auth = $sshUser->getAuthentication(); + + $this->assertEquals('deployer', $sshUser->getDescription()); + $this->assertInstanceOf(AuthenticationAlternative2::class, $auth); + + $publicKeys = $auth->getPublicKeys(); + + $this->assertCount(1, $publicKeys); + $this->assertEquals('ssh-rsa FOOBAR', $publicKeys[0]->getKey()); + $this->assertEquals('deployer', $publicKeys[0]->getComment()); + return true; + })) + ->willReturnCallback(function (CreateSshUserRequest $req): CreateSshUserCreatedResponse { + return new CreateSshUserCreatedResponse( + (new SshUser( + new \DateTime(), + new \DateTime(), + $req->getBody()->getDescription(), + false, + 'SSH_USER_ID', + $req->getProjectId(), + 'ssh-YYYYYY' + )), + ); + }); + + SSHUserRecipe::assertSSHUser(); + } + + public function testAssertSSHUserUsesExistingSSHUser(): void + { + $this->mockClient->sshSFTPUser->expects($this->once()) + ->method('listSshUsers') + ->willReturn(new ListSshUsersOKResponse([ + (new SshUser( + new \DateTime(), + new \DateTime(), + 'deployer', + false, + 'SSH_USER_ID', + 'PROJECT_ID', + 'ssh-YYYYYY' + ))->withPublicKeys([ + new PublicKey('deployer', 'ssh-rsa FOOBAR'), + ]) + ])); + $this->mockClient->sshSFTPUser->expects($this->never()) + ->method('createSshUser'); + $this->mockClient->sshSFTPUser->expects($this->never()) + ->method('updateSshUser'); + + SSHUserRecipe::assertSSHUser(); + } + + public function testAssertSSHUserUpdatesExistingPublicKeys(): void + { + $this->mockClient->sshSFTPUser->expects($this->once()) + ->method('listSshUsers') + ->willReturn(new ListSshUsersOKResponse([ + (new SshUser( + new \DateTime(), + new \DateTime(), + 'deployer', + false, + 'SSH_USER_ID', + 'PROJECT_ID', + 'ssh-YYYYYY' + ))->withPublicKeys([ + new PublicKey('deployer', 'ssh-rsa BAR'), + ]) + ])); + $this->mockClient->sshSFTPUser->expects($this->never()) + ->method('createSshUser'); + $this->mockClient->sshSFTPUser->expects($this->once()) + ->method('updateSshUser') + ->with(new Callback(function (UpdateSshUserRequest $req): bool { + $keys = $req->getBody()->getPublicKeys(); + + $this->assertEquals('SSH_USER_ID', $req->getSshUserId()); + $this->assertNotNull($keys); + $this->assertCount(2, $keys); + $this->assertEquals('ssh-rsa FOOBAR', $keys[1]->getKey()); + return true; + })) + ->willReturn(new EmptyResponse(new Response())); + + SSHUserRecipe::assertSSHUser(); + } +} \ No newline at end of file From e1731731cb59ccf99e4a9c112cf966647e953472 Mon Sep 17 00:00:00 2001 From: Martin Helmich Date: Thu, 14 Dec 2023 09:39:10 +0100 Subject: [PATCH 08/10] Ignore PHPUnit result cache --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 7aaf84d..3fc435d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /vendor/ /.idea -/.phpunit.cache \ No newline at end of file +/.phpunit.cache +/.phpunit.result.cache \ No newline at end of file From 42d0ff014247cb1744d91d420aa0cadaef3a268d Mon Sep 17 00:00:00 2001 From: Martin Helmich Date: Thu, 14 Dec 2023 09:48:15 +0100 Subject: [PATCH 09/10] Refactor test setup --- composer.json | 6 ++- tests/Recipes/SSHUserRecipeTest.php | 55 ++++++------------------ tests/Recipes/TestFixture.php | 43 ++++++++++++++++++ tests/Util/SSH/SSHConfigRendererTest.php | 5 +-- tests/Util/SSH/SSHPublicKeyTest.php | 3 +- tests/Util/SanityCheckTest.php | 3 +- 6 files changed, 64 insertions(+), 51 deletions(-) create mode 100644 tests/Recipes/TestFixture.php diff --git a/composer.json b/composer.json index a4297c6..72fc906 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,8 @@ "Mittwald\\Deployer\\": "src/" }, "files": [ - "src/Util/functions.php"] + "src/Util/functions.php" + ] }, "authors": [ { @@ -28,7 +29,8 @@ }, "autoload-dev": { "psr-4": { - "Deployer\\": "vendor/deployer/deployer/src" + "Deployer\\": "vendor/deployer/deployer/src", + "Mittwald\\Deployer\\": "tests/" }, "files": [ "vendor/deployer/deployer/src/functions.php", diff --git a/tests/Recipes/SSHUserRecipeTest.php b/tests/Recipes/SSHUserRecipeTest.php index 7fc1364..e3f1ebc 100644 --- a/tests/Recipes/SSHUserRecipeTest.php +++ b/tests/Recipes/SSHUserRecipeTest.php @@ -1,12 +1,8 @@ getMockBuilder(ProcessRunner::class)->disableOriginalConstructor()->getMock(); - $processRunner->expects($this->any()) + $this->fixture = new TestFixture($this); + $this->fixture->processRunner->expects($this->any()) ->method('run') ->with($this->anything(), 'cat ~/.ssh/id_rsa.pub', $this->anything()) ->willReturn('ssh-rsa FOOBAR test@local'); - // The constructor also resets the Deployer singleton, so we only need - // to instantiate it once. - $depl = new Deployer(new Application()); - $depl->processRunner = $processRunner; - $depl->output = new NullOutput(); - - Context::push(new Context(new Host('test'))); - - task('deploy:symlink', function () { - }); - AppRecipe::setup(); SSHUserRecipe::setup(); - $this->mockClient = new MockClient($this); - $this->mockClient->app->expects($this->any()) + $this->fixture->client->app->expects($this->any()) ->method('getAppinstallation') ->willReturnCallback(function (GetAppinstallationRequest $req): GetAppinstallationOKResponse { return new GetAppinstallationOKResponse( @@ -78,7 +56,7 @@ protected function setUp(): void ->withProjectId('PROJECT_ID'), ); }); - $this->mockClient->project->expects($this->any()) + $this->fixture->client->project->expects($this->any()) ->method('getProject') ->willReturnCallback(fn(GetProjectRequest $req): GetProjectOKResponse => new GetProjectOKResponse( new Project( @@ -93,19 +71,14 @@ protected function setUp(): void 'p-XXXXXX', ), )); - - $depl->config->set('mittwald_client', $this->mockClient); - $depl->config->set('mittwald_token', 'TOKEN'); - $depl->config->set('mittwald_app_id', 'INSTALLATION_ID'); - $depl->config->set('ssh_copy_id', '~/.ssh/id_rsa.pub'); } public function testAssertSSHUserCreatesSSHUserWhenItDoesNotExist(): void { - $this->mockClient->sshSFTPUser->expects($this->once()) + $this->fixture->client->sshSFTPUser->expects($this->once()) ->method('listSshUsers') ->willReturn(new ListSshUsersOKResponse([])); - $this->mockClient->sshSFTPUser->expects($this->once()) + $this->fixture->client->sshSFTPUser->expects($this->once()) ->method('createSshUser') ->with(new Callback(function (CreateSshUserRequest $request): bool { $sshUser = $request->getBody(); @@ -140,7 +113,7 @@ public function testAssertSSHUserCreatesSSHUserWhenItDoesNotExist(): void public function testAssertSSHUserUsesExistingSSHUser(): void { - $this->mockClient->sshSFTPUser->expects($this->once()) + $this->fixture->client->sshSFTPUser->expects($this->once()) ->method('listSshUsers') ->willReturn(new ListSshUsersOKResponse([ (new SshUser( @@ -155,9 +128,9 @@ public function testAssertSSHUserUsesExistingSSHUser(): void new PublicKey('deployer', 'ssh-rsa FOOBAR'), ]) ])); - $this->mockClient->sshSFTPUser->expects($this->never()) + $this->fixture->client->sshSFTPUser->expects($this->never()) ->method('createSshUser'); - $this->mockClient->sshSFTPUser->expects($this->never()) + $this->fixture->client->sshSFTPUser->expects($this->never()) ->method('updateSshUser'); SSHUserRecipe::assertSSHUser(); @@ -165,7 +138,7 @@ public function testAssertSSHUserUsesExistingSSHUser(): void public function testAssertSSHUserUpdatesExistingPublicKeys(): void { - $this->mockClient->sshSFTPUser->expects($this->once()) + $this->fixture->client->sshSFTPUser->expects($this->once()) ->method('listSshUsers') ->willReturn(new ListSshUsersOKResponse([ (new SshUser( @@ -180,9 +153,9 @@ public function testAssertSSHUserUpdatesExistingPublicKeys(): void new PublicKey('deployer', 'ssh-rsa BAR'), ]) ])); - $this->mockClient->sshSFTPUser->expects($this->never()) + $this->fixture->client->sshSFTPUser->expects($this->never()) ->method('createSshUser'); - $this->mockClient->sshSFTPUser->expects($this->once()) + $this->fixture->client->sshSFTPUser->expects($this->once()) ->method('updateSshUser') ->with(new Callback(function (UpdateSshUserRequest $req): bool { $keys = $req->getBody()->getPublicKeys(); diff --git a/tests/Recipes/TestFixture.php b/tests/Recipes/TestFixture.php new file mode 100644 index 0000000..0b17bea --- /dev/null +++ b/tests/Recipes/TestFixture.php @@ -0,0 +1,43 @@ +processRunner = $test->getMockBuilder(ProcessRunner::class)->disableOriginalConstructor()->getMock(); + $this->client = new MockClient($test); + + // The constructor also resets the Deployer singleton, so we only need + // to instantiate it once. + $this->depl = new Deployer(new Application()); + $this->depl->processRunner = $this->processRunner; + $this->depl->output = new NullOutput(); + + Context::push(new Context(new Host('test'))); + + task('deploy:symlink', function () { + }); + + $this->depl->config->set('mittwald_client', $this->client); + $this->depl->config->set('mittwald_token', 'TOKEN'); + $this->depl->config->set('mittwald_app_id', 'INSTALLATION_ID'); + $this->depl->config->set('ssh_copy_id', '~/.ssh/id_rsa.pub'); + } +} \ No newline at end of file diff --git a/tests/Util/SSH/SSHConfigRendererTest.php b/tests/Util/SSH/SSHConfigRendererTest.php index 10abcd0..5d3ce80 100644 --- a/tests/Util/SSH/SSHConfigRendererTest.php +++ b/tests/Util/SSH/SSHConfigRendererTest.php @@ -1,10 +1,7 @@ Date: Thu, 14 Dec 2023 10:32:22 +0100 Subject: [PATCH 10/10] Add test cases for SSH key+config setup --- composer.json | 6 +- composer.lock | 267 +++++++++++++++++++++++++++- src/Recipes/BaseRecipe.php | 13 ++ src/Recipes/SSHUserRecipe.php | 18 +- src/Util/SSH/SSHConfigRenderer.php | 6 +- tests/Recipes/SSHUserRecipeTest.php | 88 +++++++++ tests/Recipes/TestFixture.php | 11 ++ 7 files changed, 394 insertions(+), 15 deletions(-) diff --git a/composer.json b/composer.json index 72fc906..f6160be 100644 --- a/composer.json +++ b/composer.json @@ -20,12 +20,14 @@ "require": { "php": "^8.2", "mittwald/api-client": "^2.0", - "composer/semver": "^3.4" + "composer/semver": "^3.4", + "league/flysystem": "^3.0" }, "require-dev": { "deployer/deployer": "^7.3", "vimeo/psalm": "^5.15", - "phpunit/phpunit": "^10.5" + "phpunit/phpunit": "^10.5", + "league/flysystem-memory": "^3.0" }, "autoload-dev": { "psr-4": { diff --git a/composer.lock b/composer.lock index 258dcf7..cfc1c0b 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "af2fc6eadefe4146c9a46510fee1f05b", + "content-hash": "f133c263572d992378650f8f65883052", "packages": [ { "name": "composer/semver", @@ -482,6 +482,212 @@ }, "time": "2023-09-26T02:20:38+00:00" }, + { + "name": "league/flysystem", + "version": "3.23.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem.git", + "reference": "d4ad81e2b67396e33dc9d7e54ec74ccf73151dcc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/d4ad81e2b67396e33dc9d7e54ec74ccf73151dcc", + "reference": "d4ad81e2b67396e33dc9d7e54ec74ccf73151dcc", + "shasum": "" + }, + "require": { + "league/flysystem-local": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "conflict": { + "async-aws/core": "<1.19.0", + "async-aws/s3": "<1.14.0", + "aws/aws-sdk-php": "3.209.31 || 3.210.0", + "guzzlehttp/guzzle": "<7.0", + "guzzlehttp/ringphp": "<1.1.1", + "phpseclib/phpseclib": "3.0.15", + "symfony/http-client": "<5.2" + }, + "require-dev": { + "async-aws/s3": "^1.5 || ^2.0", + "async-aws/simple-s3": "^1.1 || ^2.0", + "aws/aws-sdk-php": "^3.220.0", + "composer/semver": "^3.0", + "ext-fileinfo": "*", + "ext-ftp": "*", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.5", + "google/cloud-storage": "^1.23", + "microsoft/azure-storage-blob": "^1.1", + "phpseclib/phpseclib": "^3.0.34", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.5.11|^10.0", + "sabre/dav": "^4.3.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "File storage abstraction for PHP", + "keywords": [ + "WebDAV", + "aws", + "cloud", + "file", + "files", + "filesystem", + "filesystems", + "ftp", + "s3", + "sftp", + "storage" + ], + "support": { + "issues": "https://github.com/thephpleague/flysystem/issues", + "source": "https://github.com/thephpleague/flysystem/tree/3.23.0" + }, + "funding": [ + { + "url": "https://ecologi.com/frankdejonge", + "type": "custom" + }, + { + "url": "https://github.com/frankdejonge", + "type": "github" + } + ], + "time": "2023-12-04T10:16:17+00:00" + }, + { + "name": "league/flysystem-local", + "version": "3.23.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-local.git", + "reference": "5cf046ba5f059460e86a997c504dd781a39a109b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/5cf046ba5f059460e86a997c504dd781a39a109b", + "reference": "5cf046ba5f059460e86a997c504dd781a39a109b", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "league/flysystem": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\Local\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Local filesystem adapter for Flysystem.", + "keywords": [ + "Flysystem", + "file", + "files", + "filesystem", + "local" + ], + "support": { + "issues": "https://github.com/thephpleague/flysystem-local/issues", + "source": "https://github.com/thephpleague/flysystem-local/tree/3.23.0" + }, + "funding": [ + { + "url": "https://ecologi.com/frankdejonge", + "type": "custom" + }, + { + "url": "https://github.com/frankdejonge", + "type": "github" + } + ], + "time": "2023-12-04T10:14:46+00:00" + }, + { + "name": "league/mime-type-detection", + "version": "1.14.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/mime-type-detection.git", + "reference": "b6a5854368533df0295c5761a0253656a2e52d9e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/b6a5854368533df0295c5761a0253656a2e52d9e", + "reference": "b6a5854368533df0295c5761a0253656a2e52d9e", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "phpstan/phpstan": "^0.12.68", + "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\MimeTypeDetection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Mime-type detection for Flysystem", + "support": { + "issues": "https://github.com/thephpleague/mime-type-detection/issues", + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.14.0" + }, + "funding": [ + { + "url": "https://github.com/frankdejonge", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/flysystem", + "type": "tidelift" + } + ], + "time": "2023-10-17T14:13:20+00:00" + }, { "name": "mittwald/api-client", "version": "v2.0.0", @@ -1393,6 +1599,65 @@ ], "time": "2022-12-24T12:35:10+00:00" }, + { + "name": "league/flysystem-memory", + "version": "3.19.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-memory.git", + "reference": "52456fb814b25a4c44414c50a6026cd7250ce835" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-memory/zipball/52456fb814b25a4c44414c50a6026cd7250ce835", + "reference": "52456fb814b25a4c44414c50a6026cd7250ce835", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "league/flysystem": "^3.0.0", + "php": "^8.0.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\InMemory\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "In-memory filesystem adapter for Flysystem.", + "keywords": [ + "Flysystem", + "file", + "files", + "filesystem", + "memory" + ], + "support": { + "issues": "https://github.com/thephpleague/flysystem-memory/issues", + "source": "https://github.com/thephpleague/flysystem-memory/tree/3.19.0" + }, + "funding": [ + { + "url": "https://ecologi.com/frankdejonge", + "type": "custom" + }, + { + "url": "https://github.com/frankdejonge", + "type": "github" + } + ], + "time": "2023-11-07T08:50:56+00:00" + }, { "name": "myclabs/deep-copy", "version": "1.11.1", diff --git a/src/Recipes/BaseRecipe.php b/src/Recipes/BaseRecipe.php index a0554a8..08f518e 100644 --- a/src/Recipes/BaseRecipe.php +++ b/src/Recipes/BaseRecipe.php @@ -1,6 +1,8 @@ read(parse_home_dir(get_str('mittwald_ssh_public_key_file'))); } // Need to do this in case `ssh_copy_id` contains a tilde that needs to be expanded @@ -167,7 +168,7 @@ public static function assertSSHConfig(): void $sshConfig = static::buildSSHConfigForSelectedHosts(); $renderer = new SSHConfigRenderer($sshConfig); - $renderer->renderToFile(); + $renderer->renderToFile(BaseRecipe::getFilesystem()); static::assertLocalSSHPrivateKey(); @@ -200,20 +201,17 @@ private static function buildSSHConfigForSelectedHosts(): SSHConfig private static function determineSSHPrivateKeyForHost(Host $host): string { - /** @var mixed $privateKeyFile */ - $privateKeyFile = $host->get('mittwald_ssh_private_key_file'); + $privateKeyFile = get_str_nullable('mittwald_ssh_private_key_file'); if (is_string($privateKeyFile)) { return $privateKeyFile; } - /** @var mixed $privateKeyContents */ - $privateKeyContents = $host->get('mittwald_ssh_private_key'); + $privateKeyContents = get_str_nullable('mittwald_ssh_private_key'); if (is_string($privateKeyContents)) { return './.mw-deployer/id_rsa'; } - /** @var mixed $publicKeyFile */ - $publicKeyFile = $host->get('ssh_copy_id'); + $publicKeyFile = get_str_nullable('ssh_copy_id'); if (is_string($publicKeyFile)) { /** @var string $privateKeyFile */ $privateKeyFile = str_replace('.pub', '', $publicKeyFile); @@ -229,14 +227,14 @@ private static function assertLocalSSHPrivateKey(): void static::assertLocalSSHDirectory(); if (has('mittwald_ssh_private_key')) { - file_put_contents('./.mw-deployer/id_rsa', get_str('mittwald_ssh_private_key')); + BaseRecipe::getFilesystem()->write('./.mw-deployer/id_rsa', get_str('mittwald_ssh_private_key')); } } private static function assertLocalSSHDirectory(): void { if (!is_dir('./.mw-deployer')) { - mkdir('./.mw-deployer', permissions: 0755, recursive: true); + BaseRecipe::getFilesystem()->createDirectory('./.mw-deployer'); } } } \ No newline at end of file diff --git a/src/Util/SSH/SSHConfigRenderer.php b/src/Util/SSH/SSHConfigRenderer.php index e3a8f91..85cc1ee 100644 --- a/src/Util/SSH/SSHConfigRenderer.php +++ b/src/Util/SSH/SSHConfigRenderer.php @@ -3,6 +3,8 @@ namespace Mittwald\Deployer\Util\SSH; +use League\Flysystem\Filesystem; + readonly class SSHConfigRenderer { public function __construct(private SSHConfig $config) @@ -28,8 +30,8 @@ public function render(): string return $output; } - public function renderToFile(): void + public function renderToFile(Filesystem $fs): void { - file_put_contents($this->config->filename, $this->render()); + $fs->write($this->config->filename, $this->render()); } } \ No newline at end of file diff --git a/tests/Recipes/SSHUserRecipeTest.php b/tests/Recipes/SSHUserRecipeTest.php index e3f1ebc..b3fd6f4 100644 --- a/tests/Recipes/SSHUserRecipeTest.php +++ b/tests/Recipes/SSHUserRecipeTest.php @@ -23,6 +23,7 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Constraint\Callback; use PHPUnit\Framework\TestCase; +use function Deployer\set; #[CoversClass(SSHUserRecipe::class)] class SSHUserRecipeTest extends TestCase @@ -111,6 +112,47 @@ public function testAssertSSHUserCreatesSSHUserWhenItDoesNotExist(): void SSHUserRecipe::assertSSHUser(); } + public function testAssertSSHUserCreatesSSHUserWithPublicKeyFromDefinedFile(): void + { + set('mittwald_ssh_public_key_file', '/foo/id_rsa.pub'); + $this->fixture->fs->write('/foo/id_rsa.pub', 'ssh-rsa BARBAZ test@local'); + + $this->fixture->client->sshSFTPUser->expects($this->once()) + ->method('listSshUsers') + ->willReturn(new ListSshUsersOKResponse([])); + $this->fixture->client->sshSFTPUser->expects($this->once()) + ->method('createSshUser') + ->with(new Callback(function (CreateSshUserRequest $request): bool { + $sshUser = $request->getBody(); + $auth = $sshUser->getAuthentication(); + + $this->assertEquals('deployer', $sshUser->getDescription()); + $this->assertInstanceOf(AuthenticationAlternative2::class, $auth); + + $publicKeys = $auth->getPublicKeys(); + + $this->assertCount(1, $publicKeys); + $this->assertEquals('ssh-rsa BARBAZ', $publicKeys[0]->getKey()); + $this->assertEquals('deployer', $publicKeys[0]->getComment()); + return true; + })) + ->willReturnCallback(function (CreateSshUserRequest $req): CreateSshUserCreatedResponse { + return new CreateSshUserCreatedResponse( + (new SshUser( + new \DateTime(), + new \DateTime(), + $req->getBody()->getDescription(), + false, + 'SSH_USER_ID', + $req->getProjectId(), + 'ssh-YYYYYY' + )), + ); + }); + + SSHUserRecipe::assertSSHUser(); + } + public function testAssertSSHUserUsesExistingSSHUser(): void { $this->fixture->client->sshSFTPUser->expects($this->once()) @@ -170,4 +212,50 @@ public function testAssertSSHUserUpdatesExistingPublicKeys(): void SSHUserRecipe::assertSSHUser(); } + + public function testAssertSSHConfigWritesSSHConfigWithDefaultPrivateKey(): void + { + SSHUserRecipe::assertSSHConfig(); + + $this->assertTrue($this->fixture->fs->has('.mw-deployer/sshconfig')); + $this->assertEquals('Host test + HostName test.internal + StrictHostKeyChecking accept-new + IdentityFile ~/.ssh/id_rsa + +', $this->fixture->fs->read('.mw-deployer/sshconfig')); + } + + public function testAssertSSHConfigWritesSSHConfigWithPrivateKeyFile(): void + { + set('mittwald_ssh_private_key_file', '/foo/id_rsa'); + + SSHUserRecipe::assertSSHConfig(); + + $this->assertTrue($this->fixture->fs->has('.mw-deployer/sshconfig')); + $this->assertEquals('Host test + HostName test.internal + StrictHostKeyChecking accept-new + IdentityFile /foo/id_rsa + +', $this->fixture->fs->read('.mw-deployer/sshconfig')); + } + + public function testAssertSSHConfigWritesSSHConfigWithPrivateKeyContents(): void + { + set('mittwald_ssh_private_key', 'PRIVATE KEY CONTENTS'); + + SSHUserRecipe::assertSSHConfig(); + + $this->assertTrue($this->fixture->fs->has('.mw-deployer/sshconfig')); + $this->assertEquals('Host test + HostName test.internal + StrictHostKeyChecking accept-new + IdentityFile ./.mw-deployer/id_rsa + +', $this->fixture->fs->read('.mw-deployer/sshconfig')); + + $this->assertTrue($this->fixture->fs->has('.mw-deployer/id_rsa')); + $this->assertEquals('PRIVATE KEY CONTENTS', $this->fixture->fs->read('.mw-deployer/id_rsa')); + } } \ No newline at end of file diff --git a/tests/Recipes/TestFixture.php b/tests/Recipes/TestFixture.php index 0b17bea..8c88476 100644 --- a/tests/Recipes/TestFixture.php +++ b/tests/Recipes/TestFixture.php @@ -6,11 +6,14 @@ use Deployer\Deployer; use Deployer\Host\Host; use Deployer\Task\Context; +use League\Flysystem\Filesystem; +use League\Flysystem\InMemory\InMemoryFilesystemAdapter; use Mittwald\Deployer\Client\MockClient; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Application; use Symfony\Component\Console\Output\NullOutput; +use function Deployer\host; use function Deployer\task; class TestFixture @@ -18,12 +21,15 @@ class TestFixture public ProcessRunner&MockObject $processRunner; public MockClient $client; public Deployer $depl; + public Filesystem $fs; public function __construct(TestCase $test) { $this->processRunner = $test->getMockBuilder(ProcessRunner::class)->disableOriginalConstructor()->getMock(); $this->client = new MockClient($test); + $this->fs = new Filesystem(new InMemoryFilesystemAdapter()); + // The constructor also resets the Deployer singleton, so we only need // to instantiate it once. $this->depl = new Deployer(new Application()); @@ -35,9 +41,14 @@ public function __construct(TestCase $test) task('deploy:symlink', function () { }); + host('test') + ->set('mittwald_internal_hostname', 'test.internal'); + $this->depl->config->set('mittwald_client', $this->client); + $this->depl->config->set('mittwald_filesystem', $this->fs); $this->depl->config->set('mittwald_token', 'TOKEN'); $this->depl->config->set('mittwald_app_id', 'INSTALLATION_ID'); $this->depl->config->set('ssh_copy_id', '~/.ssh/id_rsa.pub'); + $this->depl->config->set('selected_hosts', ['test']); } } \ No newline at end of file