Skip to content

CLOUDP-296203: [AtlasCLI Plugins] Add Support for installing CLI Plugins from private repos #4009

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jun 30, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions build/ci/library_owners.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys": "apix-2",
"github.com/bradleyjkemp/cupaloy/v2": "apix-2",
"github.com/briandowns/spinner": "apix-2",
"github.com/cli/go-gh/v2": "apix-2",
"github.com/creack/pty": "apix-2",
"github.com/denisbrodbeck/machineid": "apix-2",
"github.com/evergreen-ci/shrub": "apix-2",
Expand Down
4 changes: 3 additions & 1 deletion build/package/purls.txt
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ pkg:golang/github.com/bodgit/[email protected]
pkg:golang/github.com/bodgit/[email protected]
pkg:golang/github.com/bodgit/[email protected]
pkg:golang/github.com/briandowns/[email protected]
pkg:golang/github.com/cli/go-gh/[email protected]
pkg:golang/github.com/cli/[email protected]
pkg:golang/github.com/cloudflare/[email protected]
pkg:golang/github.com/denisbrodbeck/[email protected]
pkg:golang/github.com/dsnet/[email protected]
Expand Down Expand Up @@ -65,7 +67,7 @@ pkg:golang/github.com/klauspost/[email protected]
pkg:golang/github.com/kylelemons/[email protected]
pkg:golang/github.com/mattn/[email protected]
pkg:golang/github.com/mattn/[email protected]
pkg:golang/github.com/mgutz/[email protected]20170206155736-9520e82c474b
pkg:golang/github.com/mgutz/[email protected]20200706080929-d51e80ef957d
pkg:golang/github.com/mholt/[email protected]
pkg:golang/github.com/minio/[email protected]
pkg:golang/github.com/mongodb-forks/[email protected]
Expand Down
4 changes: 3 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ require (
require (
github.com/bmatcuk/doublestar/v4 v4.0.2 // indirect
github.com/cenkalti/backoff/v5 v5.0.2 // indirect
github.com/cli/safeexec v1.0.0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/google/addlicense v1.1.1 // indirect
github.com/google/go-licenses/v2 v2.0.0-alpha.1 // indirect
Expand Down Expand Up @@ -101,6 +102,7 @@ require (
github.com/bodgit/plumbing v1.3.0 // indirect
github.com/bodgit/sevenzip v1.6.0 // indirect
github.com/bodgit/windows v1.0.1 // indirect
github.com/cli/go-gh/v2 v2.12.1
github.com/cloudflare/circl v1.6.1 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960 // indirect
Expand Down Expand Up @@ -135,7 +137,7 @@ require (
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
github.com/minio/minlz v1.0.0 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect
Expand Down
7 changes: 6 additions & 1 deletion go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,10 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/cli/go-gh/v2 v2.12.1 h1:SVt1/afj5FRAythyMV3WJKaUfDNsxXTIe7arZbwTWKA=
github.com/cli/go-gh/v2 v2.12.1/go.mod h1:+5aXmEOJsH9fc9mBHfincDwnS02j2AIA/DsTH0Bk5uw=
github.com/cli/safeexec v1.0.0 h1:0VngyaIyqACHdcMNWfo6+KdUYnqEr2Sg+bSP1pdF+dI=
github.com/cli/safeexec v1.0.0/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
Expand Down Expand Up @@ -301,8 +305,9 @@ github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/mholt/archives v0.1.2 h1:UBSe5NfYKHI1sy+S5dJsEsG9jsKKk8NJA4HCC+xTI4A=
github.com/mholt/archives v0.1.2/go.mod h1:D7QzTHgw3ctfS6wgOO9dN+MFgdZpbksGCxprUOwZWDs=
github.com/minio/minlz v1.0.0 h1:Kj7aJZ1//LlTP1DM8Jm7lNKvvJS2m74gyyXXn3+uJWQ=
Expand Down
12 changes: 5 additions & 7 deletions internal/cli/plugin/first_class.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ package plugin
import (
"fmt"

"github.com/google/go-github/v61/github"
"github.com/mongodb/mongodb-atlas-cli/atlascli/internal/plugin"
"github.com/spf13/cobra"
)
Expand Down Expand Up @@ -90,16 +89,16 @@ func (fcp *FirstClassPlugin) isAlreadyInstalled(plugins *plugin.ValidatedPlugins
return false
}

func (fcp *FirstClassPlugin) runFirstClassPluginCommand(cmd *cobra.Command, args []string, ghClient *github.Client, plugins *plugin.ValidatedPlugins) error {
func (fcp *FirstClassPlugin) runFirstClassPluginCommand(cmd *cobra.Command, args []string, plugins *plugin.ValidatedPlugins) error {
installOpts := &InstallOpts{
Opts: Opts{
plugins: plugins,
},
ghClient: NewAuthenticatedGithubClient(),
}
installOpts.githubAsset = &GithubAsset{
ghClient: ghClient,
owner: fcp.Github.Owner,
name: fcp.Github.Name,
owner: fcp.Github.Owner,
name: fcp.Github.Name,
}
installOpts.Print("Installing first class plugin " + fcp.Name)

Expand All @@ -122,7 +121,6 @@ func (fcp *FirstClassPlugin) runFirstClassPluginCommand(cmd *cobra.Command, args

func (fcp *FirstClassPlugin) getCommands(plugins *plugin.ValidatedPlugins) []*cobra.Command {
commands := make([]*cobra.Command, 0, len(fcp.Commands))
ghClient := github.NewClient(nil)

// for every command listed in the first class plugin, create a cobra command that installs the plugin
for _, firstClassPluginCommand := range fcp.Commands {
Expand All @@ -133,7 +131,7 @@ func (fcp *FirstClassPlugin) getCommands(plugins *plugin.ValidatedPlugins) []*co
sourceType: FirstClassSourceType,
},
RunE: func(cmd *cobra.Command, args []string) error {
return fcp.runFirstClassPluginCommand(cmd, args, ghClient, plugins)
return fcp.runFirstClassPluginCommand(cmd, args, plugins)
},
DisableFlagParsing: true,
}
Expand Down
32 changes: 32 additions & 0 deletions internal/cli/plugin/github.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Copyright 2025 MongoDB Inc
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package plugin

import (
"github.com/cli/go-gh/v2/pkg/auth"
"github.com/google/go-github/v61/github"
)

func NewAuthenticatedGithubClient() *github.Client {
// create a new github client
ghClient := github.NewClient(nil)

// try to get the gh token from either the gh cli config or the environment variable (GH_TOKEN/GITHUB_TOKEN)
if token, _ := auth.TokenForHost("github.com"); token != "" {
ghClient = ghClient.WithAuthToken(token)
}

return ghClient
}
10 changes: 6 additions & 4 deletions internal/cli/plugin/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ type InstallOpts struct {
cli.PreRunOpts
cli.OutputOpts
Opts
ghClient *github.Client
githubAsset *GithubAsset
}

Expand Down Expand Up @@ -75,7 +76,7 @@ func (opts *InstallOpts) validatePlugin(pluginDirectoryPath string) error {

func (opts *InstallOpts) Run(ctx context.Context) error {
// get all plugin assets info from github repository
assets, err := opts.githubAsset.getReleaseAssets()
assets, err := opts.githubAsset.getReleaseAssets(opts.ghClient)
if err != nil {
return err
}
Expand All @@ -87,7 +88,7 @@ func (opts *InstallOpts) Run(ctx context.Context) error {
}

// download plugin asset archive file and save it as ReadCloser
rc, err := opts.githubAsset.getPluginAssetsAsReadCloser(assetID, signatureID, pubKeyID)
rc, err := opts.githubAsset.getPluginAssetsAsReadCloser(opts.ghClient, assetID, signatureID, pubKeyID)
if err != nil {
return err
}
Expand Down Expand Up @@ -118,7 +119,9 @@ func (opts *InstallOpts) Run(ctx context.Context) error {
}

func InstallBuilder(pluginOpts *Opts) *cobra.Command {
opts := &InstallOpts{}
opts := &InstallOpts{
ghClient: NewAuthenticatedGithubClient(),
}
opts.Opts = *pluginOpts

const use = "install"
Expand Down Expand Up @@ -150,7 +153,6 @@ MongoDB provides an example plugin: https://github.com/mongodb/atlas-cli-plugin-
return err
}
opts.githubAsset = githubAssetRelease
opts.githubAsset.ghClient = github.NewClient(nil)

return opts.PreRunE(opts.checkForDuplicatePlugins)
},
Expand Down
32 changes: 17 additions & 15 deletions internal/cli/plugin/plugin_github_asset.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,9 @@ const (
)

type GithubAsset struct {
ghClient *github.Client
owner string
name string
version *semver.Version
owner string
name string
version *semver.Version
}

func (g *GithubAsset) repository() string {
Expand All @@ -77,21 +76,24 @@ func (g *GithubAsset) getPluginDirectoryName() string {
return fmt.Sprintf("%s@%s", g.owner, g.name)
}

func (g *GithubAsset) getReleaseAssets() ([]*github.ReleaseAsset, error) {
func (g *GithubAsset) getReleaseAssets(ghClient *github.Client) ([]*github.ReleaseAsset, error) {
var err error
var release *github.RepositoryRelease

// download latest release if version is not specified
if g.version == nil {
// download the 100 latest releases
const MaxPerPage = 100
releases, _, err := g.ghClient.Repositories.ListReleases(context.Background(), g.owner, g.name, &github.ListOptions{
releases, _, err := ghClient.Repositories.ListReleases(context.Background(), g.owner, g.name, &github.ListOptions{
Page: 0,
PerPage: MaxPerPage,
})

if err != nil {
return nil, fmt.Errorf("could not fetch releases for %s, access to GitHub is required, see https://dochub.mongodb.org/core/atlas-cli-deploy-docker, %w", g.repository(), err)
return nil, fmt.Errorf("could not fetch releases for %s, access to GitHub is required, see https://dochub.mongodb.org/core/atlas-cli-deploy-docker\n"+
"if you are using a private repository, you need to set the GH_TOKEN environment variable (needs content read access to the repository) or authenticate with the github cli\n"+
"more info about access tokens: https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-fine-grained-personal-access-token\n\n"+
"error: %w", g.repository(), err)
}

// get the latest release that doesn't have prerelease info or metadata in the version tag
Expand All @@ -101,10 +103,10 @@ func (g *GithubAsset) getReleaseAssets() ([]*github.ReleaseAsset, error) {
}
} else {
// try to find the release with the version tag with v prefix, if it does not exist try again without the prefix
release, _, err = g.ghClient.Repositories.GetReleaseByTag(context.Background(), g.owner, g.name, "v"+g.version.String())
release, _, err = ghClient.Repositories.GetReleaseByTag(context.Background(), g.owner, g.name, "v"+g.version.String())

if release == nil || err != nil {
release, _, err = g.ghClient.Repositories.GetReleaseByTag(context.Background(), g.owner, g.name, g.version.String())
release, _, err = ghClient.Repositories.GetReleaseByTag(context.Background(), g.owner, g.name, g.version.String())
}

if err != nil {
Expand Down Expand Up @@ -240,8 +242,8 @@ func getSignatureAssetandKeyID(name string, assets []*github.ReleaseAsset) (int6
return *signatureAsset.ID, *pubKeyAsset.ID, nil
}

func (g *GithubAsset) getPluginAssetsAsReadCloser(assetID, sigAssetID, pubKeyAssetID int64) (io.ReadCloser, error) {
rc, _, err := g.ghClient.Repositories.DownloadReleaseAsset(context.Background(), g.owner, g.name, assetID, http.DefaultClient)
func (g *GithubAsset) getPluginAssetsAsReadCloser(ghClient *github.Client, assetID, sigAssetID, pubKeyAssetID int64) (io.ReadCloser, error) {
rc, _, err := ghClient.Repositories.DownloadReleaseAsset(context.Background(), g.owner, g.name, assetID, http.DefaultClient)
if err != nil {
return nil, fmt.Errorf("could not download asset with ID %d from %s", assetID, g.repository())
}
Expand All @@ -253,7 +255,7 @@ func (g *GithubAsset) getPluginAssetsAsReadCloser(assetID, sigAssetID, pubKeyAss

// Only do verification if IDs are not 0, i.e. when there is signature package available
if sigAssetID != 0 && pubKeyAssetID != 0 {
err = g.verifyAssetSignature(asset, sigAssetID, pubKeyAssetID)
err = g.verifyAssetSignature(ghClient, asset, sigAssetID, pubKeyAssetID)
if err != nil {
return nil, err
}
Expand All @@ -264,14 +266,14 @@ func (g *GithubAsset) getPluginAssetsAsReadCloser(assetID, sigAssetID, pubKeyAss

// verifyAssetSignature verifies the asset signature.
// Returns nil if signature check is successful.
func (g *GithubAsset) verifyAssetSignature(asset []byte, sigAssetID, pubKeyAssetID int64) error {
sigRc, _, err := g.ghClient.Repositories.DownloadReleaseAsset(context.Background(), g.owner, g.name, sigAssetID, http.DefaultClient)
func (g *GithubAsset) verifyAssetSignature(ghClient *github.Client, asset []byte, sigAssetID, pubKeyAssetID int64) error {
sigRc, _, err := ghClient.Repositories.DownloadReleaseAsset(context.Background(), g.owner, g.name, sigAssetID, http.DefaultClient)
if err != nil {
return fmt.Errorf("could not download signature asset with ID %d from %s", sigAssetID, g.repository())
}
defer sigRc.Close()

keyRc, _, err := g.ghClient.Repositories.DownloadReleaseAsset(context.Background(), g.owner, g.name, pubKeyAssetID, http.DefaultClient)
keyRc, _, err := ghClient.Repositories.DownloadReleaseAsset(context.Background(), g.owner, g.name, pubKeyAssetID, http.DefaultClient)
if err != nil {
return fmt.Errorf("could not download public key asset with ID %d from %s", pubKeyAssetID, g.repository())
}
Expand Down
10 changes: 4 additions & 6 deletions internal/cli/plugin/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ type UpdateOpts struct {
UpdateAll bool
pluginSpecifier string
pluginUpdateVersion *semver.Version
ghClient *github.Client
}

func printPluginUpdateWarning(p *plugin.Plugin, err error) {
Expand Down Expand Up @@ -132,7 +133,7 @@ func (opts *UpdateOpts) validatePlugin(pluginDirectoryPath string) error {

func (opts *UpdateOpts) updatePlugin(ctx context.Context, githubAssetRelease *GithubAsset, existingPlugin *plugin.Plugin) error {
// get all plugin assets info from github repository
assets, err := githubAssetRelease.getReleaseAssets()
assets, err := githubAssetRelease.getReleaseAssets(opts.ghClient)
if err != nil {
return err
}
Expand All @@ -144,7 +145,7 @@ func (opts *UpdateOpts) updatePlugin(ctx context.Context, githubAssetRelease *Gi
}

// download plugin asset archive file and save it as ReadCloser
rc, err := githubAssetRelease.getPluginAssetsAsReadCloser(assetID, signatureID, pubKeyID)
rc, err := githubAssetRelease.getPluginAssetsAsReadCloser(opts.ghClient, assetID, signatureID, pubKeyID)
if err != nil {
return err
}
Expand Down Expand Up @@ -198,8 +199,6 @@ func (opts *UpdateOpts) updatePlugin(ctx context.Context, githubAssetRelease *Gi
}

func (opts *UpdateOpts) Run(ctx context.Context) error {
ghClient := github.NewClient(nil)

// if update flag is set, update all plugin, if not update only specified plugin
if opts.UpdateAll {
// try to create GithubAssetRelease from each plugin - when create use it to update the plugin
Expand All @@ -218,7 +217,6 @@ func (opts *UpdateOpts) Run(ctx context.Context) error {
}

// update using GithubAsset
githubAsset.ghClient = ghClient
err = opts.updatePlugin(ctx, githubAsset, p)
if err != nil {
printPluginUpdateWarning(p, err)
Expand Down Expand Up @@ -249,7 +247,6 @@ func (opts *UpdateOpts) Run(ctx context.Context) error {

// update using GithubAsset
opts.Print(fmt.Sprintf(`Updating plugin "%s"`, existingPlugin.Name))
githubAsset.ghClient = ghClient
err = opts.updatePlugin(ctx, githubAsset, existingPlugin)
if err != nil {
return err
Expand All @@ -262,6 +259,7 @@ func (opts *UpdateOpts) Run(ctx context.Context) error {
func UpdateBuilder(pluginOpts *Opts) *cobra.Command {
opts := &UpdateOpts{
UpdateAll: false,
ghClient: NewAuthenticatedGithubClient(),
}
opts.Opts = *pluginOpts

Expand Down
Loading