├── .github └── workflows │ └── ci.yaml ├── .gitignore ├── .goreleaser.yaml ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── cli └── cmds │ ├── interactive │ └── interactive.go │ ├── migrate │ └── migrate.go │ ├── root.go │ └── status │ └── status.go ├── go.mod ├── go.sum ├── main.go ├── ops ├── build ├── package └── version └── pkg ├── client └── client.go ├── cluster ├── app.go ├── cluster.go ├── clusterrepo.go ├── clusterroletemplatebinding.go ├── globalrolebinding.go ├── namespace.go ├── project.go ├── projectroletemplatebinding.go ├── tui │ ├── cluster.go │ ├── constants │ │ └── constants.go │ ├── delegate.go │ ├── objects.go │ └── tui.go ├── user.go └── util.go └── version └── version.go /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | release-tag: 13 | runs-on: ubuntu-22.04 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v3 17 | with: 18 | fetch-depth: 0 19 | - name: Set up Go 20 | uses: actions/setup-go@v4 21 | with: 22 | cache: false 23 | go-version: "1.22" 24 | - name: Run GoReleaser 25 | uses: goreleaser/goreleaser-action@v4 26 | with: 27 | distribution: goreleaser 28 | version: v1.23.0 29 | args: release --clean 30 | env: 31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | GORELEASER_CURRENT_TAG: ${{ github.ref_name }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | # vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | 23 | # ci 24 | bin/ 25 | 26 | # Debug 27 | __debug* 28 | .vscode/ 29 | cattle-drive 30 | dist/ 31 | 32 | # tui log file 33 | cattle-drive.log 34 | 35 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | version: 1 2 | 3 | snapshot: 4 | name_template: '{{ trimprefix .Summary "v" }}' 5 | 6 | before: 7 | hooks: 8 | - go mod tidy 9 | 10 | builds: 11 | - goos: 12 | - linux 13 | - darwin 14 | goarch: 15 | - amd64 16 | - arm64 17 | tags: 18 | - netgo 19 | - osusergo 20 | ldflags: 21 | - -s 22 | - -w 23 | - -X "rancherlabs/cattle-drive/pkg/version.GitCommit={{ .FullCommit }}" 24 | - -X "rancherlabs/cattle-drive/pkg/version.Version=v{{ .Version }}" 25 | 26 | archives: 27 | - format: tar.gz 28 | # this name template makes the OS and Arch compatible with the results of `uname`. 29 | name_template: >- 30 | {{ .ProjectName }}_ 31 | {{- title .Os }}_ 32 | {{- if eq .Arch "amd64" }}x86_64 33 | {{- else if eq .Arch "386" }}i386 34 | {{- else }}{{ .Arch }}{{ end }} 35 | {{- if .Arm }}v{{ .Arm }}{{ end }} 36 | # use zip for windows archives 37 | format_overrides: 38 | - goos: windows 39 | format: zip 40 | 41 | release: 42 | github: 43 | owner: rancherlabs 44 | name: cattle-drive 45 | prerelease: auto 46 | 47 | changelog: 48 | sort: asc 49 | filters: 50 | exclude: 51 | - "^docs:" 52 | - "^test:" 53 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.22.0-alpine3.19 AS build 2 | RUN apk add -U --no-cache make git bash 3 | COPY . /src/cattle-drive 4 | WORKDIR /src/cattle-drive 5 | RUN ls -l 6 | RUN make build 7 | 8 | FROM alpine AS package 9 | COPY --from=build /src/cattle-drive/bin /usr/local/bin/ 10 | ENTRYPOINT ["/usr/local/bin/cattle-drive"] 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | default: build 2 | @echo "Run make help for info about other make targets" 3 | 4 | build: ## Build using host go tools 5 | ./ops/build 6 | 7 | test: 8 | go test -v ./... -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cattle-drive 2 | 3 | A tool to migrate Rancher objects created for downstream cluster from a source to a target cluster, these objects include, but not limited to: 4 | 5 | - Projects 6 | - Namespaces 7 | - ProjectRoleTemplateBindings 8 | - ClusterRoleTemplateBindings 9 | - Cluster Apps 10 | - Cluster Catalog Repos 11 | 12 | ## Usage 13 | 14 | First you would need a kubeconfig that can connect to the local cluster of the Rancher environment with admin access, for more information on how to obtain this please visit the [docs](https://ranchermanager.docs.rancher.com/api/quickstart), the tool has 3 subcommands: 15 | 16 | ### Status 17 | 18 | The status subcommand will list all the related objects and their status, the status can be one of three: 19 | 20 | - Migrated 21 | - Not Migrated 22 | - Migrated but with wrong spec 23 | 24 | ```sh 25 | $ cattle-drive status -s hussein-rke1 -t hgalal-rke2 --kubeconfig kubeconfig.yaml 26 | Project status: 27 | - [test-project] ✔ 28 | -> users permissions: 29 | - [prtb-kds2g] ✔ 30 | -> namespaces: 31 | Cluster users permissions: 32 | - [crtb-p7cpc] ✔ 33 | - [crtb-v9ls4] ✔ 34 | Catalog repos: 35 | - [k3k] ✔ 36 | ``` 37 | 38 | ### Migrate 39 | 40 | The migrate subcommand will migrate all related objects to to the target downstream cluster, note that the some objects are only created on the local cluster while some objects has to be created on the downstream cluster itself. 41 | 42 | ```sh 43 | $ cattle-drive migrate -s hussein-rke1 -t hgalal-rke2 --kubeconfig kubeconfig.yaml 44 | Migrating Objects from cluster [hussein-rke1] to cluster [hgalal-rke2]: 45 | - migrating Project [migrate-project]... Done. 46 | ``` 47 | 48 | ### Interactive 49 | 50 | The interactive subcommands allows you to navigate in a simple list menu through all the objects and their status, and allows you to migrate certain object individually. 51 | 52 | [![asciicast](https://asciinema.org/a/Bd6wc7pT0RM92sWqOctAanReL.svg)](https://asciinema.org/a/Bd6wc7pT0RM92sWqOctAanReL) 53 | 54 | -------------------------------------------------------------------------------- /cli/cmds/interactive/interactive.go: -------------------------------------------------------------------------------- 1 | package interactive 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "rancherlabs/cattle-drive/cli/cmds" 8 | "rancherlabs/cattle-drive/pkg/client" 9 | "rancherlabs/cattle-drive/pkg/cluster" 10 | "rancherlabs/cattle-drive/pkg/cluster/tui" 11 | 12 | v3 "github.com/rancher/rancher/pkg/apis/management.cattle.io/v3" 13 | "github.com/urfave/cli/v2" 14 | v1 "k8s.io/apimachinery/pkg/apis/meta/v1" 15 | "k8s.io/client-go/rest" 16 | "k8s.io/client-go/tools/clientcmd" 17 | ) 18 | 19 | var ( 20 | source string 21 | target string 22 | logFilePath string 23 | interactiveFlags = []cli.Flag{ 24 | &cli.StringFlag{ 25 | Name: "source", 26 | Usage: "name of the source cluster", 27 | Destination: &source, 28 | Aliases: []string{"s"}, 29 | }, 30 | &cli.StringFlag{ 31 | Name: "target", 32 | Usage: "name of the target cluster", 33 | Destination: &target, 34 | Aliases: []string{"t"}, 35 | }, 36 | &cli.StringFlag{ 37 | Name: "log-file", 38 | Usage: "log file path", 39 | Destination: &logFilePath, 40 | Aliases: []string{"l"}, 41 | Value: "cattle-drive.log", 42 | }, 43 | } 44 | ) 45 | 46 | func NewCommand() *cli.Command { 47 | return &cli.Command{ 48 | Name: "interactive", 49 | Usage: "Interactive command", 50 | Action: migrate, 51 | Flags: append(cmds.CommonFlags, interactiveFlags...), 52 | } 53 | } 54 | 55 | func migrate(clx *cli.Context) error { 56 | ctx := context.Background() 57 | restConfig, err := clientcmd.BuildConfigFromFlags("", cmds.Kubeconfig) 58 | if err != nil { 59 | return err 60 | } 61 | cl, err := client.New(ctx, restConfig) 62 | if err != nil { 63 | return err 64 | } 65 | 66 | // check for target rancher 67 | var ( 68 | targetRancherConfig *rest.Config 69 | targetRancherClient *client.Clients 70 | ) 71 | if cmds.TargetRancherConfig != "" { 72 | targetRancherConfig, err = clientcmd.BuildConfigFromFlags("", cmds.TargetRancherConfig) 73 | if err != nil { 74 | return err 75 | } 76 | targetRancherClient, err = client.New(ctx, targetRancherConfig) 77 | if err != nil { 78 | return err 79 | } 80 | } 81 | prefixMsg := fmt.Sprintf("initiating source [%s] and target [%s] clusters objects.. ", source, target) 82 | if cmds.TargetRancherConfig != "" { 83 | prefixMsg = fmt.Sprintf("initiating source cluster [%s] on rancher host [%s] and target cluster [%s] on rancher host [%s] ", source, restConfig.Host, target, targetRancherConfig.Host) 84 | } 85 | cmds.Spinner.Prefix = prefixMsg 86 | cmds.Spinner.Start() 87 | 88 | if source == "" || target == "" { 89 | return errors.New("source or target is not specified") 90 | } 91 | 92 | var clusters v3.ClusterList 93 | var sourceCluster, targetCluster *v3.Cluster 94 | if err := cl.Clusters.List(ctx, "", &clusters, v1.ListOptions{}); err != nil { 95 | return err 96 | } 97 | 98 | for _, cluster := range clusters.Items { 99 | if cluster.Spec.DisplayName == source { 100 | sourceCluster = cluster.DeepCopy() 101 | } 102 | if targetRancherClient == nil { 103 | if cluster.Spec.DisplayName == target { 104 | targetCluster = cluster.DeepCopy() 105 | } 106 | } 107 | } 108 | if targetRancherClient != nil { 109 | // find the target cluster on the target rancher environment 110 | if err := targetRancherClient.Clusters.List(ctx, "", &clusters, v1.ListOptions{}); err != nil { 111 | return err 112 | } 113 | for _, cluster := range clusters.Items { 114 | if cluster.Spec.DisplayName == target { 115 | targetCluster = cluster.DeepCopy() 116 | } 117 | } 118 | } 119 | if sourceCluster == nil || targetCluster == nil { 120 | return errors.New("failed to find source or target cluster") 121 | } 122 | // initiate client for the cluster 123 | scConfig := *restConfig 124 | scConfig.Host = restConfig.Host + "/k8s/clusters/" + sourceCluster.Name 125 | scClient, err := client.New(ctx, &scConfig) 126 | if err != nil { 127 | return err 128 | } 129 | sc := &cluster.Cluster{ 130 | Obj: sourceCluster, 131 | Client: scClient, 132 | } 133 | tcConfig := *restConfig 134 | tcConfig.Host = restConfig.Host + "/k8s/clusters/" + targetCluster.Name 135 | if targetRancherClient != nil { 136 | tcConfig = *targetRancherConfig 137 | tcConfig.Host = targetRancherConfig.Host + "/k8s/clusters/" + targetCluster.Name 138 | } 139 | tcClient, err := client.New(ctx, &tcConfig) 140 | if err != nil { 141 | return err 142 | } 143 | tc := &cluster.Cluster{ 144 | Obj: targetCluster, 145 | Client: tcClient, 146 | } 147 | // check if the target cluster is in external rancher environment then set external rancher to true 148 | if targetRancherClient != nil { 149 | tc.ExternalRancher = true 150 | sc.ExternalRancher = true 151 | } 152 | if err := sc.Populate(ctx, cl); err != nil { 153 | return err 154 | } 155 | if targetRancherClient != nil { 156 | if err := tc.Populate(ctx, targetRancherClient); err != nil { 157 | return err 158 | } 159 | } else { 160 | if err := tc.Populate(ctx, cl); err != nil { 161 | return err 162 | } 163 | } 164 | if err := sc.Compare(ctx, tc); err != nil { 165 | return err 166 | } 167 | cmds.Spinner.Stop() 168 | return tui.StartTea(sc, tc, cl, targetRancherClient, logFilePath) 169 | } 170 | -------------------------------------------------------------------------------- /cli/cmds/migrate/migrate.go: -------------------------------------------------------------------------------- 1 | package migrate 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "rancherlabs/cattle-drive/cli/cmds" 9 | "rancherlabs/cattle-drive/pkg/client" 10 | "rancherlabs/cattle-drive/pkg/cluster" 11 | 12 | v3 "github.com/rancher/rancher/pkg/apis/management.cattle.io/v3" 13 | "github.com/sirupsen/logrus" 14 | "github.com/urfave/cli/v2" 15 | v1 "k8s.io/apimachinery/pkg/apis/meta/v1" 16 | "k8s.io/client-go/rest" 17 | "k8s.io/client-go/tools/clientcmd" 18 | ) 19 | 20 | var ( 21 | source string 22 | target string 23 | migrateFlags = []cli.Flag{ 24 | &cli.StringFlag{ 25 | Name: "source", 26 | Usage: "name of the source cluster", 27 | Destination: &source, 28 | Aliases: []string{"s"}, 29 | }, 30 | &cli.StringFlag{ 31 | Name: "target", 32 | Usage: "name of the target cluster", 33 | Destination: &target, 34 | Aliases: []string{"t"}, 35 | }, 36 | } 37 | ) 38 | 39 | func NewCommand() *cli.Command { 40 | return &cli.Command{ 41 | Name: "migrate", 42 | Usage: "Migrate command", 43 | Action: migrate, 44 | Flags: append(cmds.CommonFlags, migrateFlags...), 45 | } 46 | } 47 | 48 | func migrate(clx *cli.Context) error { 49 | ctx := context.Background() 50 | restConfig, err := clientcmd.BuildConfigFromFlags("", cmds.Kubeconfig) 51 | if err != nil { 52 | return err 53 | } 54 | cl, err := client.New(ctx, restConfig) 55 | if err != nil { 56 | return err 57 | } 58 | // check for target rancher 59 | var ( 60 | targetRancherConfig *rest.Config 61 | targetRancherClient *client.Clients 62 | ) 63 | if cmds.TargetRancherConfig != "" { 64 | targetRancherConfig, err = clientcmd.BuildConfigFromFlags("", cmds.TargetRancherConfig) 65 | if err != nil { 66 | return err 67 | } 68 | targetRancherClient, err = client.New(ctx, targetRancherConfig) 69 | if err != nil { 70 | return err 71 | } 72 | } 73 | prefixMsg := fmt.Sprintf("initiating source [%s] and target [%s] clusters objects.. ", source, target) 74 | if cmds.TargetRancherConfig != "" { 75 | prefixMsg = fmt.Sprintf("initiating source cluster [%s] on rancher host [%s] and target cluster [%s] on rancher host [%s] ", source, restConfig.Host, target, targetRancherConfig.Host) 76 | } 77 | cmds.Spinner.Prefix = prefixMsg 78 | cmds.Spinner.Start() 79 | 80 | if source == "" || target == "" { 81 | return errors.New("source or target is not specified") 82 | } 83 | 84 | var clusters v3.ClusterList 85 | var sourceCluster, targetCluster *v3.Cluster 86 | if err := cl.Clusters.List(ctx, "", &clusters, v1.ListOptions{}); err != nil { 87 | return err 88 | } 89 | 90 | for _, cluster := range clusters.Items { 91 | if cluster.Spec.DisplayName == source { 92 | sourceCluster = cluster.DeepCopy() 93 | } 94 | if targetRancherClient == nil { 95 | if cluster.Spec.DisplayName == target { 96 | targetCluster = cluster.DeepCopy() 97 | } 98 | } 99 | } 100 | if targetRancherClient != nil { 101 | // find the target cluster on the target rancher environment 102 | if err := targetRancherClient.Clusters.List(ctx, "", &clusters, v1.ListOptions{}); err != nil { 103 | return err 104 | } 105 | for _, cluster := range clusters.Items { 106 | if cluster.Spec.DisplayName == target { 107 | targetCluster = cluster.DeepCopy() 108 | } 109 | } 110 | } 111 | if sourceCluster == nil || targetCluster == nil { 112 | logrus.Fatal("failed to find source or target cluster") 113 | } 114 | // initiate client for the cluster 115 | scConfig := *restConfig 116 | scConfig.Host = restConfig.Host + "/k8s/clusters/" + sourceCluster.Name 117 | scClient, err := client.New(ctx, &scConfig) 118 | if err != nil { 119 | return err 120 | } 121 | sc := &cluster.Cluster{ 122 | Obj: sourceCluster, 123 | Client: scClient, 124 | } 125 | tcConfig := *restConfig 126 | tcConfig.Host = restConfig.Host + "/k8s/clusters/" + targetCluster.Name 127 | if targetRancherClient != nil { 128 | tcConfig = *targetRancherConfig 129 | tcConfig.Host = targetRancherConfig.Host + "/k8s/clusters/" + targetCluster.Name 130 | } 131 | tcClient, err := client.New(ctx, &tcConfig) 132 | if err != nil { 133 | return err 134 | } 135 | tc := &cluster.Cluster{ 136 | Obj: targetCluster, 137 | Client: tcClient, 138 | } 139 | // check if the target cluster is in external rancher environment then set external rancher to true 140 | if targetRancherClient != nil { 141 | tc.ExternalRancher = true 142 | sc.ExternalRancher = true 143 | } 144 | if err := sc.Populate(ctx, cl); err != nil { 145 | return err 146 | } 147 | if targetRancherClient != nil { 148 | if err := tc.Populate(ctx, targetRancherClient); err != nil { 149 | return err 150 | } 151 | } else { 152 | if err := tc.Populate(ctx, cl); err != nil { 153 | return err 154 | } 155 | } 156 | if err := sc.Compare(ctx, tc); err != nil { 157 | return err 158 | } 159 | cmds.Spinner.Stop() 160 | if targetRancherClient != nil { 161 | return sc.Migrate(ctx, targetRancherClient, tc, os.Stdout) 162 | } 163 | return sc.Migrate(ctx, cl, tc, os.Stdout) 164 | } 165 | -------------------------------------------------------------------------------- /cli/cmds/root.go: -------------------------------------------------------------------------------- 1 | package cmds 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/briandowns/spinner" 7 | "github.com/urfave/cli/v2" 8 | ) 9 | 10 | var ( 11 | Kubeconfig string 12 | TargetRancherConfig string 13 | CommonFlags = []cli.Flag{ 14 | &cli.StringFlag{ 15 | Name: "kubeconfig", 16 | EnvVars: []string{"KUBECONFIG"}, 17 | Usage: "Kubeconfig path", 18 | Destination: &Kubeconfig, 19 | }, 20 | &cli.StringFlag{ 21 | Name: "target-rancher-config", 22 | EnvVars: []string{"TARGET_RANCHER"}, 23 | Usage: "(experimental) migrate cluster objects to another rancher deployment", 24 | Destination: &TargetRancherConfig, 25 | }, 26 | } 27 | Spinner *spinner.Spinner 28 | ) 29 | 30 | func NewApp() *cli.App { 31 | app := cli.NewApp() 32 | app.Name = "cattle-drive" 33 | app.Usage = "Tool for migrating rancher objects for RKE downstream clusters" 34 | Spinner = spinner.New(spinner.CharSets[9], 100*time.Millisecond, spinner.WithColor("green")) 35 | return app 36 | } 37 | -------------------------------------------------------------------------------- /cli/cmds/status/status.go: -------------------------------------------------------------------------------- 1 | package status 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "rancherlabs/cattle-drive/cli/cmds" 8 | "rancherlabs/cattle-drive/pkg/client" 9 | "rancherlabs/cattle-drive/pkg/cluster" 10 | 11 | v3 "github.com/rancher/rancher/pkg/apis/management.cattle.io/v3" 12 | "github.com/urfave/cli/v2" 13 | v1 "k8s.io/apimachinery/pkg/apis/meta/v1" 14 | "k8s.io/client-go/rest" 15 | "k8s.io/client-go/tools/clientcmd" 16 | ) 17 | 18 | var ( 19 | source string 20 | target string 21 | statusFlags = []cli.Flag{ 22 | &cli.StringFlag{ 23 | Name: "source", 24 | Usage: "name of the source cluster", 25 | Destination: &source, 26 | Aliases: []string{"s"}, 27 | }, 28 | &cli.StringFlag{ 29 | Name: "target", 30 | Usage: "name of the target cluster", 31 | Destination: &target, 32 | Aliases: []string{"t"}, 33 | }, 34 | } 35 | ) 36 | 37 | func NewCommand() *cli.Command { 38 | return &cli.Command{ 39 | Name: "status", 40 | Usage: "Status command", 41 | Action: status, 42 | Flags: append(cmds.CommonFlags, statusFlags...), 43 | } 44 | } 45 | 46 | func status(clx *cli.Context) error { 47 | ctx := context.Background() 48 | restConfig, err := clientcmd.BuildConfigFromFlags("", cmds.Kubeconfig) 49 | if err != nil { 50 | return err 51 | } 52 | cl, err := client.New(ctx, restConfig) 53 | if err != nil { 54 | return err 55 | } 56 | // check for target rancher 57 | var ( 58 | targetRancherConfig *rest.Config 59 | targetRancherClient *client.Clients 60 | ) 61 | if cmds.TargetRancherConfig != "" { 62 | targetRancherConfig, err = clientcmd.BuildConfigFromFlags("", cmds.TargetRancherConfig) 63 | if err != nil { 64 | return err 65 | } 66 | targetRancherClient, err = client.New(ctx, targetRancherConfig) 67 | if err != nil { 68 | return err 69 | } 70 | } 71 | prefixMsg := fmt.Sprintf("initiating source [%s] and target [%s] clusters objects.. ", source, target) 72 | if cmds.TargetRancherConfig != "" { 73 | prefixMsg = fmt.Sprintf("initiating source cluster [%s] on rancher host [%s] and target cluster [%s] on rancher host [%s] ", source, restConfig.Host, target, targetRancherConfig.Host) 74 | } 75 | cmds.Spinner.Prefix = prefixMsg 76 | cmds.Spinner.Start() 77 | 78 | if source == "" || target == "" { 79 | return errors.New("source or target is not specified") 80 | } 81 | 82 | var clusters v3.ClusterList 83 | var sourceCluster, targetCluster *v3.Cluster 84 | if err := cl.Clusters.List(ctx, "", &clusters, v1.ListOptions{}); err != nil { 85 | return err 86 | } 87 | 88 | for _, cluster := range clusters.Items { 89 | if cluster.Spec.DisplayName == source { 90 | sourceCluster = cluster.DeepCopy() 91 | } 92 | // adding this check to avoid same target name on the wrong rancher environment 93 | if targetRancherClient == nil { 94 | if cluster.Spec.DisplayName == target { 95 | targetCluster = cluster.DeepCopy() 96 | } 97 | } 98 | } 99 | if targetRancherClient != nil { 100 | // find the target cluster on the target rancher environment 101 | if err := targetRancherClient.Clusters.List(ctx, "", &clusters, v1.ListOptions{}); err != nil { 102 | return err 103 | } 104 | for _, cluster := range clusters.Items { 105 | if cluster.Spec.DisplayName == target { 106 | targetCluster = cluster.DeepCopy() 107 | } 108 | } 109 | } 110 | if sourceCluster == nil || targetCluster == nil { 111 | return errors.New("failed to find source or target cluster") 112 | } 113 | // initiate client for the cluster 114 | scConfig := *restConfig 115 | scConfig.Host = restConfig.Host + "/k8s/clusters/" + sourceCluster.Name 116 | scClient, err := client.New(ctx, &scConfig) 117 | if err != nil { 118 | return err 119 | } 120 | sc := &cluster.Cluster{ 121 | Obj: sourceCluster, 122 | Client: scClient, 123 | } 124 | tcConfig := *restConfig 125 | tcConfig.Host = restConfig.Host + "/k8s/clusters/" + targetCluster.Name 126 | if targetRancherClient != nil { 127 | tcConfig = *targetRancherConfig 128 | tcConfig.Host = targetRancherConfig.Host + "/k8s/clusters/" + targetCluster.Name 129 | } 130 | tcClient, err := client.New(ctx, &tcConfig) 131 | if err != nil { 132 | return err 133 | } 134 | tc := &cluster.Cluster{ 135 | Obj: targetCluster, 136 | Client: tcClient, 137 | } 138 | // check if the target cluster is in external rancher environment then set external rancher to true 139 | if targetRancherClient != nil { 140 | tc.ExternalRancher = true 141 | sc.ExternalRancher = true 142 | } 143 | if err := sc.Populate(ctx, cl); err != nil { 144 | return err 145 | } 146 | if targetRancherClient != nil { 147 | if err := tc.Populate(ctx, targetRancherClient); err != nil { 148 | return err 149 | } 150 | } else { 151 | if err := tc.Populate(ctx, cl); err != nil { 152 | return err 153 | } 154 | } 155 | if err := sc.Compare(ctx, tc); err != nil { 156 | return err 157 | } 158 | cmds.Spinner.Stop() 159 | return sc.Status(ctx) 160 | } 161 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module rancherlabs/cattle-drive 2 | 3 | go 1.21.4 4 | 5 | replace ( 6 | github.com/rancher/rancher/pkg/apis => github.com/rancher/rancher/pkg/apis v0.0.0-20240205102821-ed248439462a 7 | k8s.io/api => k8s.io/api v0.28.4 8 | k8s.io/apiextensions-apiserver => k8s.io/apiextensions-apiserver v0.28.4 9 | k8s.io/apimachinery => k8s.io/apimachinery v0.28.4 10 | k8s.io/apiserver => k8s.io/apiserver v0.28.4 11 | k8s.io/cli-runtime => k8s.io/cli-runtime v0.28.4 12 | k8s.io/client-go => github.com/rancher/client-go v1.28.6-rancher1 13 | k8s.io/cloud-provider => k8s.io/cloud-provider v0.28.4 14 | k8s.io/cluster-bootstrap => k8s.io/cluster-bootstrap v0.28.4 15 | k8s.io/code-generator => k8s.io/code-generator v0.28.4 16 | k8s.io/component-base => k8s.io/component-base v0.28.4 17 | k8s.io/cri-api => k8s.io/cri-api v0.28.4 18 | k8s.io/csi-translation-lib => k8s.io/csi-translation-lib v0.28.4 19 | k8s.io/kube-aggregator => k8s.io/kube-aggregator v0.28.4 20 | k8s.io/kube-controller-manager => k8s.io/kube-controller-manager v0.28.4 21 | k8s.io/kube-proxy => k8s.io/kube-proxy v0.28.4 22 | k8s.io/kube-scheduler => k8s.io/kube-scheduler v0.28.4 23 | k8s.io/kubectl => k8s.io/kubectl v0.28.4 24 | k8s.io/kubelet => k8s.io/kubelet v0.28.4 25 | k8s.io/kubernetes => k8s.io/kubernetes v1.28.4 26 | k8s.io/legacy-cloud-providers => k8s.io/legacy-cloud-providers v0.28.4 27 | k8s.io/metrics => k8s.io/metrics v0.28.4 28 | k8s.io/sample-apiserver => k8s.io/sample-apiserver v0.28.4 29 | ) 30 | 31 | require ( 32 | github.com/briandowns/spinner v1.23.0 33 | github.com/charmbracelet/lipgloss v0.9.1 34 | github.com/rancher/rancher/pkg/apis v0.0.0-20230915232223-a9ea4ce4a5ba 35 | github.com/rancher/wrangler v1.1.1 36 | github.com/sirupsen/logrus v1.9.3 37 | k8s.io/client-go v12.0.0+incompatible 38 | ) 39 | 40 | require ( 41 | github.com/atotto/clipboard v0.1.4 // indirect 42 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 43 | github.com/charmbracelet/harmonica v0.2.0 // indirect 44 | github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect 45 | github.com/fatih/color v1.7.0 // indirect 46 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 47 | github.com/mattn/go-colorable v0.1.8 // indirect 48 | github.com/mattn/go-isatty v0.0.18 // indirect 49 | github.com/mattn/go-localereader v0.0.1 // indirect 50 | github.com/mattn/go-runewidth v0.0.15 // indirect 51 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect 52 | github.com/muesli/cancelreader v0.2.2 // indirect 53 | github.com/muesli/reflow v0.3.0 // indirect 54 | github.com/muesli/termenv v0.15.2 // indirect 55 | github.com/rancher/norman v0.0.0-20230831160711-5de27f66385d // indirect 56 | github.com/rivo/uniseg v0.4.6 // indirect 57 | github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f // indirect 58 | ) 59 | 60 | require ( 61 | github.com/beorn7/perks v1.0.1 // indirect 62 | github.com/blang/semver/v4 v4.0.0 // indirect 63 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 64 | github.com/charmbracelet/bubbles v0.18.0 65 | github.com/charmbracelet/bubbletea v0.25.0 66 | github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect 67 | github.com/davecgh/go-spew v1.1.1 // indirect 68 | github.com/emicklei/go-restful/v3 v3.10.2 // indirect 69 | github.com/evanphx/json-patch v4.12.0+incompatible // indirect 70 | github.com/ghodss/yaml v1.0.0 // indirect 71 | github.com/go-logr/logr v1.3.0 // indirect 72 | github.com/go-openapi/jsonpointer v0.19.6 // indirect 73 | github.com/go-openapi/jsonreference v0.20.2 // indirect 74 | github.com/go-openapi/swag v0.22.3 // indirect 75 | github.com/gogo/protobuf v1.3.2 // indirect 76 | github.com/golang/protobuf v1.5.3 // indirect 77 | github.com/google/gnostic-models v0.6.8 // indirect 78 | github.com/google/go-cmp v0.6.0 // indirect 79 | github.com/google/gofuzz v1.2.0 // indirect 80 | github.com/google/uuid v1.5.0 // indirect 81 | github.com/imdario/mergo v0.3.16 // indirect 82 | github.com/josharian/intern v1.0.0 // indirect 83 | github.com/json-iterator/go v1.1.12 // indirect 84 | github.com/mailru/easyjson v0.7.7 // indirect 85 | github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect 86 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 87 | github.com/modern-go/reflect2 v1.0.2 // indirect 88 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 89 | github.com/pkg/errors v0.9.1 // indirect 90 | github.com/prometheus/client_golang v1.16.0 // indirect 91 | github.com/prometheus/client_model v0.4.0 // indirect 92 | github.com/prometheus/common v0.44.0 // indirect 93 | github.com/prometheus/procfs v0.10.1 // indirect 94 | github.com/rancher/aks-operator v1.3.0-rc1 // indirect 95 | github.com/rancher/eks-operator v1.4.0-rc1 // indirect 96 | github.com/rancher/fleet/pkg/apis v0.0.0-20231017140638-93432f288e79 // indirect 97 | github.com/rancher/gke-operator v1.3.0-rc2 // indirect 98 | github.com/rancher/lasso v0.0.0-20230830164424-d684fdeb6f29 99 | github.com/rancher/rke v1.5.0 // indirect 100 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 101 | github.com/spf13/pflag v1.0.5 // indirect 102 | github.com/urfave/cli/v2 v2.27.1 103 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect 104 | golang.org/x/net v0.19.0 // indirect 105 | golang.org/x/oauth2 v0.15.0 // indirect 106 | golang.org/x/sync v0.5.0 // indirect 107 | golang.org/x/sys v0.15.0 // indirect 108 | golang.org/x/term v0.15.0 // indirect 109 | golang.org/x/text v0.14.0 // indirect 110 | golang.org/x/time v0.5.0 // indirect 111 | google.golang.org/appengine v1.6.8 // indirect 112 | google.golang.org/protobuf v1.31.0 // indirect 113 | gopkg.in/inf.v0 v0.9.1 // indirect 114 | gopkg.in/yaml.v2 v2.4.0 // indirect 115 | gopkg.in/yaml.v3 v3.0.1 // indirect 116 | k8s.io/api v0.28.6 117 | k8s.io/apiextensions-apiserver v0.27.5 // indirect 118 | k8s.io/apimachinery v0.28.6 119 | k8s.io/apiserver v0.28.4 // indirect 120 | k8s.io/component-base v0.28.4 // indirect 121 | k8s.io/klog v1.0.0 // indirect 122 | k8s.io/klog/v2 v2.100.1 // indirect 123 | k8s.io/kube-aggregator v0.25.4 // indirect 124 | k8s.io/kube-openapi v0.0.0-20230717233707-2695361300d9 // indirect 125 | k8s.io/kubernetes v1.27.9 // indirect 126 | k8s.io/utils v0.0.0-20230505201702-9f6742963106 // indirect 127 | sigs.k8s.io/cli-utils v0.27.0 // indirect 128 | sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect 129 | sigs.k8s.io/structured-merge-diff/v4 v4.2.3 // indirect 130 | sigs.k8s.io/yaml v1.4.0 // indirect 131 | ) 132 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "rancherlabs/cattle-drive/cli/cmds" 8 | "rancherlabs/cattle-drive/cli/cmds/interactive" 9 | "rancherlabs/cattle-drive/cli/cmds/migrate" 10 | "rancherlabs/cattle-drive/cli/cmds/status" 11 | "rancherlabs/cattle-drive/pkg/version" 12 | 13 | "github.com/sirupsen/logrus" 14 | "github.com/urfave/cli/v2" 15 | ) 16 | 17 | func main() { 18 | app := cmds.NewApp() 19 | app.Commands = []*cli.Command{ 20 | status.NewCommand(), 21 | migrate.NewCommand(), 22 | interactive.NewCommand(), 23 | } 24 | app.Version = fmt.Sprintf("%s (%s)", version.Version, version.GitCommit) 25 | 26 | logrus.SetOutput(io.Discard) 27 | if err := app.Run(os.Args); err != nil { 28 | fmt.Printf("exiting tool: %v", err) 29 | os.Exit(1) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /ops/build: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | source ./ops/version 4 | 5 | BUILDTAGS="netgo osusergo" 6 | GO_BUILDTAGS="${BUILD_TAGS} ${GO_BUILDTAGS} ${BUILDTAGS} ${DEBUG_TAGS}" 7 | 8 | VERSION_FLAGS=" 9 | -X ${CATTLE_DRIVE_PKG}/pkg/version.GitCommit=${REVISION} 10 | -X ${CATTLE_DRIVE_PKG}/pkg/version.Program=${PROG} 11 | -X ${CATTLE_DRIVE_PKG}/pkg/version.Version=${VERSION} 12 | " 13 | 14 | STATIC_FLAGS='-extldflags "-static"' 15 | 16 | go build \ 17 | -tags "${GO_BUILDTAGS}" \ 18 | -o bin/${PROG} \ 19 | -ldflags "${VERSION_FLAGS}" \ 20 | ${GO_TAGS} -------------------------------------------------------------------------------- /ops/package: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rancherlabs/cattle-drive/b794526f844fdbc72cb1529fff146d5a75e69400/ops/package -------------------------------------------------------------------------------- /ops/version: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -x 3 | 4 | CATTLE_DRIVE_PKG=rancherlabs/cattle-drive 5 | PROG=cattle-drive 6 | REGISTRY=docker.io 7 | REPO=${REPO:-husseingalal} 8 | GO=${GO-go} 9 | GOARCH=${GOARCH:-$("${GO}" env GOARCH)} 10 | GOOS=${GOOS:-$("${GO}" env GOOS)} 11 | if [ -z "$GOOS" ]; then 12 | if [ "${OS}" == "Windows_NT" ]; then 13 | GOOS="windows" 14 | else 15 | UNAME_S=$(shell uname -s) 16 | if [ "${UNAME_S}" == "Linux" ]; then 17 | GOOS="linux" 18 | elif [ "${UNAME_S}" == "Darwin" ]; then 19 | GOOS="darwin" 20 | elif [ "${UNAME_S}" == "FreeBSD" ]; then 21 | GOOS="freebsd" 22 | fi 23 | fi 24 | fi 25 | 26 | TREE_STATE=clean 27 | COMMIT=$DRONE_COMMIT 28 | REVISION=$(git rev-parse HEAD)$(if ! git diff --no-ext-diff --quiet --exit-code; then echo .dirty; fi) 29 | PLATFORM=${GOOS}-${GOARCH} 30 | RELEASE=${PROG}.${PLATFORM} 31 | 32 | if [ -d .git ]; then 33 | if [ -z "$GIT_TAG" ]; then 34 | GIT_TAG=$(git tag -l --contains HEAD | head -n 1) 35 | fi 36 | if [ -n "$(git status --porcelain --untracked-files=no)" ]; then 37 | DIRTY="-dirty" 38 | TREE_STATE=dirty 39 | fi 40 | 41 | COMMIT=$(git log -n3 --pretty=format:"%H %ae" | cut -f1 -d\ | head -1) 42 | if [ -z "${COMMIT}" ]; then 43 | COMMIT=$(git rev-parse HEAD || true) 44 | fi 45 | fi 46 | 47 | if [[ -n "$GIT_TAG" ]]; then 48 | VERSION=$GIT_TAG 49 | else 50 | VERSION="${VERSION}+dev.${COMMIT:0:8}$DIRTY" 51 | fi -------------------------------------------------------------------------------- /pkg/client/client.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/rancher/lasso/pkg/client" 7 | "github.com/rancher/lasso/pkg/controller" 8 | v3 "github.com/rancher/rancher/pkg/apis/cluster.cattle.io/v3" 9 | "github.com/rancher/wrangler/pkg/clients" 10 | v1 "github.com/rancher/wrangler/pkg/generated/controllers/core/v1" 11 | "k8s.io/apimachinery/pkg/runtime" 12 | "k8s.io/apimachinery/pkg/runtime/schema" 13 | "k8s.io/client-go/rest" 14 | ) 15 | 16 | type Clients struct { 17 | Clusters *client.Client 18 | Projects *client.Client 19 | ProjectRoleTemplateBindings *client.Client 20 | ClusterRoleTemplateBindings *client.Client 21 | ClusterRegistrationTokens *client.Client 22 | Users *client.Client 23 | ClusterRepos *client.Client 24 | GlobalRoleBindings *client.Client 25 | ConfigMaps v1.ConfigMapClient 26 | Namespace v1.NamespaceClient 27 | } 28 | 29 | func New(ctx context.Context, rest *rest.Config) (*Clients, error) { 30 | clients, err := clients.NewFromConfig(rest, nil) 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | if err := clients.Start(ctx); err != nil { 36 | return nil, err 37 | } 38 | 39 | localSchemeBuilder := runtime.SchemeBuilder{ 40 | v3.AddToScheme, 41 | } 42 | scheme := runtime.NewScheme() 43 | err = localSchemeBuilder.AddToScheme(scheme) 44 | if err != nil { 45 | return nil, err 46 | } 47 | factory, err := controller.NewSharedControllerFactoryFromConfig(rest, scheme) 48 | if err != nil { 49 | return nil, err 50 | } 51 | 52 | return &Clients{ 53 | ConfigMaps: clients.Core.ConfigMap(), 54 | Namespace: clients.Core.Namespace(), 55 | Users: NewClient(factory, "management.cattle.io", "v3", "users", "User", false), 56 | Clusters: NewClient(factory, "management.cattle.io", "v3", "clusters", "Cluster", false), 57 | Projects: NewClient(factory, "management.cattle.io", "v3", "projects", "Project", true), 58 | ProjectRoleTemplateBindings: NewClient(factory, "management.cattle.io", "v3", "projectRoleTemplateBindings", "ProjectRoleTemplateBinding", true), 59 | ClusterRoleTemplateBindings: NewClient(factory, "management.cattle.io", "v3", "clusterRoleTemplateBindings", "ClusterRoleTemplateBinding", true), 60 | ClusterRegistrationTokens: NewClient(factory, "management.cattle.io", "v3", "clusterRegistrationTokens", "ClusterRegistrationToken", false), 61 | ClusterRepos: NewClient(factory, "catalog.cattle.io", "v1", "clusterRepos", "ClusterRepo", false), 62 | GlobalRoleBindings: NewClient(factory, "management.cattle.io", "v3", "globalRoleBindings", "GlobalRoleBinding", false), 63 | }, nil 64 | } 65 | 66 | func NewClient(factory controller.SharedControllerFactory, group, version, resource, kind string, namespaced bool) *client.Client { 67 | gvr := schema.GroupVersionResource{Group: group, Resource: resource, Version: version} 68 | sharedController := factory.ForResourceKind(gvr, kind, namespaced) 69 | return sharedController.Client() 70 | } 71 | -------------------------------------------------------------------------------- /pkg/cluster/app.go: -------------------------------------------------------------------------------- 1 | package cluster 2 | 3 | import ( 4 | "strings" 5 | 6 | v1 "github.com/rancher/rancher/pkg/apis/catalog.cattle.io/v1" 7 | ) 8 | 9 | type App struct { 10 | Name string 11 | Obj *v1.App 12 | Migrated bool 13 | Diff bool 14 | } 15 | 16 | func newApp(obj v1.App) (*App, bool) { 17 | if strings.HasPrefix(obj.Name, "rancher-") { 18 | return nil, true 19 | } 20 | return &App{ 21 | Name: obj.Name, 22 | Obj: obj.DeepCopy(), 23 | Migrated: false, 24 | Diff: false, 25 | }, false 26 | } 27 | 28 | func (a *App) normalize() { 29 | } 30 | 31 | func (a *App) mutate() { 32 | a.Obj.SetFinalizers(nil) 33 | a.Obj.SetResourceVersion("") 34 | } 35 | -------------------------------------------------------------------------------- /pkg/cluster/cluster.go: -------------------------------------------------------------------------------- 1 | package cluster 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "rancherlabs/cattle-drive/pkg/client" 9 | "reflect" 10 | 11 | v1catalog "github.com/rancher/rancher/pkg/apis/catalog.cattle.io/v1" 12 | v3 "github.com/rancher/rancher/pkg/apis/management.cattle.io/v3" 13 | apierrors "k8s.io/apimachinery/pkg/api/errors" 14 | v1 "k8s.io/apimachinery/pkg/apis/meta/v1" 15 | ) 16 | 17 | type Cluster struct { 18 | Obj *v3.Cluster 19 | ToMigrate ToMigrate 20 | SystemUser *v3.User 21 | DefaultAdmin *v3.User 22 | Client *client.Clients 23 | ExternalRancher bool 24 | } 25 | 26 | type ToMigrate struct { 27 | Projects []*Project 28 | CRTBs []*ClusterRoleTemplateBinding 29 | // apps related objects 30 | ClusterRepos []*ClusterRepo 31 | Apps []*App 32 | Users []*User 33 | } 34 | 35 | // Populate will fill in the objects to be migrated 36 | func (c *Cluster) Populate(ctx context.Context, client *client.Clients) error { 37 | var ( 38 | projects v3.ProjectList 39 | projectRoleTemplateBindings v3.ProjectRoleTemplateBindingList 40 | clusterRoleTemplateBindings v3.ClusterRoleTemplateBindingList 41 | users v3.UserList 42 | repos v1catalog.ClusterRepoList 43 | grbs v3.GlobalRoleBindingList 44 | ) 45 | // systemUsers 46 | if err := client.Users.List(ctx, "", &users, v1.ListOptions{}); err != nil { 47 | return err 48 | } 49 | usersList := []*User{} 50 | for _, user := range users.Items { 51 | for _, principalID := range user.PrincipalIDs { 52 | if principalID == "system://"+c.Obj.Name { 53 | c.SystemUser = user.DeepCopy() 54 | break 55 | } 56 | } 57 | if c.ExternalRancher { 58 | if user.Name == c.Obj.Annotations["field.cattle.io/creatorId"] { 59 | // use the cluster creator user as the default admin for any new project 60 | c.DefaultAdmin = user.DeepCopy() 61 | } 62 | if user.Username == "admin" || user.Username == "" { 63 | continue 64 | } 65 | var grbList []*GlobalRoleBinding 66 | if err := client.GlobalRoleBindings.List(ctx, "", &grbs, v1.ListOptions{}); err != nil { 67 | return err 68 | } 69 | for _, grb := range grbs.Items { 70 | if grb.UserName == user.Name { 71 | newGRB := newGRB(grb) 72 | newGRB.normalize() 73 | newGRB.SetDescription(user) 74 | grbList = append(grbList, newGRB) 75 | } 76 | } 77 | // populate users 78 | u := newUser(user, grbList) 79 | u.normalize() 80 | usersList = append(usersList, u) 81 | 82 | } 83 | } 84 | // namespaces 85 | namespaces, err := c.Client.Namespace.List(v1.ListOptions{}) 86 | if err != nil { 87 | return err 88 | } 89 | // projects 90 | if err := client.Projects.List(ctx, c.Obj.Name, &projects, v1.ListOptions{}); err != nil { 91 | return err 92 | } 93 | pList := []*Project{} 94 | for _, p := range projects.Items { 95 | // skip default projects before listing their prtb or roles 96 | if p.Spec.DisplayName == "Default" || p.Spec.DisplayName == "System" { 97 | continue 98 | } 99 | // prtbs 100 | if err := client.ProjectRoleTemplateBindings.List(ctx, p.Name, &projectRoleTemplateBindings, v1.ListOptions{}); err != nil { 101 | return err 102 | } 103 | prtbList := []*ProjectRoleTemplateBinding{} 104 | for _, item := range projectRoleTemplateBindings.Items { 105 | if item.Name == "creator-project-owner" || item.Name == "creator-project-member" { 106 | continue 107 | } 108 | prtb := newPRTB(item, "", p.Spec.DisplayName) 109 | prtb.normalize() 110 | if err := prtb.SetDescription(ctx, client); err != nil { 111 | return err 112 | } 113 | prtbList = append(prtbList, prtb) 114 | } 115 | nsList := []*Namespace{} 116 | for _, ns := range namespaces.Items { 117 | if projectID, ok := ns.Labels[projectIDLabelAnnotation]; ok && projectID == p.Name { 118 | n := newNamespace(ns, "", p.Spec.DisplayName) 119 | n.normalize() 120 | nsList = append(nsList, n) 121 | } 122 | } 123 | p := newProject(p, prtbList, nsList) 124 | p.normalize() 125 | pList = append(pList, p) 126 | } 127 | 128 | crtbList := []*ClusterRoleTemplateBinding{} 129 | if err := client.ClusterRoleTemplateBindings.List(ctx, c.Obj.Name, &clusterRoleTemplateBindings, v1.ListOptions{}); err != nil { 130 | return err 131 | } 132 | for _, item := range clusterRoleTemplateBindings.Items { 133 | crtb, isDefault := newCRTB(item, c.SystemUser) 134 | if isDefault { 135 | continue 136 | } 137 | crtb.normalize() 138 | if err := crtb.SetDescription(ctx, client); err != nil { 139 | return err 140 | } 141 | crtbList = append(crtbList, crtb) 142 | } 143 | // apps 144 | // cluster repos 145 | reposList := []*ClusterRepo{} 146 | if err := c.Client.ClusterRepos.List(ctx, "", &repos, v1.ListOptions{}); err != nil { 147 | return err 148 | } 149 | for _, item := range repos.Items { 150 | repo, isDefault := newClusterRepo(item) 151 | if isDefault { 152 | continue 153 | } 154 | repo.normalize() 155 | reposList = append(reposList, repo) 156 | 157 | } 158 | 159 | c.ToMigrate = ToMigrate{ 160 | Projects: pList, 161 | CRTBs: crtbList, 162 | ClusterRepos: reposList, 163 | Users: usersList, 164 | } 165 | return nil 166 | } 167 | 168 | // Compare will compare between objects of downstream source cluster and target cluster 169 | func (c *Cluster) Compare(ctx context.Context, tc *Cluster) error { 170 | // users 171 | for _, sUser := range c.ToMigrate.Users { 172 | for _, tUser := range tc.ToMigrate.Users { 173 | if sUser.Name == tUser.Name && sUser.Obj.Username == tUser.Obj.Username { 174 | sUser.Migrated = true 175 | for _, sGRB := range sUser.GlobalRoleBindings { 176 | for _, tGRB := range tUser.GlobalRoleBindings { 177 | if sGRB.Name == tGRB.Name { 178 | sGRB.Migrated = true 179 | } 180 | } 181 | } 182 | } 183 | } 184 | } 185 | // projects 186 | for _, sProject := range c.ToMigrate.Projects { 187 | for _, tProject := range tc.ToMigrate.Projects { 188 | if sProject.Name == tProject.Name { 189 | sProject.Migrated = true 190 | if !reflect.DeepEqual(sProject.Obj.Spec, tProject.Obj.Spec) { 191 | sProject.Diff = true 192 | break 193 | } else { 194 | // its critical to adjust the project name here because its used in different other objects ns/prtbs 195 | sProject.Obj.Name = tProject.Obj.Name 196 | for _, sPRTB := range sProject.PRTBs { 197 | sPRTB.ProjectName = tProject.Obj.Name 198 | } 199 | for _, ns := range sProject.Namespaces { 200 | ns.ProjectName = tProject.Obj.Name 201 | } 202 | } 203 | // now we check for prtbs related to that project 204 | for _, sPrtb := range sProject.PRTBs { 205 | for _, tPrtb := range tProject.PRTBs { 206 | if sPrtb.Name == tPrtb.Name { 207 | sPrtb.Migrated = true 208 | if !sPrtb.Compare(tPrtb) { 209 | sPrtb.Diff = true 210 | } 211 | } 212 | } 213 | } 214 | // namespaces 215 | for _, ns := range sProject.Namespaces { 216 | for _, tns := range tProject.Namespaces { 217 | if ns.Name == tns.Name { 218 | ns.Migrated = true 219 | if !reflect.DeepEqual(ns.Obj, tns.Obj) { 220 | ns.Diff = true 221 | } 222 | } 223 | } 224 | } 225 | } 226 | } 227 | } 228 | 229 | // crtbs 230 | for _, sCrtb := range c.ToMigrate.CRTBs { 231 | for _, tCrtb := range tc.ToMigrate.CRTBs { 232 | if sCrtb.Name == tCrtb.Name { 233 | sCrtb.Migrated = true 234 | if !reflect.DeepEqual(sCrtb.Obj, tCrtb.Obj) { 235 | sCrtb.Diff = true 236 | break 237 | } 238 | } 239 | } 240 | } 241 | 242 | for _, sRepo := range c.ToMigrate.ClusterRepos { 243 | for _, tRepo := range tc.ToMigrate.ClusterRepos { 244 | if sRepo.Name == tRepo.Name { 245 | sRepo.Migrated = true 246 | if !reflect.DeepEqual(sRepo.Obj.Spec, tRepo.Obj.Spec) { 247 | sRepo.Diff = true 248 | break 249 | } 250 | } 251 | } 252 | } 253 | return nil 254 | } 255 | 256 | func (c *Cluster) Status(ctx context.Context) error { 257 | if c.ExternalRancher { 258 | fmt.Printf("Users status:\n") 259 | for _, u := range c.ToMigrate.Users { 260 | print(u.Obj.Username, u.Migrated, u.Diff, 0) 261 | if len(u.GlobalRoleBindings) > 0 { 262 | fmt.Printf(" -> user permissions:\n") 263 | } 264 | for _, grb := range u.GlobalRoleBindings { 265 | print(grb.Name+": "+grb.Description, grb.Migrated, grb.Diff, 1) 266 | } 267 | } 268 | } 269 | 270 | fmt.Printf("Project status:\n") 271 | for _, p := range c.ToMigrate.Projects { 272 | print(p.Name, p.Migrated, p.Diff, 0) 273 | if len(p.PRTBs) > 0 { 274 | fmt.Printf(" -> users permissions:\n") 275 | } 276 | for _, prtb := range p.PRTBs { 277 | print(prtb.Name+": "+prtb.Description, prtb.Migrated, prtb.Diff, 1) 278 | } 279 | if len(p.Namespaces) > 0 { 280 | fmt.Printf(" -> namespaces:\n") 281 | } 282 | for _, ns := range p.Namespaces { 283 | print(ns.Name, ns.Migrated, ns.Diff, 1) 284 | } 285 | 286 | } 287 | fmt.Printf("Cluster users permissions:\n") 288 | for _, crtb := range c.ToMigrate.CRTBs { 289 | print(crtb.Name+": "+crtb.Description, crtb.Migrated, crtb.Diff, 0) 290 | } 291 | fmt.Printf("Catalog repos:\n") 292 | for _, repo := range c.ToMigrate.ClusterRepos { 293 | print(repo.Name, repo.Migrated, repo.Diff, 0) 294 | } 295 | return nil 296 | } 297 | 298 | func (c *Cluster) Migrate(ctx context.Context, client *client.Clients, tc *Cluster, w io.Writer) error { 299 | fmt.Fprintf(w, "Migrating Objects from cluster [%s] to cluster [%s]:\n", c.Obj.Spec.DisplayName, tc.Obj.Spec.DisplayName) 300 | // users 301 | if c.ExternalRancher { 302 | for _, u := range c.ToMigrate.Users { 303 | if !u.Migrated { 304 | fmt.Fprintf(w, "- migrating User [%s]... ", u.Obj.Username) 305 | 306 | u.Mutate() 307 | if err := client.Users.Create(ctx, "", u.Obj, nil, v1.CreateOptions{}); err != nil { 308 | return err 309 | } 310 | // migrating all grbs for this user 311 | for _, grb := range u.GlobalRoleBindings { 312 | grb.Mutate() 313 | if err := client.GlobalRoleBindings.Create(ctx, "", grb.Obj, nil, v1.CreateOptions{}); err != nil { 314 | return err 315 | } 316 | } 317 | fmt.Fprintf(w, "Done.\n") 318 | } 319 | } 320 | } 321 | 322 | for _, p := range c.ToMigrate.Projects { 323 | if !p.Migrated { 324 | fmt.Fprintf(w, "- migrating Project [%s]... ", p.Name) 325 | p.Mutate(tc) 326 | if err := client.Projects.Create(ctx, tc.Obj.Name, p.Obj, nil, v1.CreateOptions{}); err != nil { 327 | return err 328 | } 329 | // set ProjectName for all ns and prtbs for this project 330 | for _, sPRTB := range p.PRTBs { 331 | sPRTB.ProjectName = p.Obj.Name 332 | } 333 | for _, ns := range p.Namespaces { 334 | ns.ProjectName = p.Obj.Name 335 | } 336 | fmt.Fprintf(w, "Done.\n") 337 | } 338 | 339 | for _, prtb := range p.PRTBs { 340 | if !prtb.Migrated { 341 | fmt.Fprintf(w, " - migrating PRTB [%s]... ", prtb.Name) 342 | // check if the user exists first in case of external rancher 343 | userID := prtb.Obj.UserName 344 | var user v3.User 345 | if err := client.Users.Get(ctx, "", userID, &user, v1.GetOptions{}); err != nil { 346 | if apierrors.IsNotFound(err) { 347 | return errors.New("user " + userID + " does not exists, please migrate user first") 348 | } 349 | } 350 | prtb.Mutate(tc.Obj.Name, prtb.ProjectName) 351 | if err := client.ProjectRoleTemplateBindings.Create(ctx, prtb.ProjectName, prtb.Obj, nil, v1.CreateOptions{}); err != nil { 352 | return err 353 | } 354 | fmt.Fprintf(w, "Done.\n") 355 | } 356 | } 357 | for _, ns := range p.Namespaces { 358 | if !ns.Migrated { 359 | fmt.Fprintf(w, " - migrating Namespace [%s]... ", ns.Name) 360 | ns.Mutate(tc.Obj.Name, ns.ProjectName) 361 | if _, err := tc.Client.Namespace.Create(ns.Obj); err != nil { 362 | return err 363 | } 364 | fmt.Fprintf(w, "Done.\n") 365 | } 366 | } 367 | } 368 | for _, crtb := range c.ToMigrate.CRTBs { 369 | if !crtb.Migrated { 370 | fmt.Fprintf(w, "- migrating CRTB [%s]... ", crtb.Name) 371 | // check if the user exists first in case of external rancher 372 | userID := crtb.Obj.UserName 373 | var user v3.User 374 | if err := client.Users.Get(ctx, "", userID, &user, v1.GetOptions{}); err != nil { 375 | if apierrors.IsNotFound(err) { 376 | return errors.New("user " + userID + " does not exists, please migrate user first") 377 | } 378 | } 379 | 380 | crtb.Mutate(tc) 381 | if err := client.ClusterRoleTemplateBindings.Create(ctx, tc.Obj.Name, crtb.Obj, nil, v1.CreateOptions{}); err != nil { 382 | return err 383 | } 384 | fmt.Fprintf(w, "Done.\n") 385 | } 386 | } 387 | // catalog repos 388 | for _, repo := range c.ToMigrate.ClusterRepos { 389 | if !repo.Migrated { 390 | fmt.Fprintf(w, "- migrating catalog repo [%s]... ", repo.Name) 391 | repo.Mutate() 392 | if err := tc.Client.ClusterRepos.Create(ctx, tc.Obj.Name, repo.Obj, nil, v1.CreateOptions{}); err != nil { 393 | return err 394 | } 395 | fmt.Fprintf(w, "Done.\n") 396 | } 397 | } 398 | 399 | return nil 400 | } 401 | 402 | func NewProjectName(ctx context.Context, targetClusterName, oldProjectName string, client *client.Clients) (string, error) { 403 | var projects v3.ProjectList 404 | if err := client.Projects.List(ctx, targetClusterName, &projects, v1.ListOptions{}); err != nil { 405 | return "", err 406 | } 407 | for _, project := range projects.Items { 408 | if oldProjectName == project.Spec.DisplayName { 409 | return project.Name, nil 410 | } 411 | } 412 | return "", errors.New("failed to find project with the name " + oldProjectName) 413 | } 414 | -------------------------------------------------------------------------------- /pkg/cluster/clusterrepo.go: -------------------------------------------------------------------------------- 1 | package cluster 2 | 3 | import ( 4 | "strings" 5 | 6 | v1 "github.com/rancher/rancher/pkg/apis/catalog.cattle.io/v1" 7 | ) 8 | 9 | type ClusterRepo struct { 10 | Name string 11 | Obj *v1.ClusterRepo 12 | Migrated bool 13 | Diff bool 14 | } 15 | 16 | func newClusterRepo(obj v1.ClusterRepo) (*ClusterRepo, bool) { 17 | if strings.HasPrefix(obj.Name, "rancher-") { 18 | return nil, true 19 | } 20 | return &ClusterRepo{ 21 | Name: obj.Name, 22 | Obj: obj.DeepCopy(), 23 | Migrated: false, 24 | Diff: false, 25 | }, false 26 | } 27 | 28 | func (c *ClusterRepo) normalize() { 29 | } 30 | 31 | func (c *ClusterRepo) Mutate() { 32 | c.Obj.SetFinalizers(nil) 33 | c.Obj.SetResourceVersion("") 34 | c.Obj.Status = v1.RepoStatus{} 35 | } 36 | -------------------------------------------------------------------------------- /pkg/cluster/clusterroletemplatebinding.go: -------------------------------------------------------------------------------- 1 | package cluster 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "rancherlabs/cattle-drive/pkg/client" 7 | "strings" 8 | 9 | v3 "github.com/rancher/rancher/pkg/apis/management.cattle.io/v3" 10 | v1 "k8s.io/apimachinery/pkg/apis/meta/v1" 11 | ) 12 | 13 | const ( 14 | creatorClusterOwner = "creator-cluster-owner" 15 | fleetDefaultOwner = "fleet-default-owner" 16 | ) 17 | 18 | type ClusterRoleTemplateBinding struct { 19 | Name string 20 | Obj *v3.ClusterRoleTemplateBinding 21 | Migrated bool 22 | Diff bool 23 | // Description only exists for PRTB and CRTB 24 | Description string 25 | } 26 | 27 | func newCRTB(obj v3.ClusterRoleTemplateBinding, systemUser *v3.User) (*ClusterRoleTemplateBinding, bool) { 28 | if obj.Name == creatorClusterOwner || strings.Contains(obj.Name, fleetDefaultOwner) || strings.Contains(obj.Name, systemUser.Name) { 29 | // skipping crtb if its one of the default crtbs created for each cluster 30 | return nil, true 31 | } 32 | return &ClusterRoleTemplateBinding{ 33 | Name: obj.Name, 34 | Obj: obj.DeepCopy(), 35 | Migrated: false, 36 | Diff: false, 37 | }, false 38 | } 39 | 40 | // normalize will remove unneeded fields in the spec to make it easier to compare 41 | func (c *ClusterRoleTemplateBinding) normalize() { 42 | // removing objectMeta and clusterName since crtb has no spec 43 | c.Obj.ObjectMeta = v1.ObjectMeta{} 44 | c.Obj.ClusterName = "" 45 | } 46 | 47 | func (c *ClusterRoleTemplateBinding) Mutate(tc *Cluster) { 48 | c.Obj.ClusterName = tc.Obj.Name 49 | c.Obj.SetName(c.Name) 50 | c.Obj.SetNamespace(tc.Obj.Name) 51 | c.Obj.SetFinalizers(nil) 52 | c.Obj.SetResourceVersion("") 53 | c.Obj.SetLabels(nil) 54 | for annotation := range c.Obj.Annotations { 55 | if strings.Contains(annotation, lifeCycleAnnotationPrefix) { 56 | delete(c.Obj.Annotations, annotation) 57 | } 58 | } 59 | } 60 | func (c *ClusterRoleTemplateBinding) SetDescription(ctx context.Context, client *client.Clients) error { 61 | // check if this is a group 62 | if c.Obj.UserName == "" && c.Obj.UserPrincipalName == "" { 63 | groupName := c.Obj.GroupName 64 | if groupName == "" { 65 | // handling external auth providers 66 | groupName = c.Obj.GroupPrincipalName 67 | } 68 | c.Description = fmt.Sprintf("%s permission for group %s", c.Obj.RoleTemplateName, groupName) 69 | return nil 70 | } 71 | // setting description for external users 72 | if c.Obj.UserPrincipalName != "" { 73 | c.Description = fmt.Sprintf("%s permission for user %s", c.Obj.RoleTemplateName, c.Obj.UserPrincipalName) 74 | return nil 75 | } 76 | var user v3.User 77 | userID := c.Obj.UserName 78 | if err := client.Users.Get(ctx, "", userID, &user, v1.GetOptions{}); err != nil { 79 | return err 80 | } 81 | name := user.DisplayName 82 | if name == "" { 83 | name = user.Username 84 | } 85 | c.Description = fmt.Sprintf("%s permission for user %s", c.Obj.RoleTemplateName, name) 86 | return nil 87 | } 88 | -------------------------------------------------------------------------------- /pkg/cluster/globalrolebinding.go: -------------------------------------------------------------------------------- 1 | package cluster 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | v3 "github.com/rancher/rancher/pkg/apis/management.cattle.io/v3" 8 | ) 9 | 10 | type GlobalRoleBinding struct { 11 | Name string 12 | Obj *v3.GlobalRoleBinding 13 | Description string 14 | Migrated bool 15 | Diff bool 16 | } 17 | 18 | func newGRB(obj v3.GlobalRoleBinding) *GlobalRoleBinding { 19 | return &GlobalRoleBinding{ 20 | Name: obj.Name, 21 | Obj: obj.DeepCopy(), 22 | Migrated: false, 23 | } 24 | } 25 | 26 | // normalize will remove unneeded fields in the spec to make it easier to compare 27 | func (g *GlobalRoleBinding) normalize() { 28 | for _, or := range g.Obj.OwnerReferences { 29 | or.UID = "" 30 | } 31 | } 32 | 33 | func (g *GlobalRoleBinding) Mutate() { 34 | g.Obj.SetName(g.Name) 35 | g.Obj.SetFinalizers(nil) 36 | g.Obj.SetResourceVersion("") 37 | g.Obj.SetLabels(nil) 38 | for annotation := range g.Obj.Annotations { 39 | if strings.Contains(annotation, lifeCycleAnnotationPrefix) { 40 | delete(g.Obj.Annotations, annotation) 41 | } 42 | if strings.Contains(annotation, "authz.management.cattle.io/crb-name") { 43 | delete(g.Obj.Annotations, annotation) 44 | } 45 | } 46 | } 47 | 48 | func (g *GlobalRoleBinding) SetDescription(user v3.User) error { 49 | 50 | g.Description = fmt.Sprintf("%s permission for user %s", g.Obj.GlobalRoleName, user.Username) 51 | return nil 52 | } 53 | -------------------------------------------------------------------------------- /pkg/cluster/namespace.go: -------------------------------------------------------------------------------- 1 | package cluster 2 | 3 | import ( 4 | "strings" 5 | 6 | v1 "k8s.io/api/core/v1" 7 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 8 | ) 9 | 10 | const ( 11 | projectIDLabelAnnotation = "field.cattle.io/projectId" 12 | statusAnnotation = "cattle.io/status" 13 | ) 14 | 15 | type Namespace struct { 16 | Name string 17 | Obj *v1.Namespace 18 | ProjectName string 19 | ProjectDisplayName string 20 | Migrated bool 21 | Diff bool 22 | } 23 | 24 | func newNamespace(obj v1.Namespace, projectName, projectDisplayName string) *Namespace { 25 | return &Namespace{ 26 | Name: obj.Name, 27 | Obj: obj.DeepCopy(), 28 | Migrated: false, 29 | Diff: false, 30 | ProjectName: projectName, 31 | ProjectDisplayName: projectDisplayName, 32 | } 33 | } 34 | 35 | func (n Namespace) normalize() { 36 | delete(n.Obj.Annotations, statusAnnotation) 37 | n.Obj.Annotations[projectIDLabelAnnotation] = "" 38 | n.Obj.Labels[projectIDLabelAnnotation] = "" 39 | n.Obj.SetManagedFields(nil) 40 | n.Obj.SetCreationTimestamp(metav1.Time{}) 41 | n.Obj.SetResourceVersion("") 42 | n.Obj.SetUID("") 43 | } 44 | 45 | func (n Namespace) Mutate(clusterName, projectName string) { 46 | n.Obj.Annotations[projectIDLabelAnnotation] = clusterName + ":" + projectName 47 | n.Obj.Labels[projectIDLabelAnnotation] = projectName 48 | n.Obj.SetFinalizers(nil) 49 | for annotation := range n.Obj.Annotations { 50 | if strings.Contains(annotation, lifeCycleAnnotationPrefix) { 51 | delete(n.Obj.Annotations, annotation) 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /pkg/cluster/project.go: -------------------------------------------------------------------------------- 1 | package cluster 2 | 3 | import ( 4 | "strings" 5 | 6 | v3 "github.com/rancher/rancher/pkg/apis/management.cattle.io/v3" 7 | ) 8 | 9 | const ( 10 | lifeCycleAnnotationPrefix = "lifecycle.cattle.io" 11 | ) 12 | 13 | type Project struct { 14 | Name string 15 | TargetName string 16 | Obj *v3.Project 17 | Migrated bool 18 | Diff bool 19 | PRTBs []*ProjectRoleTemplateBinding 20 | Namespaces []*Namespace 21 | } 22 | 23 | func newProject(obj v3.Project, prtbs []*ProjectRoleTemplateBinding, namespaces []*Namespace) *Project { 24 | return &Project{ 25 | Name: obj.Spec.DisplayName, 26 | Obj: obj.DeepCopy(), 27 | Migrated: false, 28 | Diff: false, 29 | PRTBs: prtbs, 30 | Namespaces: namespaces, 31 | } 32 | } 33 | 34 | // normalize will remove unneeded fields in the spec to make it easier to compare 35 | func (p *Project) normalize() { 36 | p.Obj.Spec.ClusterName = "" 37 | p.Obj.Spec.ResourceQuota.UsedLimit = v3.ResourceQuotaLimit{} 38 | } 39 | 40 | // mutate will change the project object to be suitable for recreation to the target cluster 41 | func (p *Project) Mutate(c *Cluster) { 42 | newProjectName := "p-" + generateName(5) 43 | p.Obj.Spec.ClusterName = c.Obj.Name 44 | p.Obj.Namespace = c.Obj.Name 45 | p.Obj.Status = v3.ProjectStatus{} 46 | p.Obj.SetFinalizers(nil) 47 | p.Obj.SetResourceVersion("") 48 | p.Obj.SetName(newProjectName) 49 | for annotation := range p.Obj.Annotations { 50 | if strings.Contains(annotation, lifeCycleAnnotationPrefix) { 51 | delete(p.Obj.Annotations, annotation) 52 | } 53 | } 54 | if c.ExternalRancher { 55 | p.Obj.Annotations["field.cattle.io/creatorId"] = c.DefaultAdmin.Name 56 | p.Obj.Annotations["authz.management.cattle.io/creator-role-bindings"] = `{"required":["project-owner"]}` 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /pkg/cluster/projectroletemplatebinding.go: -------------------------------------------------------------------------------- 1 | package cluster 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "rancherlabs/cattle-drive/pkg/client" 7 | "strings" 8 | 9 | v3 "github.com/rancher/rancher/pkg/apis/management.cattle.io/v3" 10 | v1 "k8s.io/apimachinery/pkg/apis/meta/v1" 11 | ) 12 | 13 | type ProjectRoleTemplateBinding struct { 14 | Name string 15 | Obj *v3.ProjectRoleTemplateBinding 16 | ProjectName string 17 | ProjectDisplayName string 18 | Migrated bool 19 | Diff bool 20 | Description string 21 | } 22 | 23 | func newPRTB(obj v3.ProjectRoleTemplateBinding, projectName, projectDisplayName string) *ProjectRoleTemplateBinding { 24 | return &ProjectRoleTemplateBinding{ 25 | Name: obj.Name, 26 | Obj: obj.DeepCopy(), 27 | Migrated: false, 28 | Diff: false, 29 | ProjectName: projectName, 30 | ProjectDisplayName: projectDisplayName, 31 | } 32 | } 33 | 34 | // normalize will remove unneeded fields in the spec to make it easier to compare 35 | func (p *ProjectRoleTemplateBinding) normalize() { 36 | // removing objectMeta and projectName since prtb has no spec 37 | p.Obj.ObjectMeta = v1.ObjectMeta{} 38 | p.Obj.ProjectName = "" 39 | if p.Obj.GroupPrincipalName != "" || p.Obj.GroupName != "" { 40 | p.Obj.UserName = "" 41 | p.Obj.UserPrincipalName = "" 42 | } 43 | } 44 | 45 | func (p *ProjectRoleTemplateBinding) Mutate(clusterName, projectName string) { 46 | p.Obj.ProjectName = clusterName + ":" + projectName 47 | p.Obj.SetName(p.Name) 48 | p.Obj.SetNamespace(projectName) 49 | p.Obj.SetFinalizers(nil) 50 | p.Obj.SetResourceVersion("") 51 | p.Obj.SetLabels(nil) 52 | for annotation := range p.Obj.Annotations { 53 | if strings.Contains(annotation, lifeCycleAnnotationPrefix) { 54 | delete(p.Obj.Annotations, annotation) 55 | } 56 | } 57 | } 58 | 59 | func (p *ProjectRoleTemplateBinding) Compare(target *ProjectRoleTemplateBinding) bool { 60 | // if group principal name exists in the target cluster we should just return true 61 | // this is due to the original creator of the source cluster might be different than 62 | // the target cluster creator 63 | if target.Obj.GroupPrincipalName != "" || target.Obj.GroupName != "" { 64 | return true 65 | } 66 | if p.Obj.UserName == target.Obj.UserName && 67 | p.Obj.UserPrincipalName == target.Obj.UserPrincipalName && 68 | p.Obj.ServiceAccount == target.Obj.ServiceAccount && 69 | p.Obj.RoleTemplateName == target.Obj.RoleTemplateName { 70 | return true 71 | } 72 | return false 73 | } 74 | 75 | func (p *ProjectRoleTemplateBinding) SetDescription(ctx context.Context, client *client.Clients) error { 76 | if p.Obj.UserName == "" && p.Obj.UserPrincipalName == "" { 77 | groupName := p.Obj.GroupName 78 | if groupName == "" { 79 | // handling external auth providers 80 | groupName = p.Obj.GroupPrincipalName 81 | } 82 | p.Description = fmt.Sprintf("%s permission for group %s", p.Obj.RoleTemplateName, groupName) 83 | return nil 84 | } 85 | // setting description for external users 86 | if p.Obj.UserPrincipalName != "" { 87 | p.Description = fmt.Sprintf("%s permission for user %s", p.Obj.RoleTemplateName, p.Obj.UserPrincipalName) 88 | return nil 89 | } 90 | var user v3.User 91 | userID := p.Obj.UserName 92 | if err := client.Users.Get(ctx, "", userID, &user, v1.GetOptions{}); err != nil { 93 | return err 94 | } 95 | name := user.DisplayName 96 | if name == "" { 97 | name = user.Username 98 | } 99 | p.Description = fmt.Sprintf("%s permission for user %s", p.Obj.RoleTemplateName, name) 100 | return nil 101 | } 102 | -------------------------------------------------------------------------------- /pkg/cluster/tui/cluster.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "rancherlabs/cattle-drive/pkg/cluster/tui/constants" 7 | "strings" 8 | "time" 9 | 10 | "github.com/charmbracelet/bubbles/key" 11 | "github.com/charmbracelet/bubbles/list" 12 | "github.com/charmbracelet/bubbles/progress" 13 | tea "github.com/charmbracelet/bubbletea" 14 | ) 15 | 16 | type mode int 17 | 18 | const ( 19 | nav mode = iota 20 | migrate 21 | migrated 22 | ) 23 | 24 | type Model struct { 25 | mode mode 26 | migratingAll bool 27 | list list.Model 28 | progress progress.Model 29 | quitting bool 30 | } 31 | 32 | type item struct { 33 | title string 34 | desc string 35 | objType string 36 | obj interface{} 37 | status constants.MigrationStatus 38 | } 39 | 40 | var ( 41 | delegateKeys = newDelegateKeyMap() 42 | ) 43 | 44 | func (i item) Title() string { return i.title } 45 | func (i item) Description() string { return i.desc } 46 | func (i item) FilterValue() string { return i.title } 47 | 48 | // Init run any intial IO on program start 49 | func (m Model) Init() tea.Cmd { 50 | return nil 51 | } 52 | 53 | func InitCluster(msg tea.Msg) (tea.Model, tea.Cmd) { 54 | prog := progress.New(progress.WithSolidFill("#04B575")) 55 | items := newClusterList() 56 | delegate := newItemDelegate(delegateKeys) 57 | clusterList := list.New(items, delegate, 8, 8) 58 | clusterList.Styles.Title = constants.TitleStyle 59 | 60 | m := Model{mode: nav, list: clusterList, progress: prog} 61 | if constants.WindowSize.Height != 0 { 62 | top, right, bottom, left := constants.DocStyle.GetMargin() 63 | m.list.SetSize(constants.WindowSize.Width-left-right, constants.WindowSize.Height-top-bottom-1) 64 | } 65 | m.list.Title = "Cluster " + constants.SC.Obj.Spec.DisplayName + " migration" 66 | if msg != nil { 67 | return m, func() tea.Msg { return msg } 68 | } 69 | return m, func() tea.Msg { return errMsg{nil} } 70 | } 71 | 72 | func newClusterList() []list.Item { 73 | items := []list.Item{ 74 | item{title: "Projects", desc: "Projects, Namespaces, and PRTB", objType: constants.ProjectsType, obj: nil}, 75 | item{title: "Cluster User Permissions", desc: "user permissions for the cluster (CRTB)", objType: constants.CRTBsType, obj: nil}, 76 | item{title: "Catalog Repos", desc: "Cluster apps repos", objType: constants.ReposType, obj: nil}, 77 | } 78 | if constants.TC.ExternalRancher || constants.SC.ExternalRancher { 79 | items = append(items, item{title: "Users", desc: "Rancher Users", objType: constants.UsersType, obj: nil}) 80 | } 81 | return items 82 | } 83 | 84 | // Update handle IO and commands 85 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 86 | var cmd tea.Cmd 87 | var cmds []tea.Cmd 88 | switch msg := msg.(type) { 89 | case tickMsg: 90 | m.migratingAll = true 91 | for { 92 | select { 93 | case <-time.After(time.Millisecond * 500): 94 | if m.progress.Percent() == 1.0 { 95 | cmd := m.progress.SetPercent(0) 96 | return m, tea.Batch(tickCmd(), cmd) 97 | } 98 | cmd := m.progress.IncrPercent(0.25) 99 | return m, tea.Batch(tickCmd(), cmd) 100 | case <-constants.Migratedch: 101 | return InitCluster(nil) 102 | } 103 | } 104 | case progress.FrameMsg: 105 | progressModel, cmd := m.progress.Update(msg) 106 | m.progress = progressModel.(progress.Model) 107 | return m, cmd 108 | case tea.WindowSizeMsg: 109 | constants.WindowSize = msg 110 | top, right, bottom, left := constants.DocStyle.GetMargin() 111 | m.list.SetSize(msg.Width-left-right, msg.Height-top-bottom-1) 112 | case tea.KeyMsg: 113 | switch { 114 | case key.Matches(msg, delegateKeys.Quit): 115 | m.quitting = true 116 | return m, tea.Quit 117 | case key.Matches(msg, delegateKeys.Enter): 118 | entry := InitObjects(m.list.SelectedItem().(item)) 119 | return entry.Update(constants.WindowSize) 120 | case key.Matches(msg, delegateKeys.MigrateAll): 121 | m.mode = migrate 122 | go m.migrateCluster(context.Background()) 123 | return InitCluster(tickMsg{}) 124 | default: 125 | m.list, cmd = m.list.Update(msg) 126 | } 127 | cmds = append(cmds, cmd) 128 | } 129 | 130 | return m, tea.Batch(cmds...) 131 | } 132 | 133 | // View return the text UI to be output to the terminal 134 | func (m Model) View() string { 135 | if m.quitting { 136 | return "" 137 | } 138 | if m.migratingAll { 139 | pad := strings.Repeat(" ", 2) 140 | return "\n\n Migrating all objects.. please wait" + pad + m.progress.View() + "\n\n" + pad 141 | } 142 | return constants.DocStyle.Render(m.list.View() + "\n") 143 | } 144 | 145 | func (m *Model) migrateCluster(ctx context.Context) { 146 | fmt.Fprintf(&constants.LogFile, "[%s] initiating cluster objects migrate:\n", time.Now().String()) 147 | cl := constants.TClient 148 | if cl == nil { 149 | cl = constants.Lclient 150 | } 151 | if err := constants.SC.Migrate(ctx, cl, constants.TC, &constants.LogFile); err != nil { 152 | fmt.Fprintf(&constants.LogFile, "[%s] [error] %v\n", time.Now().String(), err) 153 | m.Update(tea.Quit()) 154 | } 155 | if err := updateClusters(ctx); err != nil { 156 | fmt.Fprintf(&constants.LogFile, "[%s] [error] %v\n", time.Now().String(), err) 157 | m.Update(tea.Quit()) 158 | } 159 | constants.Migratedch <- true 160 | } 161 | -------------------------------------------------------------------------------- /pkg/cluster/tui/constants/constants.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | import ( 4 | "os" 5 | "rancherlabs/cattle-drive/pkg/client" 6 | "rancherlabs/cattle-drive/pkg/cluster" 7 | 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/charmbracelet/lipgloss" 10 | ) 11 | 12 | /* CONSTANTS */ 13 | const ( 14 | /* Migration Types */ 15 | MigratedStatus MigrationStatus = iota 16 | WrongSpecStatus 17 | NotMigratedStatus 18 | CheckMark = "\u2714" 19 | WrongMark = "\u2718" 20 | WrongSpec = "(Wrong fields)" 21 | 22 | /* Object Types */ 23 | ProjectsType = "projects" 24 | CRTBsType = "crtbs" 25 | NamespacesType = "namespaces" 26 | PRTBsType = "prtbs" 27 | ReposType = "repos" 28 | ProjectType = "project" 29 | CRTBType = "crtb" 30 | NamespaceType = "namespace" 31 | PRTBType = "prtb" 32 | RepoType = "repo" 33 | UsersType = "users" 34 | UserType = "user" 35 | ) 36 | 37 | type MigrationStatus int 38 | 39 | var ( 40 | // P the current tea program 41 | P *tea.Program 42 | // SC the source cluster 43 | SC *cluster.Cluster 44 | // TC the target cluster 45 | TC *cluster.Cluster 46 | // Local Rancher Client 47 | Lclient *client.Clients 48 | // Target Rancher Client 49 | TClient *client.Clients 50 | // WindowSize store the size of the terminal window 51 | WindowSize tea.WindowSizeMsg 52 | // Migratedch all object chan 53 | Migratedch = make(chan bool) 54 | // logFile 55 | LogFile os.File 56 | ) 57 | 58 | /* STYLING */ 59 | var ( 60 | DocStyle = lipgloss.NewStyle().Margin(0, 2).Foreground(lipgloss.Color("241")) 61 | 62 | AppStyle = lipgloss.NewStyle().Padding(1, 2) 63 | 64 | TitleStyle = lipgloss.NewStyle(). 65 | Foreground(lipgloss.Color("#FFFDF5")). 66 | Background(lipgloss.Color("#25A065")). 67 | Padding(0, 1) 68 | StatusMessageStyle = lipgloss.NewStyle(). 69 | Foreground(lipgloss.AdaptiveColor{Light: "#04B575", Dark: "#04B575"}). 70 | Render 71 | ) 72 | -------------------------------------------------------------------------------- /pkg/cluster/tui/delegate.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "rancherlabs/cattle-drive/pkg/cluster/tui/constants" 5 | 6 | "github.com/charmbracelet/bubbles/key" 7 | "github.com/charmbracelet/bubbles/list" 8 | tea "github.com/charmbracelet/bubbletea" 9 | ) 10 | 11 | func newItemDelegate(keys *delegateKeyMap) list.DefaultDelegate { 12 | d := list.NewDefaultDelegate() 13 | 14 | d.UpdateFunc = func(msg tea.Msg, m *list.Model) tea.Cmd { 15 | var title string 16 | 17 | if i, ok := m.SelectedItem().(item); ok { 18 | title = i.Title() 19 | } else { 20 | return nil 21 | } 22 | 23 | switch msg := msg.(type) { 24 | case tea.KeyMsg: 25 | switch { 26 | case key.Matches(msg, delegateKeys.Enter): 27 | return m.NewStatusMessage(constants.StatusMessageStyle("You chose " + title)) 28 | } 29 | } 30 | 31 | return nil 32 | } 33 | 34 | help := []key.Binding{keys.Enter, keys.Migrate, keys.MigrateAll, keys.Back} 35 | 36 | d.ShortHelpFunc = func() []key.Binding { 37 | return help 38 | } 39 | 40 | d.FullHelpFunc = func() [][]key.Binding { 41 | return [][]key.Binding{help} 42 | } 43 | 44 | return d 45 | } 46 | 47 | type delegateKeyMap struct { 48 | MigrateAll key.Binding 49 | Migrate key.Binding 50 | Back key.Binding 51 | Enter key.Binding 52 | Quit key.Binding 53 | } 54 | 55 | // Additional short help entries. This satisfies the help.KeyMap interface and 56 | // is entirely optional. 57 | func (d delegateKeyMap) ShortHelp() []key.Binding { 58 | return []key.Binding{ 59 | d.MigrateAll, 60 | d.Migrate, 61 | d.Back, 62 | d.Enter, 63 | d.Quit, 64 | } 65 | } 66 | 67 | // Additional full help entries. This satisfies the help.KeyMap interface and 68 | // is entirely optional. 69 | func (d delegateKeyMap) FullHelp() [][]key.Binding { 70 | return [][]key.Binding{ 71 | { 72 | d.MigrateAll, 73 | d.Migrate, 74 | d.Back, 75 | d.Enter, 76 | d.Quit, 77 | }, 78 | } 79 | } 80 | 81 | func newDelegateKeyMap() *delegateKeyMap { 82 | return &delegateKeyMap{ 83 | Enter: key.NewBinding( 84 | key.WithKeys("enter"), 85 | key.WithHelp("enter", "select"), 86 | ), 87 | Migrate: key.NewBinding( 88 | key.WithKeys("m"), 89 | key.WithHelp("m", "migrate"), 90 | ), 91 | MigrateAll: key.NewBinding( 92 | key.WithKeys("a"), 93 | key.WithHelp("a", "migrate all"), 94 | ), 95 | Back: key.NewBinding( 96 | key.WithKeys("esc"), 97 | key.WithHelp("esc", "main menu"), 98 | ), 99 | Quit: key.NewBinding( 100 | key.WithKeys("ctrl+c", "q"), 101 | key.WithHelp("ctrl+c/q", "quit"), 102 | ), 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /pkg/cluster/tui/objects.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "rancherlabs/cattle-drive/pkg/cluster" 7 | "rancherlabs/cattle-drive/pkg/cluster/tui/constants" 8 | "strings" 9 | "time" 10 | 11 | "github.com/charmbracelet/bubbles/key" 12 | "github.com/charmbracelet/bubbles/list" 13 | "github.com/charmbracelet/bubbles/progress" 14 | tea "github.com/charmbracelet/bubbletea" 15 | v1 "k8s.io/apimachinery/pkg/apis/meta/v1" 16 | ) 17 | 18 | type ( 19 | errMsg struct{ error } 20 | tickMsg time.Time 21 | ) 22 | 23 | type Objects struct { 24 | mode mode 25 | list list.Model 26 | quitting bool 27 | progress progress.Model 28 | activeObject string 29 | } 30 | 31 | // Init run any intial IO on program start 32 | func (m Objects) Init() tea.Cmd { 33 | return tickCmd() 34 | } 35 | 36 | func InitObjects(i item) *Objects { 37 | prog := progress.New(progress.WithSolidFill("#04B575")) 38 | var title string 39 | items := []list.Item{} 40 | m := Objects{mode: nav, progress: prog} 41 | switch i.objType { 42 | case "users": 43 | title = "Rancher Users" 44 | for _, user := range constants.SC.ToMigrate.Users { 45 | grbNames := []string{} 46 | for _, grb := range user.GlobalRoleBindings { 47 | grbNames = append(grbNames, grb.Obj.GlobalRoleName) 48 | } 49 | 50 | title, status := status(user.Obj.Username, user.Migrated, user.Diff) 51 | i := item{title: title, desc: "permissions: (" + strings.Join(grbNames, ",") + ")", status: status, objType: constants.UserType, obj: user} 52 | items = append(items, i) 53 | } 54 | case "project": 55 | // in case of individual project then we will list namespaces and prtbs 56 | project := i.obj.(*cluster.Project) 57 | title = "Project [" + project.Name + "]" 58 | items = []list.Item{ 59 | item{title: "Project User Permissions", objType: constants.PRTBsType, obj: project.PRTBs}, 60 | item{title: "Namespaces", objType: constants.NamespacesType, obj: project.Namespaces}, 61 | } 62 | case "namespaces": 63 | namespaces := i.obj.([]*cluster.Namespace) 64 | title = "Namespaces for Project " 65 | if len(namespaces) > 0 { 66 | title = title + "[" + namespaces[0].ProjectDisplayName + "]" 67 | } 68 | for _, ns := range namespaces { 69 | t, status := status(ns.Name, ns.Migrated, ns.Diff) 70 | i := item{title: t, status: status, objType: constants.NamespaceType, obj: ns} 71 | items = append(items, i) 72 | } 73 | case "prtbs": 74 | prtbs := i.obj.([]*cluster.ProjectRoleTemplateBinding) 75 | title = "User Permissions for Project " 76 | if len(prtbs) > 0 { 77 | title = title + "[" + prtbs[0].ProjectDisplayName + "]" 78 | } 79 | for _, prtb := range prtbs { 80 | t, status := status(prtb.Name, prtb.Migrated, prtb.Diff) 81 | i := item{title: t, desc: prtb.Description, status: status, objType: constants.PRTBType, obj: prtb} 82 | items = append(items, i) 83 | } 84 | case "projects": 85 | title = "Projects" 86 | for _, project := range constants.SC.ToMigrate.Projects { 87 | title, status := status(project.Name, project.Migrated, project.Diff) 88 | i := item{title: title, status: status, desc: project.Obj.Spec.Description, objType: constants.ProjectType, obj: project} 89 | items = append(items, i) 90 | } 91 | case "crtbs": 92 | title = "Cluster Users Permissions" 93 | for _, crtb := range constants.SC.ToMigrate.CRTBs { 94 | title, status := status(crtb.Name, crtb.Migrated, crtb.Diff) 95 | i := item{title: title, desc: crtb.Description, status: status, objType: constants.CRTBType, obj: crtb} 96 | items = append(items, i) 97 | } 98 | case "repos": 99 | title = "Cluster Catalog Repos" 100 | for _, repo := range constants.SC.ToMigrate.ClusterRepos { 101 | title, status := status(repo.Name, repo.Migrated, repo.Diff) 102 | i := item{title: title, status: status, objType: constants.RepoType, obj: repo} 103 | items = append(items, i) 104 | } 105 | } 106 | delegateObjKeys := *delegateKeys 107 | delegateObjKeys.MigrateAll = key.NewBinding() 108 | delegate := newItemDelegate(&delegateObjKeys) 109 | objList := list.New(items, delegate, 8, 8) 110 | objList.Styles.Title = constants.TitleStyle 111 | m.list = objList 112 | if constants.WindowSize.Height != 0 { 113 | top, right, bottom, left := constants.DocStyle.GetMargin() 114 | m.list.SetSize(constants.WindowSize.Width-left-right, constants.WindowSize.Height-top-bottom-1) 115 | } 116 | m.list.Title = title 117 | return &m 118 | } 119 | 120 | // Update handle IO and commands 121 | func (m Objects) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 122 | var cmd tea.Cmd 123 | var cmds []tea.Cmd 124 | switch msg := msg.(type) { 125 | case tea.WindowSizeMsg: 126 | constants.WindowSize = msg 127 | top, right, bottom, left := constants.DocStyle.GetMargin() 128 | m.list.SetSize(msg.Width-left-right, msg.Height-top-bottom-1) 129 | case tickMsg: 130 | m.mode = migrate 131 | cmd := m.progress.IncrPercent(0.25) 132 | if m.progress.Percent() == 1.0 || m.mode == migrated { 133 | return InitCluster(nil) 134 | } 135 | return m, tea.Batch(tickCmd(), cmd) 136 | case progress.FrameMsg: 137 | progressModel, cmd := m.progress.Update(msg) 138 | m.progress = progressModel.(progress.Model) 139 | return m, cmd 140 | case tea.KeyMsg: 141 | switch { 142 | case key.Matches(msg, delegateKeys.Quit): 143 | m.quitting = true 144 | return m, tea.Quit 145 | case key.Matches(msg, delegateKeys.Back): 146 | return InitCluster(nil) 147 | case key.Matches(msg, delegateKeys.Enter): 148 | if m.list.SelectedItem() == nil { 149 | return m, tea.Batch(cmds...) 150 | } 151 | item := m.list.SelectedItem().(item) 152 | if item.objType == constants.ProjectType && item.status == constants.MigratedStatus { 153 | entry := InitObjects(item) 154 | return entry.Update(constants.WindowSize) 155 | } 156 | if item.objType == constants.PRTBsType || item.objType == constants.NamespacesType { 157 | entry := InitObjects(item) 158 | return entry.Update(constants.WindowSize) 159 | } 160 | case key.Matches(msg, delegateKeys.Migrate): 161 | item := m.list.SelectedItem().(item) 162 | if item.status == constants.NotMigratedStatus { 163 | m.mode = migrate 164 | m.activeObject = item.objType + "/" + item.title 165 | go m.migrateObject(context.Background(), item) 166 | return m, tickCmd() 167 | } 168 | default: 169 | m.list, cmd = m.list.Update(msg) 170 | } 171 | cmds = append(cmds, cmd) 172 | } 173 | return m, tea.Batch(cmds...) 174 | } 175 | 176 | // View return the text UI to be output to the terminal 177 | func (m Objects) View() string { 178 | if m.quitting { 179 | return "" 180 | } 181 | if m.mode == migrate { 182 | pad := strings.Repeat(" ", 2) 183 | return "\n\n Waiting for object [" + m.activeObject + "] to be migrated\n\n" + pad + m.progress.View() + "\n\n" + pad 184 | } 185 | return constants.DocStyle.Render(m.list.View() + "\n") 186 | } 187 | 188 | func status(name string, migrated, diff bool) (string, constants.MigrationStatus) { 189 | if migrated { 190 | if !diff { 191 | return name + " " + constants.CheckMark, constants.MigratedStatus 192 | } else { 193 | return name + " " + constants.WrongMark + " " + constants.WrongSpec, constants.WrongSpecStatus 194 | } 195 | } 196 | return name + " " + constants.WrongMark, constants.NotMigratedStatus 197 | } 198 | 199 | func (m *Objects) migrateObject(ctx context.Context, i item) (tea.Msg, error) { 200 | var ( 201 | objectMigrated bool 202 | msg string 203 | ) 204 | cl := constants.TClient 205 | if cl == nil { 206 | cl = constants.Lclient 207 | } 208 | 209 | switch i.objType { 210 | case constants.ProjectType: 211 | if i.status == constants.NotMigratedStatus { 212 | p := i.obj.(*cluster.Project) 213 | p.Mutate(constants.TC) 214 | if err := cl.Projects.Create(ctx, constants.TC.Obj.Name, p.Obj, nil, v1.CreateOptions{}); err != nil { 215 | return nil, err 216 | } 217 | objectMigrated = true 218 | if err := updateClusters(ctx); err != nil { 219 | return nil, err 220 | } 221 | msg = p.Name 222 | } 223 | case constants.NamespaceType: 224 | if i.status == constants.NotMigratedStatus { 225 | ns := i.obj.(*cluster.Namespace) 226 | ns.Mutate(constants.TC.Obj.Name, ns.ProjectName) 227 | if _, err := constants.TC.Client.Namespace.Create(ns.Obj); err != nil { 228 | return nil, err 229 | } 230 | objectMigrated = true 231 | if err := updateClusters(ctx); err != nil { 232 | return nil, err 233 | } 234 | msg = ns.Name 235 | } 236 | 237 | case constants.PRTBType: 238 | if i.status == constants.NotMigratedStatus { 239 | prtb := i.obj.(*cluster.ProjectRoleTemplateBinding) 240 | prtb.Mutate(constants.TC.Obj.Name, prtb.ProjectName) 241 | if err := cl.ProjectRoleTemplateBindings.Create(ctx, prtb.ProjectName, prtb.Obj, nil, v1.CreateOptions{}); err != nil { 242 | return nil, err 243 | } 244 | objectMigrated = true 245 | if err := updateClusters(ctx); err != nil { 246 | return nil, err 247 | } 248 | msg = prtb.Name 249 | } 250 | case constants.CRTBType: 251 | if i.status == constants.NotMigratedStatus { 252 | crtb := i.obj.(*cluster.ClusterRoleTemplateBinding) 253 | crtb.Mutate(constants.TC) 254 | if err := cl.ClusterRoleTemplateBindings.Create(ctx, constants.TC.Obj.Name, crtb.Obj, nil, v1.CreateOptions{}); err != nil { 255 | return nil, err 256 | } 257 | objectMigrated = true 258 | if err := updateClusters(ctx); err != nil { 259 | return nil, err 260 | } 261 | msg = crtb.Name 262 | } 263 | 264 | case constants.RepoType: 265 | if i.status == constants.NotMigratedStatus { 266 | repo := i.obj.(*cluster.ClusterRepo) 267 | repo.Mutate() 268 | if err := constants.TC.Client.ClusterRepos.Create(ctx, constants.TC.Obj.Name, repo.Obj, nil, v1.CreateOptions{}); err != nil { 269 | return nil, err 270 | } 271 | objectMigrated = true 272 | if err := updateClusters(ctx); err != nil { 273 | return nil, err 274 | } 275 | msg = repo.Name 276 | } 277 | case constants.UserType: 278 | if i.status == constants.NotMigratedStatus { 279 | if constants.TClient != nil { 280 | user := i.obj.(*cluster.User) 281 | user.Mutate() 282 | if err := constants.TClient.Users.Create(ctx, "", user.Obj, nil, v1.CreateOptions{}); err != nil { 283 | return nil, err 284 | } 285 | for _, grb := range user.GlobalRoleBindings { 286 | grb.Mutate() 287 | if err := constants.TClient.GlobalRoleBindings.Create(ctx, "", grb.Obj, nil, v1.CreateOptions{}); err != nil { 288 | return nil, err 289 | } 290 | } 291 | objectMigrated = true 292 | if err := updateClusters(ctx); err != nil { 293 | return nil, err 294 | } 295 | msg = user.Name 296 | } 297 | } 298 | 299 | } 300 | m.mode = migrated 301 | if objectMigrated { 302 | fmt.Fprintf(&constants.LogFile, "[%s] migrated object [%s/%s]\n", time.Now().String(), i.objType, i.title) 303 | } 304 | return msg, nil 305 | } 306 | 307 | func updateClusters(ctx context.Context) error { 308 | if err := constants.SC.Populate(ctx, constants.Lclient); err != nil { 309 | return err 310 | } 311 | if constants.TClient != nil { 312 | if err := constants.TC.Populate(ctx, constants.TClient); err != nil { 313 | return err 314 | } 315 | } else { 316 | if err := constants.TC.Populate(ctx, constants.Lclient); err != nil { 317 | return err 318 | } 319 | } 320 | if err := constants.SC.Compare(ctx, constants.TC); err != nil { 321 | return err 322 | } 323 | fmt.Fprintf(&constants.LogFile, "[%s] successfully updated cluster [%s]\n", time.Now().String(), constants.SC.Obj.Spec.DisplayName) 324 | return nil 325 | } 326 | 327 | func tickCmd() tea.Cmd { 328 | return tea.Tick(time.Second, func(t time.Time) tea.Msg { 329 | return tickMsg(t) 330 | }) 331 | } 332 | -------------------------------------------------------------------------------- /pkg/cluster/tui/tui.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "rancherlabs/cattle-drive/pkg/client" 8 | "rancherlabs/cattle-drive/pkg/cluster" 9 | "rancherlabs/cattle-drive/pkg/cluster/tui/constants" 10 | 11 | tea "github.com/charmbracelet/bubbletea" 12 | ) 13 | 14 | func StartTea(sc, tc *cluster.Cluster, client, tClient *client.Clients, logFilePath string) error { 15 | if f, err := tea.LogToFile(logFilePath, "help"); err != nil { 16 | fmt.Println("Couldn't open a file for logging:", err) 17 | os.Exit(1) 18 | } else { 19 | constants.LogFile = *f 20 | defer func() { 21 | err = constants.LogFile.Close() 22 | if err != nil { 23 | log.Fatal(err) 24 | } 25 | }() 26 | } 27 | constants.SC = sc 28 | constants.TC = tc 29 | constants.Lclient = client 30 | constants.TClient = tClient 31 | 32 | m, _ := InitCluster(nil) 33 | constants.P = tea.NewProgram(m, tea.WithAltScreen()) 34 | if _, err := constants.P.Run(); err != nil { 35 | return err 36 | } 37 | return nil 38 | } 39 | -------------------------------------------------------------------------------- /pkg/cluster/user.go: -------------------------------------------------------------------------------- 1 | package cluster 2 | 3 | import ( 4 | "strings" 5 | 6 | v3 "github.com/rancher/rancher/pkg/apis/management.cattle.io/v3" 7 | ) 8 | 9 | type User struct { 10 | Name string 11 | Obj *v3.User 12 | Migrated bool 13 | Diff bool 14 | GlobalRoleBindings []*GlobalRoleBinding 15 | } 16 | 17 | func newUser(obj v3.User, grbList []*GlobalRoleBinding) *User { 18 | return &User{ 19 | Name: obj.Name, 20 | Obj: obj.DeepCopy(), 21 | GlobalRoleBindings: grbList, 22 | Migrated: false, 23 | } 24 | } 25 | 26 | // normalize will remove unneeded fields in the spec to make it easier to compare 27 | func (u *User) normalize() { 28 | } 29 | 30 | func (u *User) Mutate() { 31 | u.Obj.SetName(u.Name) 32 | u.Obj.SetFinalizers(nil) 33 | u.Obj.SetResourceVersion("") 34 | u.Obj.SetLabels(nil) 35 | for annotation := range u.Obj.Annotations { 36 | if strings.Contains(annotation, lifeCycleAnnotationPrefix) { 37 | delete(u.Obj.Annotations, annotation) 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /pkg/cluster/util.go: -------------------------------------------------------------------------------- 1 | package cluster 2 | 3 | import ( 4 | "crypto/rand" 5 | "fmt" 6 | "os" 7 | "strings" 8 | 9 | "github.com/briandowns/spinner" 10 | ) 11 | 12 | var Spinner *spinner.Spinner 13 | 14 | const ( 15 | checkMark = "\u2714" 16 | wrongMark = "\u2718" 17 | wrongSpec = "(Wrong fields)" 18 | characters = "abcdefghijklmnopqrstuvwxyz0123456789" 19 | ) 20 | 21 | func print(resource string, check, diff bool, indent int) { 22 | indentStr := strings.Repeat("\t", indent) 23 | if check { 24 | if diff { 25 | fmt.Fprintf(os.Stdout, "%s \033[1;31m- [%s] %s %s\033[0m\n", indentStr, resource, wrongMark, wrongSpec) 26 | } else { 27 | fmt.Fprintf(os.Stdout, "%s \033[1;32m- [%s] %s\033[0m\n", indentStr, resource, checkMark) 28 | } 29 | } else { 30 | fmt.Fprintf(os.Stdout, "%s \033[1;31m- [%s] %s\033[0m\n", indentStr, resource, wrongMark) 31 | } 32 | } 33 | 34 | func generateName(length int) string { 35 | bytes := make([]byte, length) 36 | _, err := rand.Read(bytes) 37 | if err != nil { 38 | fmt.Println("failed to generate random string") 39 | os.Exit(1) 40 | } 41 | 42 | out := make([]byte, length) 43 | for i := range out { 44 | index := uint8(bytes[i]) % uint8(len(characters)) 45 | out[i] = characters[index] 46 | } 47 | 48 | return string(out) 49 | } 50 | -------------------------------------------------------------------------------- /pkg/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import "strings" 4 | 5 | var ( 6 | Program = "cattle-drive" 7 | ProgramUpper = strings.ToUpper(Program) 8 | Version = "dev" 9 | GitCommit = "HEAD" 10 | ) 11 | --------------------------------------------------------------------------------