From 335061a9bbd8f22f92df71643d729d31d215fed1 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Tue, 18 Feb 2025 21:22:59 +0200 Subject: [PATCH 1/3] build: rename project to coder-toolbox - will also reflect on the jar/zip name --- settings.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/settings.gradle.kts b/settings.gradle.kts index 1685928..172ab4f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1 +1 @@ -rootProject.name = "toolbox-gateway-sample" +rootProject.name = "coder-toolbox" From 4fe83cebaf7bb22ff381a975942cbb546bd8de53 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Tue, 18 Feb 2025 21:27:44 +0200 Subject: [PATCH 2/3] chore: update license - copied from coder gateway plugin --- LICENSE | 222 +++++--------------------------------------------------- 1 file changed, 20 insertions(+), 202 deletions(-) diff --git a/LICENSE b/LICENSE index 7a4a3ea..f92ac4f 100644 --- a/LICENSE +++ b/LICENSE @@ -1,202 +1,20 @@ - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. \ No newline at end of file +The MIT License + +Copyright (c) 2019 Coder Technologies Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file From 7151981b6d1ac7f587a48ad80b61358e290deb06 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Tue, 18 Feb 2025 21:47:10 +0200 Subject: [PATCH 3/3] import code from coder/jetbrains-coder --- build.gradle.kts | 72 +- gradle/libs.versions.toml | 22 +- .../kotlin/SampleEnvironmentContentsView.kt | 12 - src/main/kotlin/SampleRemoteDevExtension.kt | 18 - src/main/kotlin/SampleRemoteEnvironment.kt | 22 - src/main/kotlin/SampleRemoteProvider.kt | 83 -- src/main/kotlin/await.kt | 22 - .../coder/toolbox/CoderGatewayExtension.kt | 28 + .../coder/toolbox/CoderRemoteEnvironment.kt | 134 +++ .../com/coder/toolbox/CoderRemoteProvider.kt | 360 ++++++++ .../com/coder/toolbox/cli/CoderCLIManager.kt | 498 +++++++++++ .../com/coder/toolbox/cli/ex/Exceptions.kt | 7 + .../toolbox/models/WorkspaceAndAgentStatus.kt | 160 ++++ .../com/coder/toolbox/sdk/CoderRestClient.kt | 258 ++++++ .../toolbox/sdk/convertors/ArchConverter.kt | 14 + .../sdk/convertors/InstantConverter.kt | 23 + .../toolbox/sdk/convertors/OSConverter.kt | 14 + .../toolbox/sdk/convertors/UUIDConverter.kt | 14 + .../toolbox/sdk/ex/APIResponseException.kt | 26 + .../coder/toolbox/sdk/v2/CoderV2RestFacade.kt | 54 ++ .../coder/toolbox/sdk/v2/models/BuildInfo.kt | 19 + .../v2/models/CreateWorkspaceBuildRequest.kt | 31 + .../coder/toolbox/sdk/v2/models/Response.kt | 17 + .../coder/toolbox/sdk/v2/models/Template.kt | 11 + .../com/coder/toolbox/sdk/v2/models/User.kt | 9 + .../coder/toolbox/sdk/v2/models/Workspace.kt | 22 + .../toolbox/sdk/v2/models/WorkspaceAgent.kt | 39 + .../toolbox/sdk/v2/models/WorkspaceBuild.kt | 29 + .../sdk/v2/models/WorkspaceResource.kt | 9 + .../sdk/v2/models/WorkspaceTransition.kt | 9 + .../sdk/v2/models/WorkspacesResponse.kt | 9 + .../toolbox/services/CoderSecretsService.kt | 29 + .../toolbox/services/CoderSettingsService.kt | 60 ++ .../coder/toolbox/settings/CoderSettings.kt | 391 +++++++++ .../com/coder/toolbox/settings/Environment.kt | 9 + .../kotlin/com/coder/toolbox/util/Dialogs.kt | 97 +++ .../kotlin/com/coder/toolbox/util/Error.kt | 34 + .../kotlin/com/coder/toolbox/util/Escape.kt | 48 ++ .../kotlin/com/coder/toolbox/util/Hash.kt | 22 + .../kotlin/com/coder/toolbox/util/Headers.kt | 59 ++ .../com/coder/toolbox/util/LinkHandler.kt | 304 +++++++ .../kotlin/com/coder/toolbox/util/LinkMap.kt | 39 + src/main/kotlin/com/coder/toolbox/util/OS.kt | 48 ++ .../com/coder/toolbox/util/PathExtensions.kt | 47 + .../kotlin/com/coder/toolbox/util/SemVer.kt | 57 ++ src/main/kotlin/com/coder/toolbox/util/TLS.kt | 248 ++++++ .../com/coder/toolbox/util/URLExtensions.kt | 32 + .../kotlin/com/coder/toolbox/util/Without.kt | 47 + .../com/coder/toolbox/views/CoderPage.kt | 101 +++ .../coder/toolbox/views/CoderSettingsPage.kt | 64 ++ .../com/coder/toolbox/views/ConnectPage.kt | 106 +++ .../coder/toolbox/views/EnvironmentView.kt | 40 + .../coder/toolbox/views/NewEnvironmentPage.kt | 16 + .../com/coder/toolbox/views/SignInPage.kt | 70 ++ .../com/coder/toolbox/views/TokenPage.kt | 63 ++ src/main/kotlin/dto.kt | 14 - ...s.toolbox.api.remoteDev.RemoteDevExtension | 2 +- src/main/resources/dependencies.json | 54 +- src/main/resources/extension.json | 12 +- src/main/resources/icon.svg | 77 +- src/main/resources/icons/create.svg | 8 + src/main/resources/icons/create_dark.svg | 8 + src/main/resources/icons/delete.svg | 7 + src/main/resources/icons/delete_dark.svg | 7 + src/main/resources/icons/homeFolder.svg | 7 + src/main/resources/icons/homeFolder_dark.svg | 7 + src/main/resources/icons/open_terminal.svg | 3 + .../resources/icons/open_terminal_dark.svg | 3 + src/main/resources/icons/run.svg | 6 + src/main/resources/icons/run_dark.svg | 6 + src/main/resources/icons/stop.svg | 6 + src/main/resources/icons/stop_dark.svg | 6 + src/main/resources/icons/unknown.svg | 6 + src/main/resources/icons/update.svg | 3 + src/main/resources/icons/update_dark.svg | 3 + src/main/resources/logo/coder_logo.svg | 80 ++ src/main/resources/logo/coder_logo_16.svg | 87 ++ .../resources/logo/coder_logo_16_dark.svg | 87 ++ src/main/resources/logo/coder_logo_dark.svg | 80 ++ src/main/resources/symbols/0.svg | 4 + src/main/resources/symbols/1.svg | 4 + src/main/resources/symbols/2.svg | 4 + src/main/resources/symbols/3.svg | 4 + src/main/resources/symbols/4.svg | 4 + src/main/resources/symbols/5.svg | 4 + src/main/resources/symbols/6.svg | 4 + src/main/resources/symbols/7.svg | 4 + src/main/resources/symbols/8.svg | 4 + src/main/resources/symbols/9.svg | 4 + src/main/resources/symbols/a.svg | 4 + src/main/resources/symbols/b.svg | 4 + src/main/resources/symbols/c.svg | 4 + src/main/resources/symbols/d.svg | 4 + src/main/resources/symbols/e.svg | 4 + src/main/resources/symbols/f.svg | 4 + src/main/resources/symbols/g.svg | 4 + src/main/resources/symbols/h.svg | 4 + src/main/resources/symbols/i.svg | 4 + src/main/resources/symbols/j.svg | 4 + src/main/resources/symbols/k.svg | 4 + src/main/resources/symbols/l.svg | 4 + src/main/resources/symbols/m.svg | 4 + src/main/resources/symbols/n.svg | 4 + src/main/resources/symbols/o.svg | 4 + src/main/resources/symbols/p.svg | 4 + src/main/resources/symbols/q.svg | 4 + src/main/resources/symbols/r.svg | 4 + src/main/resources/symbols/s.svg | 4 + src/main/resources/symbols/t.svg | 4 + src/main/resources/symbols/u.svg | 4 + src/main/resources/symbols/v.svg | 4 + src/main/resources/symbols/w.svg | 4 + src/main/resources/symbols/x.svg | 4 + src/main/resources/symbols/y.svg | 4 + src/main/resources/symbols/z.svg | 4 + src/test/fixtures/inputs/blank-newlines.conf | 3 + src/test/fixtures/inputs/blank.conf | 0 .../inputs/existing-end-no-newline.conf | 5 + src/test/fixtures/inputs/existing-end.conf | 7 + .../inputs/existing-middle-and-unrelated.conf | 13 + src/test/fixtures/inputs/existing-middle.conf | 7 + src/test/fixtures/inputs/existing-only.conf | 3 + src/test/fixtures/inputs/existing-start.conf | 7 + .../inputs/malformed-mismatched-start.conf | 3 + .../fixtures/inputs/malformed-no-end.conf | 2 + .../fixtures/inputs/malformed-no-start.conf | 2 + .../inputs/malformed-start-after-end.conf | 3 + src/test/fixtures/inputs/no-blocks.conf | 4 + src/test/fixtures/inputs/no-newline.conf | 4 + .../fixtures/inputs/no-related-blocks.conf | 10 + .../outputs/append-blank-newlines.conf | 20 + src/test/fixtures/outputs/append-blank.conf | 16 + .../fixtures/outputs/append-no-blocks.conf | 21 + .../fixtures/outputs/append-no-newline.conf | 20 + .../outputs/append-no-related-blocks.conf | 27 + .../fixtures/outputs/disable-autostart.conf | 16 + src/test/fixtures/outputs/extra-config.conf | 20 + .../outputs/header-command-windows.conf | 16 + src/test/fixtures/outputs/header-command.conf | 16 + src/test/fixtures/outputs/log-dir.conf | 16 + .../fixtures/outputs/multiple-workspaces.conf | 30 + .../outputs/no-disable-autostart.conf | 16 + .../fixtures/outputs/no-report-usage.conf | 16 + .../outputs/replace-end-no-newline.conf | 19 + src/test/fixtures/outputs/replace-end.conf | 20 + .../replace-middle-ignore-unrelated.conf | 26 + src/test/fixtures/outputs/replace-middle.conf | 20 + src/test/fixtures/outputs/replace-only.conf | 16 + src/test/fixtures/outputs/replace-start.conf | 20 + src/test/fixtures/tls/chain-intermediate.crt | 17 + src/test/fixtures/tls/chain-intermediate.key | 27 + src/test/fixtures/tls/chain-leaf.crt | 74 ++ src/test/fixtures/tls/chain-leaf.key | 28 + src/test/fixtures/tls/chain-root.crt | 17 + src/test/fixtures/tls/chain-root.key | 27 + src/test/fixtures/tls/chain.crt | 108 +++ src/test/fixtures/tls/chain.key | 28 + src/test/fixtures/tls/generate.bash | 134 +++ src/test/fixtures/tls/no-signing.crt | 18 + src/test/fixtures/tls/no-signing.key | 28 + src/test/fixtures/tls/self-signed.crt | 17 + src/test/fixtures/tls/self-signed.key | 28 + .../coder/toolbox/cli/CoderCLIManagerTest.kt | 800 ++++++++++++++++++ .../coder/toolbox/sdk/CoderRestClientTest.kt | 526 ++++++++++++ .../kotlin/com/coder/toolbox/sdk/DataGen.kt | 78 ++ .../toolbox/settings/CoderSettingsTest.kt | 405 +++++++++ .../com/coder/toolbox/util/EscapeTest.kt | 42 + .../kotlin/com/coder/toolbox/util/HashTest.kt | 18 + .../com/coder/toolbox/util/HeadersTest.kt | 74 ++ .../com/coder/toolbox/util/LinkHandlerTest.kt | 210 +++++ .../coder/toolbox/util/PathExtensionsTest.kt | 121 +++ .../com/coder/toolbox/util/SemVerTest.kt | 111 +++ .../coder/toolbox/util/URLExtensionsTest.kt | 63 ++ 173 files changed, 7972 insertions(+), 270 deletions(-) delete mode 100644 src/main/kotlin/SampleEnvironmentContentsView.kt delete mode 100644 src/main/kotlin/SampleRemoteDevExtension.kt delete mode 100644 src/main/kotlin/SampleRemoteEnvironment.kt delete mode 100644 src/main/kotlin/SampleRemoteProvider.kt delete mode 100644 src/main/kotlin/await.kt create mode 100644 src/main/kotlin/com/coder/toolbox/CoderGatewayExtension.kt create mode 100644 src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt create mode 100644 src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt create mode 100644 src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt create mode 100644 src/main/kotlin/com/coder/toolbox/cli/ex/Exceptions.kt create mode 100644 src/main/kotlin/com/coder/toolbox/models/WorkspaceAndAgentStatus.kt create mode 100644 src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt create mode 100644 src/main/kotlin/com/coder/toolbox/sdk/convertors/ArchConverter.kt create mode 100644 src/main/kotlin/com/coder/toolbox/sdk/convertors/InstantConverter.kt create mode 100644 src/main/kotlin/com/coder/toolbox/sdk/convertors/OSConverter.kt create mode 100644 src/main/kotlin/com/coder/toolbox/sdk/convertors/UUIDConverter.kt create mode 100644 src/main/kotlin/com/coder/toolbox/sdk/ex/APIResponseException.kt create mode 100644 src/main/kotlin/com/coder/toolbox/sdk/v2/CoderV2RestFacade.kt create mode 100644 src/main/kotlin/com/coder/toolbox/sdk/v2/models/BuildInfo.kt create mode 100644 src/main/kotlin/com/coder/toolbox/sdk/v2/models/CreateWorkspaceBuildRequest.kt create mode 100644 src/main/kotlin/com/coder/toolbox/sdk/v2/models/Response.kt create mode 100644 src/main/kotlin/com/coder/toolbox/sdk/v2/models/Template.kt create mode 100644 src/main/kotlin/com/coder/toolbox/sdk/v2/models/User.kt create mode 100644 src/main/kotlin/com/coder/toolbox/sdk/v2/models/Workspace.kt create mode 100644 src/main/kotlin/com/coder/toolbox/sdk/v2/models/WorkspaceAgent.kt create mode 100644 src/main/kotlin/com/coder/toolbox/sdk/v2/models/WorkspaceBuild.kt create mode 100644 src/main/kotlin/com/coder/toolbox/sdk/v2/models/WorkspaceResource.kt create mode 100644 src/main/kotlin/com/coder/toolbox/sdk/v2/models/WorkspaceTransition.kt create mode 100644 src/main/kotlin/com/coder/toolbox/sdk/v2/models/WorkspacesResponse.kt create mode 100644 src/main/kotlin/com/coder/toolbox/services/CoderSecretsService.kt create mode 100644 src/main/kotlin/com/coder/toolbox/services/CoderSettingsService.kt create mode 100644 src/main/kotlin/com/coder/toolbox/settings/CoderSettings.kt create mode 100644 src/main/kotlin/com/coder/toolbox/settings/Environment.kt create mode 100644 src/main/kotlin/com/coder/toolbox/util/Dialogs.kt create mode 100644 src/main/kotlin/com/coder/toolbox/util/Error.kt create mode 100644 src/main/kotlin/com/coder/toolbox/util/Escape.kt create mode 100644 src/main/kotlin/com/coder/toolbox/util/Hash.kt create mode 100644 src/main/kotlin/com/coder/toolbox/util/Headers.kt create mode 100644 src/main/kotlin/com/coder/toolbox/util/LinkHandler.kt create mode 100644 src/main/kotlin/com/coder/toolbox/util/LinkMap.kt create mode 100644 src/main/kotlin/com/coder/toolbox/util/OS.kt create mode 100644 src/main/kotlin/com/coder/toolbox/util/PathExtensions.kt create mode 100644 src/main/kotlin/com/coder/toolbox/util/SemVer.kt create mode 100644 src/main/kotlin/com/coder/toolbox/util/TLS.kt create mode 100644 src/main/kotlin/com/coder/toolbox/util/URLExtensions.kt create mode 100644 src/main/kotlin/com/coder/toolbox/util/Without.kt create mode 100644 src/main/kotlin/com/coder/toolbox/views/CoderPage.kt create mode 100644 src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt create mode 100644 src/main/kotlin/com/coder/toolbox/views/ConnectPage.kt create mode 100644 src/main/kotlin/com/coder/toolbox/views/EnvironmentView.kt create mode 100644 src/main/kotlin/com/coder/toolbox/views/NewEnvironmentPage.kt create mode 100644 src/main/kotlin/com/coder/toolbox/views/SignInPage.kt create mode 100644 src/main/kotlin/com/coder/toolbox/views/TokenPage.kt delete mode 100644 src/main/kotlin/dto.kt create mode 100644 src/main/resources/icons/create.svg create mode 100644 src/main/resources/icons/create_dark.svg create mode 100644 src/main/resources/icons/delete.svg create mode 100644 src/main/resources/icons/delete_dark.svg create mode 100644 src/main/resources/icons/homeFolder.svg create mode 100644 src/main/resources/icons/homeFolder_dark.svg create mode 100644 src/main/resources/icons/open_terminal.svg create mode 100644 src/main/resources/icons/open_terminal_dark.svg create mode 100644 src/main/resources/icons/run.svg create mode 100644 src/main/resources/icons/run_dark.svg create mode 100644 src/main/resources/icons/stop.svg create mode 100644 src/main/resources/icons/stop_dark.svg create mode 100644 src/main/resources/icons/unknown.svg create mode 100644 src/main/resources/icons/update.svg create mode 100644 src/main/resources/icons/update_dark.svg create mode 100644 src/main/resources/logo/coder_logo.svg create mode 100644 src/main/resources/logo/coder_logo_16.svg create mode 100644 src/main/resources/logo/coder_logo_16_dark.svg create mode 100644 src/main/resources/logo/coder_logo_dark.svg create mode 100644 src/main/resources/symbols/0.svg create mode 100644 src/main/resources/symbols/1.svg create mode 100644 src/main/resources/symbols/2.svg create mode 100644 src/main/resources/symbols/3.svg create mode 100644 src/main/resources/symbols/4.svg create mode 100644 src/main/resources/symbols/5.svg create mode 100644 src/main/resources/symbols/6.svg create mode 100644 src/main/resources/symbols/7.svg create mode 100644 src/main/resources/symbols/8.svg create mode 100644 src/main/resources/symbols/9.svg create mode 100644 src/main/resources/symbols/a.svg create mode 100644 src/main/resources/symbols/b.svg create mode 100644 src/main/resources/symbols/c.svg create mode 100644 src/main/resources/symbols/d.svg create mode 100644 src/main/resources/symbols/e.svg create mode 100644 src/main/resources/symbols/f.svg create mode 100644 src/main/resources/symbols/g.svg create mode 100644 src/main/resources/symbols/h.svg create mode 100644 src/main/resources/symbols/i.svg create mode 100644 src/main/resources/symbols/j.svg create mode 100644 src/main/resources/symbols/k.svg create mode 100644 src/main/resources/symbols/l.svg create mode 100644 src/main/resources/symbols/m.svg create mode 100644 src/main/resources/symbols/n.svg create mode 100644 src/main/resources/symbols/o.svg create mode 100644 src/main/resources/symbols/p.svg create mode 100644 src/main/resources/symbols/q.svg create mode 100644 src/main/resources/symbols/r.svg create mode 100644 src/main/resources/symbols/s.svg create mode 100644 src/main/resources/symbols/t.svg create mode 100644 src/main/resources/symbols/u.svg create mode 100644 src/main/resources/symbols/v.svg create mode 100644 src/main/resources/symbols/w.svg create mode 100644 src/main/resources/symbols/x.svg create mode 100644 src/main/resources/symbols/y.svg create mode 100644 src/main/resources/symbols/z.svg create mode 100644 src/test/fixtures/inputs/blank-newlines.conf create mode 100644 src/test/fixtures/inputs/blank.conf create mode 100644 src/test/fixtures/inputs/existing-end-no-newline.conf create mode 100644 src/test/fixtures/inputs/existing-end.conf create mode 100644 src/test/fixtures/inputs/existing-middle-and-unrelated.conf create mode 100644 src/test/fixtures/inputs/existing-middle.conf create mode 100644 src/test/fixtures/inputs/existing-only.conf create mode 100644 src/test/fixtures/inputs/existing-start.conf create mode 100644 src/test/fixtures/inputs/malformed-mismatched-start.conf create mode 100644 src/test/fixtures/inputs/malformed-no-end.conf create mode 100644 src/test/fixtures/inputs/malformed-no-start.conf create mode 100644 src/test/fixtures/inputs/malformed-start-after-end.conf create mode 100644 src/test/fixtures/inputs/no-blocks.conf create mode 100644 src/test/fixtures/inputs/no-newline.conf create mode 100644 src/test/fixtures/inputs/no-related-blocks.conf create mode 100644 src/test/fixtures/outputs/append-blank-newlines.conf create mode 100644 src/test/fixtures/outputs/append-blank.conf create mode 100644 src/test/fixtures/outputs/append-no-blocks.conf create mode 100644 src/test/fixtures/outputs/append-no-newline.conf create mode 100644 src/test/fixtures/outputs/append-no-related-blocks.conf create mode 100644 src/test/fixtures/outputs/disable-autostart.conf create mode 100644 src/test/fixtures/outputs/extra-config.conf create mode 100644 src/test/fixtures/outputs/header-command-windows.conf create mode 100644 src/test/fixtures/outputs/header-command.conf create mode 100644 src/test/fixtures/outputs/log-dir.conf create mode 100644 src/test/fixtures/outputs/multiple-workspaces.conf create mode 100644 src/test/fixtures/outputs/no-disable-autostart.conf create mode 100644 src/test/fixtures/outputs/no-report-usage.conf create mode 100644 src/test/fixtures/outputs/replace-end-no-newline.conf create mode 100644 src/test/fixtures/outputs/replace-end.conf create mode 100644 src/test/fixtures/outputs/replace-middle-ignore-unrelated.conf create mode 100644 src/test/fixtures/outputs/replace-middle.conf create mode 100644 src/test/fixtures/outputs/replace-only.conf create mode 100644 src/test/fixtures/outputs/replace-start.conf create mode 100644 src/test/fixtures/tls/chain-intermediate.crt create mode 100644 src/test/fixtures/tls/chain-intermediate.key create mode 100644 src/test/fixtures/tls/chain-leaf.crt create mode 100644 src/test/fixtures/tls/chain-leaf.key create mode 100644 src/test/fixtures/tls/chain-root.crt create mode 100644 src/test/fixtures/tls/chain-root.key create mode 100644 src/test/fixtures/tls/chain.crt create mode 100644 src/test/fixtures/tls/chain.key create mode 100755 src/test/fixtures/tls/generate.bash create mode 100644 src/test/fixtures/tls/no-signing.crt create mode 100644 src/test/fixtures/tls/no-signing.key create mode 100644 src/test/fixtures/tls/self-signed.crt create mode 100644 src/test/fixtures/tls/self-signed.key create mode 100644 src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt create mode 100644 src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt create mode 100644 src/test/kotlin/com/coder/toolbox/sdk/DataGen.kt create mode 100644 src/test/kotlin/com/coder/toolbox/settings/CoderSettingsTest.kt create mode 100644 src/test/kotlin/com/coder/toolbox/util/EscapeTest.kt create mode 100644 src/test/kotlin/com/coder/toolbox/util/HashTest.kt create mode 100644 src/test/kotlin/com/coder/toolbox/util/HeadersTest.kt create mode 100644 src/test/kotlin/com/coder/toolbox/util/LinkHandlerTest.kt create mode 100644 src/test/kotlin/com/coder/toolbox/util/PathExtensionsTest.kt create mode 100644 src/test/kotlin/com/coder/toolbox/util/SemVerTest.kt create mode 100644 src/test/kotlin/com/coder/toolbox/util/URLExtensionsTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index 4237936..e0ca598 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -11,6 +11,7 @@ plugins { alias(libs.plugins.serialization) `java-library` alias(libs.plugins.dependency.license.report) + alias(libs.plugins.ksp) alias(libs.plugins.gradle.wrapper) } @@ -37,8 +38,17 @@ jvmWrapper { dependencies { compileOnly(libs.bundles.toolbox.plugin.api) + implementation(libs.slf4j) + implementation(libs.tinylog) implementation(libs.bundles.serialization) implementation(libs.coroutines.core) + implementation(libs.okhttp) + implementation(libs.exec) + implementation(libs.moshi) + ksp(libs.moshi.codegen) + implementation(libs.retrofit) + implementation(libs.retrofit.moshi) + testImplementation(kotlin("test")) } licenseReport { @@ -52,7 +62,11 @@ tasks.compileKotlin { compilerOptions.jvmTarget.set(JvmTarget.JVM_21) } -val pluginId = "com.jetbrains.toolbox.sample" +tasks.test { + useJUnitPlatform() +} + +val pluginId = "com.coder.toolbox" val pluginVersion = "0.0.1" val assemblePlugin by tasks.registering(Jar::class) { @@ -63,6 +77,38 @@ val assemblePlugin by tasks.registering(Jar::class) { val copyPlugin by tasks.creating(Sync::class.java) { dependsOn(assemblePlugin) + from(assemblePlugin.get().outputs.files) + from("src/main/resources") { + include("extension.json") + include("dependencies.json") + include("icon.svg") + } + + // Copy dependencies, excluding those provided by Toolbox. + from( + configurations.compileClasspath.map { configuration -> + configuration.files.filterNot { file -> + listOf( + "kotlin", + "remote-dev-api", + "core-api", + "ui-api", + "annotations", + ).any { file.name.contains(it) } + } + }, + ) + + into(getPluginInstallDir()) +} + +tasks.register("cleanAll", Delete::class.java) { + dependsOn(tasks.clean) + delete(getPluginInstallDir()) + delete() +} + +private fun getPluginInstallDir(): Path { val userHome = System.getProperty("user.home").let { Path.of(it) } val toolboxCachesDir = when { SystemInfoRt.isWindows -> System.getenv("LOCALAPPDATA")?.let { Path.of(it) } ?: (userHome / "AppData" / "Local") @@ -78,18 +124,7 @@ val copyPlugin by tasks.creating(Sync::class.java) { else -> error("Unknown os") } / "plugins" - val targetDir = pluginsDir / pluginId - - from(assemblePlugin.get().outputs.files) - - from("src/main/resources") { - include("extension.json") - include("dependencies.json") - include("icon.svg") - } - - into(targetDir) - + return pluginsDir / pluginId } val pluginZip by tasks.creating(Zip::class) { @@ -119,4 +154,13 @@ val uploadPlugin by tasks.creating { // subsequent updates instance.uploader.upload(pluginId, pluginZip.outputs.files.singleFile) } -} \ No newline at end of file +} + +// For use with kotlin-language-server. +tasks.register("classpath") { + doFirst { + File("classpath").writeText( + sourceSets["main"].runtimeClasspath.asPath + ) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c63937b..bed6d8a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,10 +3,16 @@ toolbox-plugin-api = "0.6.2.6.0.37447" kotlin = "2.0.10" coroutines = "1.7.3" serialization = "1.5.0" +okhttp = "4.10.0" +slf4j = "2.0.3" +tinylog = "2.7.0" dependency-license-report = "2.5" marketplace-client = "2.0.38" gradle-wrapper = "0.14.0" - +exec = "1.12" +moshi = "1.15.1" +ksp = "2.0.10-1.0.24" +retrofit = "2.8.2" [libraries] toolbox-core-api = { module = "com.jetbrains.toolbox:core-api", version.ref = "toolbox-plugin-api" } @@ -15,15 +21,25 @@ toolbox-remote-dev-api = { module = "com.jetbrains.toolbox:remote-dev-api", vers coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "serialization" } serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" } +serialization-json-okio = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json-okio", version.ref = "serialization" } +okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +slf4j = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } +tinylog = {module = "org.tinylog:slf4j-tinylog", version.ref = "tinylog"} +exec = { module = "org.zeroturnaround:zt-exec", version.ref = "exec" } +moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi"} +moshi-codegen = { module = "com.squareup.moshi:moshi-kotlin-codegen", version.ref = "moshi"} +retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit"} +retrofit-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "retrofit"} marketplace-client = { module = "org.jetbrains.intellij:plugin-repository-rest-client", version.ref = "marketplace-client" } [bundles] -serialization = [ "serialization-core", "serialization-json" ] +serialization = [ "serialization-core", "serialization-json", "serialization-json-okio" ] toolbox-plugin-api = [ "toolbox-core-api", "toolbox-ui-api", "toolbox-remote-dev-api" ] [plugins] kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } dependency-license-report = { id = "com.github.jk1.dependency-license-report", version.ref = "dependency-license-report" } -gradle-wrapper = { id = "me.filippov.gradle.jvm.wrapper", version.ref = "gradle-wrapper" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp"} +gradle-wrapper = { id = "me.filippov.gradle.jvm.wrapper", version.ref = "gradle-wrapper" } \ No newline at end of file diff --git a/src/main/kotlin/SampleEnvironmentContentsView.kt b/src/main/kotlin/SampleEnvironmentContentsView.kt deleted file mode 100644 index 01e2cc9..0000000 --- a/src/main/kotlin/SampleEnvironmentContentsView.kt +++ /dev/null @@ -1,12 +0,0 @@ -package toolbox.gateway.sample - -import com.jetbrains.toolbox.api.remoteDev.environments.ManualEnvironmentContentsView - - -class SampleEnvironmentContentsView : ManualEnvironmentContentsView { - override fun addEnvironmentContentsListener(listener: ManualEnvironmentContentsView.Listener) { - } - - override fun removeEnvironmentContentsListener(listener: ManualEnvironmentContentsView.Listener) { - } -} \ No newline at end of file diff --git a/src/main/kotlin/SampleRemoteDevExtension.kt b/src/main/kotlin/SampleRemoteDevExtension.kt deleted file mode 100644 index f98cb80..0000000 --- a/src/main/kotlin/SampleRemoteDevExtension.kt +++ /dev/null @@ -1,18 +0,0 @@ -package toolbox.gateway.sample - -import com.jetbrains.toolbox.api.core.ServiceLocator -import com.jetbrains.toolbox.api.remoteDev.RemoteDevExtension -import com.jetbrains.toolbox.api.remoteDev.RemoteEnvironmentConsumer -import com.jetbrains.toolbox.api.remoteDev.RemoteProvider -import kotlinx.coroutines.CoroutineScope - -class SampleRemoteDevExtension : RemoteDevExtension { - override fun createRemoteProviderPluginInstance(serviceLocator: ServiceLocator): RemoteProvider { - return SampleRemoteProvider( -// serviceLocator.getService(OkHttpClient::class.java), - serviceLocator.getService(RemoteEnvironmentConsumer::class.java), - serviceLocator.getService(CoroutineScope::class.java), - serviceLocator - ) - } -} \ No newline at end of file diff --git a/src/main/kotlin/SampleRemoteEnvironment.kt b/src/main/kotlin/SampleRemoteEnvironment.kt deleted file mode 100644 index 3ee183b..0000000 --- a/src/main/kotlin/SampleRemoteEnvironment.kt +++ /dev/null @@ -1,22 +0,0 @@ -package toolbox.gateway.sample - -import com.jetbrains.toolbox.api.remoteDev.AbstractRemoteProviderEnvironment -import com.jetbrains.toolbox.api.remoteDev.EnvironmentVisibilityState -import com.jetbrains.toolbox.api.remoteDev.environments.EnvironmentContentsView -import java.util.concurrent.CompletableFuture - -class SampleRemoteEnvironment( - private val environment: EnvironmentDTO -) : AbstractRemoteProviderEnvironment() { - override fun getId(): String = environment.id - override fun getName(): String = environment.name - override fun getContentsView(): CompletableFuture { - return CompletableFuture.completedFuture(SampleEnvironmentContentsView()) - } - - override fun setVisible(visibilityState: EnvironmentVisibilityState) { - } - - override fun onDelete() { - } -} \ No newline at end of file diff --git a/src/main/kotlin/SampleRemoteProvider.kt b/src/main/kotlin/SampleRemoteProvider.kt deleted file mode 100644 index a725b6d..0000000 --- a/src/main/kotlin/SampleRemoteProvider.kt +++ /dev/null @@ -1,83 +0,0 @@ -package toolbox.gateway.sample - -import com.jetbrains.toolbox.api.core.ServiceLocator -import com.jetbrains.toolbox.api.core.diagnostics.Logger -import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon -import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState -import com.jetbrains.toolbox.api.remoteDev.RemoteEnvironmentConsumer -import com.jetbrains.toolbox.api.remoteDev.RemoteProvider -import kotlinx.coroutines.* -import kotlinx.serialization.json.Json -//import kotlinx.serialization.json.okio.decodeFromBufferedSource -//import okhttp3.OkHttpClient -//import okhttp3.Request -import org.intellij.lang.annotations.Language -import java.net.URI -import kotlin.time.Duration.Companion.seconds - -class SampleRemoteProvider( -// private val httpClient: OkHttpClient, - private val consumer: RemoteEnvironmentConsumer, - coroutineScope: CoroutineScope, - serviceLocator: ServiceLocator, -) : RemoteProvider { - private val logger = serviceLocator.getService(Logger::class.java) - - init { - coroutineScope.launch { -// val request = Request.Builder() -// .get() -// .url("https://my.awesome.control.server/some/logical/path/gateway.json") -//// .cacheControl(CacheControl.FORCE_NETWORK) -// .build() - while (true) { - try { - logger.debug("Updating remote environments for Sample Plugin") -// val response = httpClient.newCall(request).await() -// val body = response.body ?: continue - @Language("json") - val body = """ - { - "environments": [ - { - "id": "lol.kek.azaza", - "name": "My shiny new environment" - } - ] - } - """.trimIndent() - val dto = Json.decodeFromString(EnvironmentsDTO.serializer(), body) - try { - consumer.consumeEnvironments(dto.environments.map { SampleRemoteEnvironment(it) }, true) - } catch (_: CancellationException) { - logger.debug("Environments update cancelled") - break - } - } catch (e: Exception) { - logger.warn("Failed to retrieve environments: ${e.message}") - } - // only for demo purposes! - delay(3.seconds) - } - } - } - - override fun close() {} - - override fun getName(): String = "Sample Provider" - override fun getSvgIcon(): SvgIcon { - return SvgIcon(this::class.java.getResourceAsStream("/icon.svg")?.readAllBytes() ?: byteArrayOf()) - } - - override fun canCreateNewEnvironments(): Boolean = true - override fun isSingleEnvironment(): Boolean = false - - override fun setVisible(visibilityState: ProviderVisibilityState) {} - - override fun addEnvironmentsListener(listener: RemoteEnvironmentConsumer) {} - override fun removeEnvironmentsListener(listener: RemoteEnvironmentConsumer) {} - - override fun handleUri(uri: URI) { - logger.debug { "External request: $uri" } - } -} diff --git a/src/main/kotlin/await.kt b/src/main/kotlin/await.kt deleted file mode 100644 index 5b8640f..0000000 --- a/src/main/kotlin/await.kt +++ /dev/null @@ -1,22 +0,0 @@ -//package toolbox.gateway.sample -// -//import kotlinx.coroutines.suspendCancellableCoroutine -//import okhttp3.Call -//import okhttp3.Callback -//import okhttp3.Response -//import java.io.IOException -// -//suspend fun Call.await(): Response = suspendCancellableCoroutine { continuation -> -// enqueue(object : Callback { -// override fun onResponse(call: Call, response: Response) { -// continuation.resumeWith(Result.success(response)) -// } -// override fun onFailure(call: Call, e: IOException) { -// if (continuation.isCancelled) return -// continuation.resumeWith(Result.failure(e)) -// } -// }) -// continuation.invokeOnCancellation { -// try { cancel() } catch (_: Exception) { } -// } -//} diff --git a/src/main/kotlin/com/coder/toolbox/CoderGatewayExtension.kt b/src/main/kotlin/com/coder/toolbox/CoderGatewayExtension.kt new file mode 100644 index 0000000..8a99e70 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/CoderGatewayExtension.kt @@ -0,0 +1,28 @@ +package com.coder.toolbox + +import com.jetbrains.toolbox.api.core.PluginSecretStore +import com.jetbrains.toolbox.api.core.PluginSettingsStore +import com.jetbrains.toolbox.api.core.ServiceLocator +import com.jetbrains.toolbox.api.remoteDev.RemoteDevExtension +import com.jetbrains.toolbox.api.remoteDev.RemoteEnvironmentConsumer +import com.jetbrains.toolbox.api.remoteDev.RemoteProvider +import com.jetbrains.toolbox.api.ui.ToolboxUi +import kotlinx.coroutines.CoroutineScope +import okhttp3.OkHttpClient + +/** + * Entry point into the extension. + */ +class CoderGatewayExtension : RemoteDevExtension { + // All services must be passed in here and threaded as necessary. + override fun createRemoteProviderPluginInstance(serviceLocator: ServiceLocator): RemoteProvider { + return CoderRemoteProvider( + OkHttpClient(), + serviceLocator.getService(RemoteEnvironmentConsumer::class.java), + serviceLocator.getService(CoroutineScope::class.java), + serviceLocator.getService(ToolboxUi::class.java), + serviceLocator.getService(PluginSettingsStore::class.java), + serviceLocator.getService(PluginSecretStore::class.java), + ) + } +} diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt new file mode 100644 index 0000000..35087c6 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -0,0 +1,134 @@ +package com.coder.toolbox + +import com.coder.toolbox.models.WorkspaceAndAgentStatus +import com.coder.toolbox.sdk.CoderRestClient +import com.coder.toolbox.sdk.v2.models.Workspace +import com.coder.toolbox.sdk.v2.models.WorkspaceAgent +import com.coder.toolbox.views.Action +import com.coder.toolbox.views.EnvironmentView +import com.jetbrains.toolbox.api.remoteDev.AbstractRemoteProviderEnvironment +import com.jetbrains.toolbox.api.remoteDev.EnvironmentVisibilityState +import com.jetbrains.toolbox.api.remoteDev.environments.EnvironmentContentsView +import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateConsumer +import com.jetbrains.toolbox.api.ui.ToolboxUi +import java.util.concurrent.CompletableFuture + +/** + * Represents an agent and workspace combination. + * + * Used in the environment list view. + */ +class CoderRemoteEnvironment( + private val client: CoderRestClient, + private var workspace: Workspace, + private var agent: WorkspaceAgent, + private val ui: ToolboxUi, +) : AbstractRemoteProviderEnvironment() { + override fun getId(): String = "${workspace.name}.${agent.name}" + override fun getName(): String = "${workspace.name}.${agent.name}" + private var status = WorkspaceAndAgentStatus.from(workspace, agent) + + init { + actionsList.add( + Action("Open web terminal") { + // TODO - check this later +// ui.openUrl(client.url.withPath("/${workspace.ownerName}/$name/terminal").toString()) + }, + ) + actionsList.add( + Action("Open in dashboard") { + // TODO - check this later +// ui.openUrl(client.url.withPath("/@${workspace.ownerName}/${workspace.name}").toString()) + }, + ) + actionsList.add( + Action("View template") { + // TODO - check this later +// ui.openUrl(client.url.withPath("/templates/${workspace.templateName}").toString()) + }, + ) + actionsList.add( + Action("Start", enabled = { status.canStart() }) { + val build = client.startWorkspace(workspace) + workspace = workspace.copy(latestBuild = build) + update(workspace, agent) + }, + ) + actionsList.add( + Action("Stop", enabled = { status.ready() || status.pending() }) { + val build = client.stopWorkspace(workspace) + workspace = workspace.copy(latestBuild = build) + update(workspace, agent) + }, + ) + actionsList.add( + Action("Update", enabled = { workspace.outdated }) { + val build = client.updateWorkspace(workspace) + workspace = workspace.copy(latestBuild = build) + update(workspace, agent) + }, + ) + } + + /** + * Update the workspace/agent status to the listeners, if it has changed. + */ + fun update(workspace: Workspace, agent: WorkspaceAgent) { + this.workspace = workspace + this.agent = agent + val newStatus = WorkspaceAndAgentStatus.from(workspace, agent) + if (newStatus != status) { + status = newStatus + val state = status.toRemoteEnvironmentState() + listenerSet.forEach { it.consume(state) } + } + } + + /** + * The contents are provided by the SSH view provided by Toolbox, all we + * have to do is provide it a host name. + */ + override fun getContentsView(): CompletableFuture = + CompletableFuture.completedFuture(EnvironmentView(client.url, workspace, agent)) + + /** + * Does nothing. In theory we could do something like start the workspace + * when you click into the workspace but you would still need to press + * "connect" anyway before the content is populated so there does not seem + * to be much value. + */ + override fun setVisible(visibilityState: EnvironmentVisibilityState) {} + + /** + * Immediately send the state to the listener and store for updates. + */ + override fun addStateListener(consumer: EnvironmentStateConsumer): Boolean { + // TODO@JB: It would be ideal if we could have the workspace state and + // the connected state listed separately, since right now the + // connected state can mask the workspace state. + // TODO@JB: You can still press connect if the environment is + // unreachable. Is that expected? + consumer.consume(status.toRemoteEnvironmentState()) + return super.addStateListener(consumer) + } + + override fun onDelete() { + throw NotImplementedError() + } + + /** + * An environment is equal if it has the same ID. + */ + override fun equals(other: Any?): Boolean { + if (other == null) return false + if (this === other) return true // Note the triple === + if (other !is CoderRemoteEnvironment) return false + if (getId() != other.getId()) return false + return true + } + + /** + * Companion to equals, for sets. + */ + override fun hashCode(): Int = getId().hashCode() +} diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt new file mode 100644 index 0000000..8929d9c --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -0,0 +1,360 @@ +package com.coder.toolbox + +import com.coder.toolbox.cli.CoderCLIManager +import com.coder.toolbox.sdk.CoderRestClient +import com.coder.toolbox.sdk.v2.models.WorkspaceStatus +import com.coder.toolbox.services.CoderSecretsService +import com.coder.toolbox.services.CoderSettingsService +import com.coder.toolbox.settings.CoderSettings +import com.coder.toolbox.settings.Source +import com.coder.toolbox.util.DialogUi +import com.coder.toolbox.util.LinkHandler +import com.coder.toolbox.util.toQueryParameters +import com.coder.toolbox.views.Action +import com.coder.toolbox.views.CoderSettingsPage +import com.coder.toolbox.views.ConnectPage +import com.coder.toolbox.views.NewEnvironmentPage +import com.coder.toolbox.views.SignInPage +import com.coder.toolbox.views.TokenPage +import com.jetbrains.toolbox.api.core.PluginSecretStore +import com.jetbrains.toolbox.api.core.PluginSettingsStore +import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon +import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState +import com.jetbrains.toolbox.api.remoteDev.RemoteEnvironmentConsumer +import com.jetbrains.toolbox.api.remoteDev.RemoteProvider +import com.jetbrains.toolbox.api.ui.ToolboxUi +import com.jetbrains.toolbox.api.ui.actions.RunnableActionDescription +import com.jetbrains.toolbox.api.ui.components.AccountDropdownField +import com.jetbrains.toolbox.api.ui.components.UiPage +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +import org.slf4j.LoggerFactory +import java.net.URI +import java.net.URL +import kotlin.coroutines.cancellation.CancellationException +import kotlin.time.Duration.Companion.seconds + +class CoderRemoteProvider( + private val httpClient: OkHttpClient, + private val consumer: RemoteEnvironmentConsumer, + private val coroutineScope: CoroutineScope, + private val ui: ToolboxUi, + settingsStore: PluginSettingsStore, + secretsStore: PluginSecretStore, +) : RemoteProvider { + private val logger = LoggerFactory.getLogger(javaClass) + + // Current polling job. + private var pollJob: Job? = null + private var lastEnvironments: Set? = null + + // Create our services from the Toolbox ones. + private val settingsService = CoderSettingsService(settingsStore) + private val settings: CoderSettings = CoderSettings(settingsService) + private val secrets: CoderSecretsService = CoderSecretsService(secretsStore) + private val settingsPage: CoderSettingsPage = CoderSettingsPage(settingsService) + private val dialogUi = DialogUi(settings, ui) + private val linkHandler = LinkHandler(settings, httpClient, dialogUi) + + // The REST client, if we are signed in. + private var client: CoderRestClient? = null + + // If we have an error in the polling we store it here before going back to + // sign-in page, so we can display it there. This is mainly because there + // does not seem to be a mechanism to show errors on the environment list. + private var pollError: Exception? = null + + // On the first load, automatically log in if we can. + private var firstRun = true + + /** + * With the provided client, start polling for workspaces. Every time a new + * workspace is added, reconfigure SSH using the provided cli (including the + * first time). + */ + private fun poll(client: CoderRestClient, cli: CoderCLIManager): Job = coroutineScope.launch { + while (isActive) { + try { + logger.debug("Fetching workspace agents from {}", client.url) + val environments = client.workspaces().flatMap { ws -> + // Agents are not included in workspaces that are off + // so fetch them separately. + when (ws.latestBuild.status) { + WorkspaceStatus.RUNNING -> ws.latestBuild.resources + else -> emptyList() + }.ifEmpty { + client.resources(ws) + }.flatMap { resource -> + resource.agents?.distinctBy { + // There can be duplicates with coder_agent_instance. + // TODO: Can we just choose one or do they hold + // different information? + it.name + }?.map { agent -> + // If we have an environment already, update that. + val env = CoderRemoteEnvironment(client, ws, agent, ui) + lastEnvironments?.firstOrNull { it == env }?.let { + it.update(ws, agent) + it + } ?: env + } ?: emptyList() + } + }.toSet() + + // In case we logged out while running the query. + if (!isActive) { + return@launch + } + + // Reconfigure if a new environment is found. + // TODO@JB: Should we use the add/remove listeners instead? + val newEnvironments = lastEnvironments + ?.let { environments.subtract(it) } + ?: environments + if (newEnvironments.isNotEmpty()) { + logger.info("Found new environment(s), reconfiguring CLI: {}", newEnvironments) + cli.configSsh(newEnvironments.map { it.name }.toSet()) + } + + consumer.consumeEnvironments(environments, true) + + lastEnvironments = environments + } catch (_: CancellationException) { + logger.debug("{} polling loop canceled", client.url) + break + } catch (ex: Exception) { + logger.info("setting exception $ex") + pollError = ex + logout() + break + } + // TODO: Listening on a web socket might be better? + delay(5.seconds) + } + } + + /** + * Stop polling, clear the client and environments, then go back to the + * first page. + */ + private fun logout() { + // Keep the URL and token to make it easy to log back in, but set + // rememberMe to false so we do not try to automatically log in. + secrets.rememberMe = "false" + close() + reset() + } + + /** + * A dropdown that appears at the top of the environment list to the right. + */ + override fun getAccountDropDown(): AccountDropdownField? { + val username = client?.me?.username + if (username != null) { + return AccountDropdownField(username, Runnable { logout() }) + } + return null + } + + /** + * List of actions that appear next to the account. + */ + override fun getAdditionalPluginActions(): List = listOf( + Action("Settings", closesPage = false) { + ui.showUiPage(settingsPage) + }, + ) + + /** + * Cancel polling and clear the client and environments. + * + * Called as part of our own logout but it is unclear where it is called by + * Toolbox. Maybe on uninstall? + */ + override fun close() { + pollJob?.cancel() + client = null + lastEnvironments = null + consumer.consumeEnvironments(emptyList(), true) + } + + override fun getName(): String = "Coder Gateway" + override fun getSvgIcon(): SvgIcon = + SvgIcon(this::class.java.getResourceAsStream("/icon.svg")?.readAllBytes() ?: byteArrayOf()) + + override fun getNoEnvironmentsSvgIcon(): ByteArray = + this::class.java.getResourceAsStream("/icon.svg")?.readAllBytes() ?: byteArrayOf() + + /** + * TODO@JB: It would be nice to show "loading workspaces" at first but it + * appears to be only called once. + */ + override fun getNoEnvironmentsDescription(): String = "No workspaces yet" + + /** + * TODO@JB: Supposedly, setting this to false causes the new environment + * page to not show but it shows anyway. For now we have it + * displaying the deployment URL, which is actually useful, so if + * this changes it would be nice to have a new spot to show the + * URL. + */ + override fun canCreateNewEnvironments(): Boolean = false + + /** + * Just displays the deployment URL at the moment, but we could use this as + * a form for creating new environments. + */ + override fun getNewEnvironmentUiPage(): UiPage = NewEnvironmentPage(client?.url?.toString()) + + /** + * We always show a list of environments. + */ + override fun isSingleEnvironment(): Boolean = false + + /** + * TODO: Possibly a good idea to start/stop polling based on visibility, at + * the cost of momentarily stale data. It would not be bad if we had + * a place to put a timer ("last updated 10 seconds ago" for example) + * and a manual refresh button. + */ + override fun setVisible(visibilityState: ProviderVisibilityState) {} + + /** + * Ignored; unsure if we should use this over the consumer we get passed in. + */ + override fun addEnvironmentsListener(listener: RemoteEnvironmentConsumer) {} + + /** + * Ignored; unsure if we should use this over the consumer we get passed in. + */ + override fun removeEnvironmentsListener(listener: RemoteEnvironmentConsumer) {} + + /** + * Handle incoming links (like from the dashboard). + */ + override fun handleUri(uri: URI) { + val params = uri.toQueryParameters() + val name = linkHandler.handle(params) + // TODO@JB: Now what? How do we actually connect this workspace? + logger.debug("External request for {}: {}", name, uri) + } + + /** + * Make Toolbox ask for the page again. Use any time we need to change the + * root page (for example, sign-in or the environment list). + * + * When moving between related pages, instead use ui.showUiPage() and + * ui.hideUiPage() which stacks and has built-in back navigation, rather + * than using multiple root pages. + */ + private fun reset() { + // TODO - check this later +// ui.showPluginEnvironmentsPage() + } + + /** + * Return the sign-in page if we do not have a valid client. + + * Otherwise return null, which causes Toolbox to display the environment + * list. + */ + override fun getOverrideUiPage(): UiPage? { + // Show sign in page if we have not configured the client yet. + if (client == null) { + // When coming back to the application, authenticate immediately. + val autologin = firstRun && secrets.rememberMe == "true" + var autologinEx: Exception? = null + secrets.lastToken.let { lastToken -> + secrets.lastDeploymentURL.let { lastDeploymentURL -> + if (autologin && lastDeploymentURL.isNotBlank() && (lastToken.isNotBlank() || !settings.requireTokenAuth)) { + try { + return createConnectPage(URL(lastDeploymentURL), lastToken) + } catch (ex: Exception) { + autologinEx = ex + } + } + } + } + firstRun = false + + // Login flow. + val signInPage = SignInPage(getDeploymentURL()) { deploymentURL -> + ui.showUiPage( + TokenPage(deploymentURL, getToken(deploymentURL)) { selectedToken -> + ui.showUiPage(createConnectPage(deploymentURL, selectedToken)) + }, + ) + } + + // We might have tried and failed to automatically log in. + autologinEx?.let { signInPage.notify("Error logging in", it) } + // We might have navigated here due to a polling error. + pollError?.let { signInPage.notify("Error fetching workspaces", it) } + + return signInPage + } + return null + } + + /** + * Create a connect page that starts polling and resets the UI on success. + */ + private fun createConnectPage(deploymentURL: URL, token: String?): ConnectPage = ConnectPage( + deploymentURL, + token, + settings, + httpClient, + coroutineScope, + { reset() }, + ) { client, cli -> + // Store the URL and token for use next time. + secrets.lastDeploymentURL = client.url.toString() + secrets.lastToken = client.token ?: "" + // Currently we always remember, but this could be made an option. + secrets.rememberMe = "true" + this.client = client + pollError = null + pollJob?.cancel() + pollJob = poll(client, cli) + reset() + } + + /** + * Try to find a token. + * + * Order of preference: + * + * 1. Last used token, if it was for this deployment. + * 2. Token on disk for this deployment. + * 3. Global token for Coder, if it matches the deployment. + */ + private fun getToken(deploymentURL: URL): Pair? = secrets.lastToken.let { + if (it.isNotBlank() && secrets.lastDeploymentURL == deploymentURL.toString()) { + it to Source.LAST_USED + } else { + settings.token(deploymentURL) + } + } + + /** + * Try to find a URL. + * + * In order of preference: + * + * 1. Last used URL. + * 2. URL in settings. + * 3. CODER_URL. + * 4. URL in global cli config. + */ + private fun getDeploymentURL(): Pair? = secrets.lastDeploymentURL.let { + if (it.isNotBlank()) { + it to Source.LAST_USED + } else { + settings.defaultURL() + } + } +} diff --git a/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt new file mode 100644 index 0000000..e62cd95 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt @@ -0,0 +1,498 @@ +package com.coder.toolbox.cli + +import com.coder.toolbox.cli.ex.MissingVersionException +import com.coder.toolbox.cli.ex.ResponseException +import com.coder.toolbox.cli.ex.SSHConfigFormatException +import com.coder.toolbox.settings.CoderSettings +import com.coder.toolbox.settings.CoderSettingsState +import com.coder.toolbox.util.CoderHostnameVerifier +import com.coder.toolbox.util.InvalidVersionException +import com.coder.toolbox.util.OS +import com.coder.toolbox.util.SemVer +import com.coder.toolbox.util.coderSocketFactory +import com.coder.toolbox.util.escape +import com.coder.toolbox.util.escapeSubcommand +import com.coder.toolbox.util.getHeaders +import com.coder.toolbox.util.getOS +import com.coder.toolbox.util.safeHost +import com.coder.toolbox.util.sha1 +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import com.squareup.moshi.JsonDataException +import com.squareup.moshi.Moshi +import org.slf4j.LoggerFactory +import org.zeroturnaround.exec.ProcessExecutor +import java.io.EOFException +import java.io.FileInputStream +import java.io.FileNotFoundException +import java.net.ConnectException +import java.net.HttpURLConnection +import java.net.URL +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.util.zip.GZIPInputStream +import javax.net.ssl.HttpsURLConnection + +/** + * Version output from the CLI's version command. + */ +@JsonClass(generateAdapter = true) +internal data class Version( + @Json(name = "version") val version: String, +) + +/** + * Do as much as possible to get a valid, up-to-date CLI. + * + * 1. Read the binary directory for the provided URL. + * 2. Abort if we already have an up-to-date version. + * 3. Download the binary using an ETag. + * 4. Abort if we get a 304 (covers cases where the binary is older and does not + * have a version command). + * 5. Download on top of the existing binary. + * 6. Since the binary directory can be read-only, if downloading fails, start + * from step 2 with the data directory. + */ +fun ensureCLI( + deploymentURL: URL, + buildVersion: String, + settings: CoderSettings, + indicator: ((t: String) -> Unit)? = null, +): CoderCLIManager { + val cli = CoderCLIManager(deploymentURL, settings) + + // Short-circuit if we already have the expected version. This + // lets us bypass the 304 which is slower and may not be + // supported if the binary is downloaded from alternate sources. + // For CLIs without the JSON output flag we will fall back to + // the 304 method. + val cliMatches = cli.matchesVersion(buildVersion) + if (cliMatches == true) { + return cli + } + + // If downloads are enabled download the new version. + if (settings.enableDownloads) { + indicator?.invoke("Downloading Coder CLI...") + try { + cli.download() + return cli + } catch (e: java.nio.file.AccessDeniedException) { + // Might be able to fall back to the data directory. + val binPath = settings.binPath(deploymentURL) + val dataDir = settings.dataDir(deploymentURL) + if (binPath.parent == dataDir || !settings.enableBinaryDirectoryFallback) { + throw e + } + } + } + + // Try falling back to the data directory. + val dataCLI = CoderCLIManager(deploymentURL, settings, true) + val dataCLIMatches = dataCLI.matchesVersion(buildVersion) + if (dataCLIMatches == true) { + return dataCLI + } + + if (settings.enableDownloads) { + indicator?.invoke("Downloading Coder CLI...") + dataCLI.download() + return dataCLI + } + + // Prefer the binary directory unless the data directory has a + // working binary and the binary directory does not. + return if (cliMatches == null && dataCLIMatches != null) dataCLI else cli +} + +/** + * The supported features of the CLI. + */ +data class Features( + val disableAutostart: Boolean = false, + val reportWorkspaceUsage: Boolean = false, +) + +/** + * Manage the CLI for a single deployment. + */ +class CoderCLIManager( + // The URL of the deployment this CLI is for. + private val deploymentURL: URL, + // Plugin configuration. + private val settings: CoderSettings = CoderSettings(CoderSettingsState()), + // If the binary directory is not writable, this can be used to force the + // manager to download to the data directory instead. + forceDownloadToData: Boolean = false, +) { + private val logger = LoggerFactory.getLogger(javaClass) + + val remoteBinaryURL: URL = settings.binSource(deploymentURL) + val localBinaryPath: Path = settings.binPath(deploymentURL, forceDownloadToData) + val coderConfigPath: Path = settings.dataDir(deploymentURL).resolve("config") + + /** + * Download the CLI from the deployment if necessary. + */ + fun download(): Boolean { + val eTag = getBinaryETag() + val conn = remoteBinaryURL.openConnection() as HttpURLConnection + if (settings.headerCommand.isNotBlank()) { + val headersFromHeaderCommand = getHeaders(deploymentURL, settings.headerCommand) + for ((key, value) in headersFromHeaderCommand) { + conn.setRequestProperty(key, value) + } + } + if (eTag != null) { + logger.info("Found existing binary at $localBinaryPath; calculated hash as $eTag") + conn.setRequestProperty("If-None-Match", "\"$eTag\"") + } + conn.setRequestProperty("Accept-Encoding", "gzip") + if (conn is HttpsURLConnection) { + conn.sslSocketFactory = coderSocketFactory(settings.tls) + conn.hostnameVerifier = CoderHostnameVerifier(settings.tls.altHostname) + } + + try { + conn.connect() + logger.info("GET ${conn.responseCode} $remoteBinaryURL") + when (conn.responseCode) { + HttpURLConnection.HTTP_OK -> { + logger.info("Downloading binary to $localBinaryPath") + Files.createDirectories(localBinaryPath.parent) + conn.inputStream.use { + Files.copy( + if (conn.contentEncoding == "gzip") GZIPInputStream(it) else it, + localBinaryPath, + StandardCopyOption.REPLACE_EXISTING, + ) + } + if (getOS() != OS.WINDOWS) { + localBinaryPath.toFile().setExecutable(true) + } + return true + } + + HttpURLConnection.HTTP_NOT_MODIFIED -> { + logger.info("Using cached binary at $localBinaryPath") + return false + } + } + } catch (e: ConnectException) { + // Add the URL so this is more easily debugged. + throw ConnectException("${e.message} to $remoteBinaryURL") + } finally { + conn.disconnect() + } + throw ResponseException("Unexpected response from $remoteBinaryURL", conn.responseCode) + } + + /** + * Return the entity tag for the binary on disk, if any. + */ + private fun getBinaryETag(): String? = try { + sha1(FileInputStream(localBinaryPath.toFile())) + } catch (e: FileNotFoundException) { + null + } catch (e: Exception) { + logger.warn("Unable to calculate hash for $localBinaryPath", e) + null + } + + /** + * Use the provided token to authenticate the CLI. + */ + fun login(token: String): String { + logger.info("Storing CLI credentials in $coderConfigPath") + return exec( + "login", + deploymentURL.toString(), + "--token", + token, + "--global-config", + coderConfigPath.toString(), + ) + } + + /** + * Configure SSH to use this binary. + * + * This can take supported features for testing purposes only. + */ + fun configSsh( + workspaceNames: Set, + feats: Features = features, + ) { + logger.info("Configuring SSH config at ${settings.sshConfigPath}") + writeSSHConfig(modifySSHConfig(readSSHConfig(), workspaceNames, feats)) + } + + /** + * Return the contents of the SSH config or null if it does not exist. + */ + private fun readSSHConfig(): String? = try { + settings.sshConfigPath.toFile().readText() + } catch (e: FileNotFoundException) { + null + } + + /** + * Given an existing SSH config modify it to add or remove the config for + * this deployment and return the modified config or null if it does not + * need to be modified. + * + * If features are not provided, calculate them based on the binary + * version. + */ + private fun modifySSHConfig( + contents: String?, + workspaceNames: Set, + feats: Features, + ): String? { + val host = deploymentURL.safeHost() + val startBlock = "# --- START CODER JETBRAINS $host" + val endBlock = "# --- END CODER JETBRAINS $host" + val isRemoving = workspaceNames.isEmpty() + val baseArgs = + listOfNotNull( + escape(localBinaryPath.toString()), + "--global-config", + escape(coderConfigPath.toString()), + // CODER_URL might be set, and it will override the URL file in + // the config directory, so override that here to make sure we + // always use the correct URL. + "--url", + escape(deploymentURL.toString()), + if (settings.headerCommand.isNotBlank()) "--header-command" else null, + if (settings.headerCommand.isNotBlank()) escapeSubcommand(settings.headerCommand) else null, + "ssh", + "--stdio", + if (settings.disableAutostart && feats.disableAutostart) "--disable-autostart" else null, + ) + val proxyArgs = baseArgs + listOfNotNull( + if (settings.sshLogDirectory.isNotBlank()) "--log-dir" else null, + if (settings.sshLogDirectory.isNotBlank()) escape(settings.sshLogDirectory) else null, + if (feats.reportWorkspaceUsage) "--usage-app=jetbrains" else null, + ) + val backgroundProxyArgs = baseArgs + listOfNotNull(if (feats.reportWorkspaceUsage) "--usage-app=disable" else null) + val extraConfig = + if (settings.sshConfigOptions.isNotBlank()) { + "\n" + settings.sshConfigOptions.prependIndent(" ") + } else { + "" + } + val blockContent = + workspaceNames.joinToString( + System.lineSeparator(), + startBlock + System.lineSeparator(), + System.lineSeparator() + endBlock, + transform = { + """ + Host ${getHostName(deploymentURL, it)} + ProxyCommand ${proxyArgs.joinToString(" ")} $it + ConnectTimeout 0 + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + LogLevel ERROR + SetEnv CODER_SSH_SESSION_TYPE=JetBrains + """.trimIndent() + .plus(extraConfig) + .plus("\n") + .plus( + """ + Host ${getBackgroundHostName(deploymentURL, it)} + ProxyCommand ${backgroundProxyArgs.joinToString(" ")} $it + ConnectTimeout 0 + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + LogLevel ERROR + SetEnv CODER_SSH_SESSION_TYPE=JetBrains + """.trimIndent() + .plus(extraConfig), + ).replace("\n", System.lineSeparator()) + }, + ) + + if (contents == null) { + logger.info("No existing SSH config to modify") + return blockContent + System.lineSeparator() + } + + val start = "(\\s*)$startBlock".toRegex().find(contents) + val end = "$endBlock(\\s*)".toRegex().find(contents) + + if (start == null && end == null && isRemoving) { + logger.info("No workspaces and no existing config blocks to remove") + return null + } + + if (start == null && end == null) { + logger.info("Appending config block") + val toAppend = + if (contents.isEmpty()) { + blockContent + } else { + listOf( + contents, + blockContent, + ).joinToString(System.lineSeparator()) + } + return toAppend + System.lineSeparator() + } + + if (start == null) { + throw SSHConfigFormatException("End block exists but no start block") + } + if (end == null) { + throw SSHConfigFormatException("Start block exists but no end block") + } + if (start.range.first > end.range.first) { + throw SSHConfigFormatException("Start block found after end block") + } + + if (isRemoving) { + logger.info("No workspaces; removing config block") + return listOf( + contents.substring(0, start.range.first), + // Need to keep the trailing newline(s) if we are not at the + // front of the file otherwise the before and after lines would + // get joined. + if (start.range.first > 0) end.groupValues[1] else "", + contents.substring(end.range.last + 1), + ).joinToString("") + } + + logger.info("Replacing existing config block") + return listOf( + contents.substring(0, start.range.first), + start.groupValues[1], // Leading newline(s). + blockContent, + end.groupValues[1], // Trailing newline(s). + contents.substring(end.range.last + 1), + ).joinToString("") + } + + /** + * Write the provided SSH config or do nothing if null. + */ + private fun writeSSHConfig(contents: String?) { + if (contents != null) { + settings.sshConfigPath.parent.toFile().mkdirs() + settings.sshConfigPath.toFile().writeText(contents) + // The Coder cli will *not* create the log directory. + if (settings.sshLogDirectory.isNotBlank()) { + Path.of(settings.sshLogDirectory).toFile().mkdirs() + } + } + } + + /** + * Return the binary version. + * + * Throws if it could not be determined. + */ + fun version(): SemVer { + val raw = exec("version", "--output", "json") + try { + val json = Moshi.Builder().build().adapter(Version::class.java).fromJson(raw) + if (json?.version == null || json.version.isBlank()) { + throw MissingVersionException("No version found in output") + } + return SemVer.parse(json.version) + } catch (exception: JsonDataException) { + throw MissingVersionException("No version found in output") + } catch (exception: EOFException) { + throw MissingVersionException("No version found in output") + } + } + + /** + * Like version(), but logs errors instead of throwing them. + */ + private fun tryVersion(): SemVer? = try { + version() + } catch (e: Exception) { + when (e) { + is InvalidVersionException -> { + logger.info("Got invalid version from $localBinaryPath: ${e.message}") + } + else -> { + // An error here most likely means the CLI does not exist or + // it executed successfully but output no version which + // suggests it is not the right binary. + logger.info("Unable to determine $localBinaryPath version: ${e.message}") + } + } + null + } + + /** + * Returns true if the CLI has the same major/minor/patch version as the + * provided version, false if it does not match, or null if the CLI version + * could not be determined because the binary could not be executed or the + * version could not be parsed. + */ + fun matchesVersion(rawBuildVersion: String): Boolean? { + val cliVersion = tryVersion() ?: return null + val buildVersion = + try { + SemVer.parse(rawBuildVersion) + } catch (e: InvalidVersionException) { + logger.info("Got invalid build version: $rawBuildVersion") + return null + } + + val matches = cliVersion == buildVersion + logger.info("$localBinaryPath version $cliVersion matches $buildVersion: $matches") + return matches + } + + private fun exec(vararg args: String): String { + val stdout = + ProcessExecutor() + .command(localBinaryPath.toString(), *args) + .environment("CODER_HEADER_COMMAND", settings.headerCommand) + .exitValues(0) + .readOutput(true) + .execute() + .outputUTF8() + val redactedArgs = listOf(*args).joinToString(" ").replace(tokenRegex, "--token ") + logger.info("`$localBinaryPath $redactedArgs`: $stdout") + return stdout + } + + val features: Features + get() { + val version = tryVersion() + return if (version == null) { + Features() + } else { + Features( + disableAutostart = version >= SemVer(2, 5, 0), + reportWorkspaceUsage = version >= SemVer(2, 13, 0), + ) + } + } + + companion object { + private val tokenRegex = "--token [^ ]+".toRegex() + + @JvmStatic + fun getHostName( + url: URL, + workspaceName: String, + ): String = "coder-jetbrains--$workspaceName--${url.safeHost()}" + + @JvmStatic + fun getBackgroundHostName( + url: URL, + workspaceName: String, + ): String = getHostName(url, workspaceName) + "--bg" + + @JvmStatic + fun getBackgroundHostName( + hostname: String, + ): String = hostname + "--bg" + } +} diff --git a/src/main/kotlin/com/coder/toolbox/cli/ex/Exceptions.kt b/src/main/kotlin/com/coder/toolbox/cli/ex/Exceptions.kt new file mode 100644 index 0000000..d3ca3a4 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/cli/ex/Exceptions.kt @@ -0,0 +1,7 @@ +package com.coder.toolbox.cli.ex + +class ResponseException(message: String, val code: Int) : Exception(message) + +class SSHConfigFormatException(message: String) : Exception(message) + +class MissingVersionException(message: String) : Exception(message) diff --git a/src/main/kotlin/com/coder/toolbox/models/WorkspaceAndAgentStatus.kt b/src/main/kotlin/com/coder/toolbox/models/WorkspaceAndAgentStatus.kt new file mode 100644 index 0000000..51bc2a9 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/models/WorkspaceAndAgentStatus.kt @@ -0,0 +1,160 @@ +package com.coder.toolbox.models + +import com.coder.toolbox.sdk.v2.models.Workspace +import com.coder.toolbox.sdk.v2.models.WorkspaceAgent +import com.coder.toolbox.sdk.v2.models.WorkspaceAgentLifecycleState +import com.coder.toolbox.sdk.v2.models.WorkspaceAgentStatus +import com.coder.toolbox.sdk.v2.models.WorkspaceStatus +import com.jetbrains.toolbox.api.core.ui.color.Color +import com.jetbrains.toolbox.api.core.ui.color.StateColor +import com.jetbrains.toolbox.api.core.ui.color.ThemeColor +import com.jetbrains.toolbox.api.remoteDev.states.CustomRemoteEnvironmentState + +/** + * WorkspaceAndAgentStatus represents the combined status of a single agent and + * its workspace (or just the workspace if there are no agents). + */ +enum class WorkspaceAndAgentStatus(val label: String, val description: String) { + // Workspace states. + QUEUED("Queued", "The workspace is queueing to start."), + STARTING("Starting", "The workspace is starting."), + FAILED("Failed", "The workspace has failed to start."), + DELETING("Deleting", "The workspace is being deleted."), + DELETED("Deleted", "The workspace has been deleted."), + STOPPING("Stopping", "The workspace is stopping."), + STOPPED("Stopped", "The workspace has stopped."), + CANCELING("Canceling action", "The workspace is being canceled."), + CANCELED("Canceled action", "The workspace has been canceled."), + RUNNING("Running", "The workspace is running, waiting for agents."), + + // Agent states. + CONNECTING("Connecting", "The agent is connecting."), + DISCONNECTED("Disconnected", "The agent has disconnected."), + TIMEOUT("Timeout", "The agent is taking longer than expected to connect."), + AGENT_STARTING("Starting", "The startup script is running."), + AGENT_STARTING_READY( + "Starting", + "The startup script is still running but the agent is ready to accept connections.", + ), + CREATED("Created", "The agent has been created."), + START_ERROR("Started with error", "The agent is ready but the startup script errored."), + START_TIMEOUT("Starting", "The startup script is taking longer than expected."), + START_TIMEOUT_READY( + "Starting", + "The startup script is taking longer than expected but the agent is ready to accept connections.", + ), + SHUTTING_DOWN("Shutting down", "The agent is shutting down."), + SHUTDOWN_ERROR("Shutdown with error", "The agent shut down but the shutdown script errored."), + SHUTDOWN_TIMEOUT("Shutting down", "The shutdown script is taking longer than expected."), + OFF("Off", "The agent has shut down."), + READY("Ready", "The agent is ready to accept connections."), + ; + + /** + * Return the environment state for Toolbox, which tells it the label, color + * and whether the environment is reachable. + * + * Note that a reachable environment will always display "connected" or + * "disconnected" regardless of the label we give that status. + */ + fun toRemoteEnvironmentState(): CustomRemoteEnvironmentState { + // Use comments; no named arguments for non-Kotlin functions. + // TODO@JB: Is there a set of default colors we could use? + return CustomRemoteEnvironmentState( + label, + StateColor( + ThemeColor( + Color(0.407f, 0.439f, 0.502f, 1.0f), // lightThemeColor + Color(0.784f, 0.784f, 0.784f, 0.784f), // darkThemeColor + ), + ThemeColor( + Color(0.878f, 0.878f, 0.941f, 0.102f), // darkThemeBackgroundColor + Color(0.878f, 0.878f, 0.961f, 0.980f), // lightThemeBackgroundColor + ) + ), + ready(), // reachable + // TODO@JB: How does this work? Would like a spinner for pending states. + null, // iconId + ) + } + + /** + * Return true if the agent is in a connectable state. + */ + fun ready(): Boolean { + // It seems that the agent can get stuck in a `created` state if the + // workspace is updated and the agent is restarted (presumably because + // lifecycle scripts are not running again). This feels like either a + // Coder or template bug, but `coder ssh` and the VS Code plugin will + // still connect so do the same here to not be the odd one out. + return listOf(READY, START_ERROR, AGENT_STARTING_READY, START_TIMEOUT_READY, CREATED) + .contains(this) + } + + /** + * Return true if the agent might soon be in a connectable state. + */ + fun pending(): Boolean { + // See ready() for why `CREATED` is not in this list. + return listOf(CONNECTING, TIMEOUT, AGENT_STARTING, START_TIMEOUT, QUEUED, STARTING) + .contains(this) + } + + /** + * Return true if the workspace can be started. + */ + fun canStart(): Boolean = listOf(STOPPED, FAILED, CANCELED) + .contains(this) + + // We want to check that the workspace is `running`, the agent is + // `connected`, and the agent lifecycle state is `ready` to ensure the best + // possible scenario for attempting a connection. + // + // We can also choose to allow `start_error` for the agent lifecycle state; + // this means the startup script did not successfully complete but the agent + // will still accept SSH connections. + // + // Lastly we can also allow connections when the agent lifecycle state is + // `starting` or `start_timeout` if `login_before_ready` is true on the + // workspace response since this bypasses the need to wait for the script. + // + // Note that latest_build.status is derived from latest_build.job.status and + // latest_build.job.transition so there is no need to check those. + companion object { + fun from( + workspace: Workspace, + agent: WorkspaceAgent? = null, + ) = when (workspace.latestBuild.status) { + WorkspaceStatus.PENDING -> QUEUED + WorkspaceStatus.STARTING -> STARTING + WorkspaceStatus.RUNNING -> + when (agent?.status) { + WorkspaceAgentStatus.CONNECTED -> + when (agent.lifecycleState) { + WorkspaceAgentLifecycleState.CREATED -> CREATED + WorkspaceAgentLifecycleState.STARTING -> if (agent.loginBeforeReady == true) AGENT_STARTING_READY else AGENT_STARTING + WorkspaceAgentLifecycleState.START_TIMEOUT -> if (agent.loginBeforeReady == true) START_TIMEOUT_READY else START_TIMEOUT + WorkspaceAgentLifecycleState.START_ERROR -> START_ERROR + WorkspaceAgentLifecycleState.READY -> READY + WorkspaceAgentLifecycleState.SHUTTING_DOWN -> SHUTTING_DOWN + WorkspaceAgentLifecycleState.SHUTDOWN_TIMEOUT -> SHUTDOWN_TIMEOUT + WorkspaceAgentLifecycleState.SHUTDOWN_ERROR -> SHUTDOWN_ERROR + WorkspaceAgentLifecycleState.OFF -> OFF + } + + WorkspaceAgentStatus.DISCONNECTED -> DISCONNECTED + WorkspaceAgentStatus.TIMEOUT -> TIMEOUT + WorkspaceAgentStatus.CONNECTING -> CONNECTING + else -> RUNNING + } + + WorkspaceStatus.STOPPING -> STOPPING + WorkspaceStatus.STOPPED -> STOPPED + WorkspaceStatus.FAILED -> FAILED + WorkspaceStatus.CANCELING -> CANCELING + WorkspaceStatus.CANCELED -> CANCELED + WorkspaceStatus.DELETING -> DELETING + WorkspaceStatus.DELETED -> DELETED + } + } +} diff --git a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt new file mode 100644 index 0000000..7fb8d13 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt @@ -0,0 +1,258 @@ +package com.coder.toolbox.sdk + +import com.coder.toolbox.sdk.convertors.ArchConverter +import com.coder.toolbox.sdk.convertors.InstantConverter +import com.coder.toolbox.sdk.convertors.OSConverter +import com.coder.toolbox.sdk.convertors.UUIDConverter +import com.coder.toolbox.sdk.ex.APIResponseException +import com.coder.toolbox.sdk.v2.CoderV2RestFacade +import com.coder.toolbox.sdk.v2.models.BuildInfo +import com.coder.toolbox.sdk.v2.models.CreateWorkspaceBuildRequest +import com.coder.toolbox.sdk.v2.models.Template +import com.coder.toolbox.sdk.v2.models.User +import com.coder.toolbox.sdk.v2.models.Workspace +import com.coder.toolbox.sdk.v2.models.WorkspaceBuild +import com.coder.toolbox.sdk.v2.models.WorkspaceResource +import com.coder.toolbox.sdk.v2.models.WorkspaceTransition +import com.coder.toolbox.settings.CoderSettings +import com.coder.toolbox.settings.CoderSettingsState +import com.coder.toolbox.util.CoderHostnameVerifier +import com.coder.toolbox.util.coderSocketFactory +import com.coder.toolbox.util.coderTrustManagers +import com.coder.toolbox.util.getArch +import com.coder.toolbox.util.getHeaders +import com.coder.toolbox.util.getOS +import com.squareup.moshi.Moshi +import okhttp3.Credentials +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory +import java.net.HttpURLConnection +import java.net.ProxySelector +import java.net.URL +import java.util.UUID +import javax.net.ssl.X509TrustManager + +/** + * Holds proxy information. + */ +data class ProxyValues( + val username: String?, + val password: String?, + val useAuth: Boolean, + val selector: ProxySelector, +) + +/** + * An HTTP client that can make requests to the Coder API. + * + * The token can be omitted if some other authentication mechanism is in use. + */ +open class CoderRestClient( + val url: URL, + val token: String?, + private val settings: CoderSettings = CoderSettings(CoderSettingsState()), + private val proxyValues: ProxyValues? = null, + private val pluginVersion: String = "development", + existingHttpClient: OkHttpClient? = null, +) { + private val httpClient: OkHttpClient + private val retroRestClient: CoderV2RestFacade + + lateinit var me: User + lateinit var buildVersion: String + + init { + val moshi = + Moshi.Builder() + .add(ArchConverter()) + .add(InstantConverter()) + .add(OSConverter()) + .add(UUIDConverter()) + .build() + + val socketFactory = coderSocketFactory(settings.tls) + val trustManagers = coderTrustManagers(settings.tls.caPath) + var builder = existingHttpClient?.newBuilder() ?: OkHttpClient.Builder() + + if (proxyValues != null) { + builder = + builder + .proxySelector(proxyValues.selector) + .proxyAuthenticator { _, response -> + if (proxyValues.useAuth && proxyValues.username != null && proxyValues.password != null) { + val credentials = Credentials.basic(proxyValues.username, proxyValues.password) + response.request.newBuilder() + .header("Proxy-Authorization", credentials) + .build() + } else { + null + } + } + } + + if (token != null) { + builder = builder.addInterceptor { it.proceed(it.request().newBuilder().addHeader("Coder-Session-Token", token).build()) } + } + + httpClient = + builder + .sslSocketFactory(socketFactory, trustManagers[0] as X509TrustManager) + .hostnameVerifier(CoderHostnameVerifier(settings.tls.altHostname)) + .addInterceptor { + it.proceed( + it.request().newBuilder().addHeader( + "User-Agent", + "Coder Gateway/$pluginVersion (${getOS()}; ${getArch()})", + ).build(), + ) + } + .addInterceptor { + var request = it.request() + val headers = getHeaders(url, settings.headerCommand) + if (headers.isNotEmpty()) { + val reqBuilder = request.newBuilder() + headers.forEach { h -> reqBuilder.addHeader(h.key, h.value) } + request = reqBuilder.build() + } + it.proceed(request) + } + .build() + + retroRestClient = + Retrofit.Builder().baseUrl(url.toString()).client(httpClient) + .addConverterFactory(MoshiConverterFactory.create(moshi)) + .build().create(CoderV2RestFacade::class.java) + } + + /** + * Authenticate and load information about the current user and the build + * version. + * + * @throws [APIResponseException]. + */ + fun authenticate(): User { + me = me() + buildVersion = buildInfo().version + return me + } + + /** + * Retrieve the current user. + * @throws [APIResponseException]. + */ + fun me(): User { + val userResponse = retroRestClient.me().execute() + if (!userResponse.isSuccessful) { + throw APIResponseException("authenticate", url, userResponse) + } + + return userResponse.body()!! + } + + /** + * Retrieves the available workspaces created by the user. + * @throws [APIResponseException]. + */ + fun workspaces(): List { + val workspacesResponse = retroRestClient.workspaces("owner:me").execute() + if (!workspacesResponse.isSuccessful) { + throw APIResponseException("retrieve workspaces", url, workspacesResponse) + } + + return workspacesResponse.body()!!.workspaces + } + + /** + * Retrieves all the agent names for all workspaces, including those that + * are off. Meant to be used when configuring SSH. + */ + fun agentNames(workspaces: List): Set { + // It is possible for there to be resources with duplicate names so we + // need to use a set. + return workspaces.flatMap { ws -> + resources(ws).filter { it.agents != null }.flatMap { it.agents!! }.map { + "${ws.name}.${it.name}" + } + }.toSet() + } + + /** + * Retrieves resources for the specified workspace. The workspaces response + * does not include agents when the workspace is off so this can be used to + * get them instead, just like `coder config-ssh` does (otherwise we risk + * removing hosts from the SSH config when they are off). + * @throws [APIResponseException]. + */ + fun resources(workspace: Workspace): List { + val resourcesResponse = retroRestClient.templateVersionResources(workspace.latestBuild.templateVersionID).execute() + if (!resourcesResponse.isSuccessful) { + throw APIResponseException("retrieve resources for ${workspace.name}", url, resourcesResponse) + } + return resourcesResponse.body()!! + } + + fun buildInfo(): BuildInfo { + val buildInfoResponse = retroRestClient.buildInfo().execute() + if (!buildInfoResponse.isSuccessful) { + throw APIResponseException("retrieve build information", url, buildInfoResponse) + } + return buildInfoResponse.body()!! + } + + /** + * @throws [APIResponseException]. + */ + private fun template(templateID: UUID): Template { + val templateResponse = retroRestClient.template(templateID).execute() + if (!templateResponse.isSuccessful) { + throw APIResponseException("retrieve template with ID $templateID", url, templateResponse) + } + return templateResponse.body()!! + } + + /** + * @throws [APIResponseException]. + */ + fun startWorkspace(workspace: Workspace): WorkspaceBuild { + val buildRequest = CreateWorkspaceBuildRequest(null, WorkspaceTransition.START) + val buildResponse = retroRestClient.createWorkspaceBuild(workspace.id, buildRequest).execute() + if (buildResponse.code() != HttpURLConnection.HTTP_CREATED) { + throw APIResponseException("start workspace ${workspace.name}", url, buildResponse) + } + return buildResponse.body()!! + } + + /** + * @throws [APIResponseException]. + */ + fun stopWorkspace(workspace: Workspace): WorkspaceBuild { + val buildRequest = CreateWorkspaceBuildRequest(null, WorkspaceTransition.STOP) + val buildResponse = retroRestClient.createWorkspaceBuild(workspace.id, buildRequest).execute() + if (buildResponse.code() != HttpURLConnection.HTTP_CREATED) { + throw APIResponseException("stop workspace ${workspace.name}", url, buildResponse) + } + return buildResponse.body()!! + } + + /** + * Start the workspace with the latest template version. Best practice is + * to STOP a workspace before doing an update if it is started. + * 1. If the update changes parameters, the old template might be needed to + * correctly STOP with the existing parameter values. + * 2. The agent gets a new ID and token on each START build. Many template + * authors are not diligent about making sure the agent gets restarted + * with this information when we do two START builds in a row. + * @throws [APIResponseException]. + */ + fun updateWorkspace(workspace: Workspace): WorkspaceBuild { + val template = template(workspace.templateID) + val buildRequest = + CreateWorkspaceBuildRequest(template.activeVersionID, WorkspaceTransition.START) + val buildResponse = retroRestClient.createWorkspaceBuild(workspace.id, buildRequest).execute() + if (buildResponse.code() != HttpURLConnection.HTTP_CREATED) { + throw APIResponseException("update workspace ${workspace.name}", url, buildResponse) + } + return buildResponse.body()!! + } +} diff --git a/src/main/kotlin/com/coder/toolbox/sdk/convertors/ArchConverter.kt b/src/main/kotlin/com/coder/toolbox/sdk/convertors/ArchConverter.kt new file mode 100644 index 0000000..5d90b74 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/sdk/convertors/ArchConverter.kt @@ -0,0 +1,14 @@ +package com.coder.toolbox.sdk.convertors + +import com.coder.toolbox.util.Arch +import com.squareup.moshi.FromJson +import com.squareup.moshi.ToJson + +/** + * Serializer/deserializer for converting [Arch] objects. + */ +class ArchConverter { + @ToJson fun toJson(src: Arch?): String = src?.toString() ?: "" + + @FromJson fun fromJson(src: String): Arch? = Arch.from(src) +} diff --git a/src/main/kotlin/com/coder/toolbox/sdk/convertors/InstantConverter.kt b/src/main/kotlin/com/coder/toolbox/sdk/convertors/InstantConverter.kt new file mode 100644 index 0000000..2e27f63 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/sdk/convertors/InstantConverter.kt @@ -0,0 +1,23 @@ +package com.coder.toolbox.sdk.convertors + +import com.squareup.moshi.FromJson +import com.squareup.moshi.ToJson +import java.time.Instant +import java.time.format.DateTimeFormatter +import java.time.temporal.TemporalAccessor + +/** + * Serializer/deserializer for converting [Instant] objects. + */ +class InstantConverter { + @ToJson fun toJson(src: Instant?): String = FORMATTER.format(src) + + @FromJson fun fromJson(src: String): Instant? = + FORMATTER.parse(src) { temporal: TemporalAccessor? -> + Instant.from(temporal) + } + + companion object { + private val FORMATTER = DateTimeFormatter.ISO_INSTANT + } +} diff --git a/src/main/kotlin/com/coder/toolbox/sdk/convertors/OSConverter.kt b/src/main/kotlin/com/coder/toolbox/sdk/convertors/OSConverter.kt new file mode 100644 index 0000000..43bc855 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/sdk/convertors/OSConverter.kt @@ -0,0 +1,14 @@ +package com.coder.toolbox.sdk.convertors + +import com.coder.toolbox.util.OS +import com.squareup.moshi.FromJson +import com.squareup.moshi.ToJson + +/** + * Serializer/deserializer for converting [OS] objects. + */ +class OSConverter { + @ToJson fun toJson(src: OS?): String = src?.toString() ?: "" + + @FromJson fun fromJson(src: String): OS? = OS.from(src) +} diff --git a/src/main/kotlin/com/coder/toolbox/sdk/convertors/UUIDConverter.kt b/src/main/kotlin/com/coder/toolbox/sdk/convertors/UUIDConverter.kt new file mode 100644 index 0000000..5b3fbb5 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/sdk/convertors/UUIDConverter.kt @@ -0,0 +1,14 @@ +package com.coder.toolbox.sdk.convertors + +import com.squareup.moshi.FromJson +import com.squareup.moshi.ToJson +import java.util.UUID + +/** + * Serializer/deserializer for converting [UUID] objects. + */ +class UUIDConverter { + @ToJson fun toJson(src: UUID): String = src.toString() + + @FromJson fun fromJson(src: String): UUID = UUID.fromString(src) +} diff --git a/src/main/kotlin/com/coder/toolbox/sdk/ex/APIResponseException.kt b/src/main/kotlin/com/coder/toolbox/sdk/ex/APIResponseException.kt new file mode 100644 index 0000000..2540ca8 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/sdk/ex/APIResponseException.kt @@ -0,0 +1,26 @@ +package com.coder.toolbox.sdk.ex + +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL + +class APIResponseException(action: String, url: URL, res: retrofit2.Response<*>) : + IOException( + "Unable to $action: url=$url, code=${res.code()}, details=${ + when (res.code()) { + HttpURLConnection.HTTP_NOT_FOUND -> "The requested resource could not be found" + else -> res.errorBody()?.charStream()?.use { + val text = it.readText() + // Be careful with the length because if you try to show a + // notification in Toolbox that is too large it crashes the + // application. + if (text.length > 500) { + "${text.substring(0, 500)}…" + } else { + text + } + } ?: "no details provided" + }}", + ) { + val isUnauthorized = res.code() == HttpURLConnection.HTTP_UNAUTHORIZED +} diff --git a/src/main/kotlin/com/coder/toolbox/sdk/v2/CoderV2RestFacade.kt b/src/main/kotlin/com/coder/toolbox/sdk/v2/CoderV2RestFacade.kt new file mode 100644 index 0000000..86a4de6 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/sdk/v2/CoderV2RestFacade.kt @@ -0,0 +1,54 @@ +package com.coder.toolbox.sdk.v2 + +import com.coder.toolbox.sdk.v2.models.BuildInfo +import com.coder.toolbox.sdk.v2.models.CreateWorkspaceBuildRequest +import com.coder.toolbox.sdk.v2.models.Template +import com.coder.toolbox.sdk.v2.models.User +import com.coder.toolbox.sdk.v2.models.WorkspaceBuild +import com.coder.toolbox.sdk.v2.models.WorkspaceResource +import com.coder.toolbox.sdk.v2.models.WorkspacesResponse +import retrofit2.Call +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Path +import retrofit2.http.Query +import java.util.UUID + +interface CoderV2RestFacade { + /** + * Retrieves details about the authenticated user. + */ + @GET("api/v2/users/me") + fun me(): Call + + /** + * Retrieves all workspaces the authenticated user has access to. + */ + @GET("api/v2/workspaces") + fun workspaces( + @Query("q") searchParams: String, + ): Call + + @GET("api/v2/buildinfo") + fun buildInfo(): Call + + /** + * Queues a new build to occur for a workspace. + */ + @POST("api/v2/workspaces/{workspaceID}/builds") + fun createWorkspaceBuild( + @Path("workspaceID") workspaceID: UUID, + @Body createWorkspaceBuildRequest: CreateWorkspaceBuildRequest, + ): Call + + @GET("api/v2/templates/{templateID}") + fun template( + @Path("templateID") templateID: UUID, + ): Call