Skip to content
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
37 changes: 37 additions & 0 deletions apps/cinc/cmd/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,44 @@ func resolveSupermarketProfile(cmd *cobra.Command) (config.Profile, error) {
if err != nil {
return config.Profile{}, err
}
return selectSupermarketProfile(cmd, cfg)
}

// resolveSupermarketSite returns the Supermarket a command should talk to:
// the --supermarket-site flag, then the resolved profile's supermarket_site,
// then "" for the caller's default (the public Supermarket).
//
// The read-only Supermarket commands need no credentials, so this never
// triggers the first-run flow and never fails: an absent or unreadable
// credentials file simply means no configured preference. Without this, a
// private supermarket_site was honored by `supermarket share` and ignored by
// every command that reads.
func resolveSupermarketSite(cmd *cobra.Command, siteFlag string) string {
if siteFlag != "" {
return siteFlag
}
path := resolveConfigPath(cmd)
if path == "" {
return ""
}
if _, err := os.Stat(path); err != nil {
return ""
}
cfg, err := config.Load(path)
if err != nil {
return ""
}
profile, err := selectSupermarketProfile(cmd, cfg)
if err != nil {
return ""
}
return profile.SupermarketSite
}

// selectSupermarketProfile picks which profile carries the Supermarket
// settings: an explicit --profile or environment profile wins, otherwise the
// conventional [supermarket] section, falling back to [default].
func selectSupermarketProfile(cmd *cobra.Command, cfg *config.Config) (config.Profile, error) {
if profileName, _ := cmd.Flags().GetString("profile"); profileName != "" {
return cfg.Profile(profileName)
}
Expand Down
42 changes: 23 additions & 19 deletions apps/cinc/cmd/supermarket.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ cinc supermarket list`,
if err != nil {
return err
}
client, err := supermarket.NewAnonymous(site)
client, err := supermarket.NewAnonymous(resolveSupermarketSite(cmd, site))
if err != nil {
return err
}
Expand All @@ -69,7 +69,7 @@ cinc supermarket list`,
return printSupermarketEntries(cmd.OutOrStdout(), result.Entries, verbose)
},
}
cmd.Flags().StringVar(&site, "supermarket-site", "", "URL of the Chef Supermarket site (default: https://supermarket.chef.io)")
cmd.Flags().StringVar(&site, "supermarket-site", "", "URL of the Chef Supermarket site (default: profile supermarket_site, then https://supermarket.chef.io)")
cmd.Flags().StringVar(&order, "order", "", "sort order: recently_updated, recently_added, most_downloaded, most_followed")
cmd.Flags().StringVar(&user, "user", "", "only show cookbooks owned by this Supermarket username")
cmd.Flags().IntVar(&limit, "limit", 0, "cap the number of entries returned (default: all)")
Expand Down Expand Up @@ -98,7 +98,7 @@ cinc supermarket search nginx`,
if err != nil {
return err
}
client, err := supermarket.NewAnonymous(site)
client, err := supermarket.NewAnonymous(resolveSupermarketSite(cmd, site))
if err != nil {
return err
}
Expand All @@ -114,7 +114,7 @@ cinc supermarket search nginx`,
return printSupermarketEntries(cmd.OutOrStdout(), result.Entries, verbose)
},
}
cmd.Flags().StringVar(&site, "supermarket-site", "", "URL of the Chef Supermarket site (default: https://supermarket.chef.io)")
cmd.Flags().StringVar(&site, "supermarket-site", "", "URL of the Chef Supermarket site (default: profile supermarket_site, then https://supermarket.chef.io)")
cmd.Flags().IntVar(&limit, "limit", 0, "cap the number of entries returned (default: all matches)")
cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "include maintainer and latest version per cookbook")
return cmd
Expand All @@ -140,7 +140,7 @@ cinc supermarket show nginx 1.2.0`,
if err != nil {
return err
}
client, err := supermarket.NewAnonymous(site)
client, err := supermarket.NewAnonymous(resolveSupermarketSite(cmd, site))
if err != nil {
return err
}
Expand All @@ -158,7 +158,7 @@ cinc supermarket show nginx 1.2.0`,
return printSupermarketShow(cmd.OutOrStdout(), result)
},
}
cmd.Flags().StringVar(&site, "supermarket-site", "", "URL of the Chef Supermarket site (default: https://supermarket.chef.io)")
cmd.Flags().StringVar(&site, "supermarket-site", "", "URL of the Chef Supermarket site (default: profile supermarket_site, then https://supermarket.chef.io)")
return cmd
}

Expand Down Expand Up @@ -288,8 +288,9 @@ func formatBytes(n int64) string {
}

// newSupermarketDownloadCmd builds `cinc supermarket download`.
// Like `explore`, this hits only anonymous endpoints, so we never
// load a profile or key here.
// Like `explore`, this hits only anonymous endpoints, so no key is loaded
// and a missing credentials file is fine. An existing profile is still read
// for supermarket_site so downloads come from the configured Supermarket.
func newSupermarketDownloadCmd() *cobra.Command {
var (
file string
Expand Down Expand Up @@ -319,7 +320,7 @@ cinc supermarket download nginx`,
if len(args) == 2 {
opts.Version = args[1]
}
client, err := supermarket.NewAnonymous(site)
client, err := supermarket.NewAnonymous(resolveSupermarketSite(cmd, site))
if err != nil {
return err
}
Expand All @@ -336,7 +337,7 @@ cinc supermarket download nginx`,
}
cmd.Flags().StringVar(&file, "file", "", "output file or directory (default: ./<cookbook>-<version>.tar.gz)")
cmd.Flags().BoolVar(&force, "force", false, "overwrite the output file if it already exists")
cmd.Flags().StringVar(&site, "supermarket-site", "", "URL of the Chef Supermarket site (default: https://supermarket.chef.io)")
cmd.Flags().StringVar(&site, "supermarket-site", "", "URL of the Chef Supermarket site (default: profile supermarket_site, then https://supermarket.chef.io)")
return cmd
}

Expand Down Expand Up @@ -373,7 +374,7 @@ cinc supermarket install nginx 1.2.0`,
if err != nil {
return err
}
client, err := supermarket.NewAnonymous(site)
client, err := supermarket.NewAnonymous(resolveSupermarketSite(cmd, site))
if err != nil {
return err
}
Expand All @@ -392,13 +393,15 @@ cinc supermarket install nginx 1.2.0`,
return nil
},
}
cmd.Flags().StringVar(&site, "supermarket-site", "", "URL of the Chef Supermarket site (default: https://supermarket.chef.io)")
cmd.Flags().StringVar(&site, "supermarket-site", "", "URL of the Chef Supermarket site (default: profile supermarket_site, then https://supermarket.chef.io)")
return cmd
}

// newSupermarketExploreCmd builds the `cinc supermarket explore` TUI.
// It needs no credentials — every endpoint it touches is anonymous —
// so we never run the first-run flow or load a profile here.
// It needs no credentials — every endpoint it touches is anonymous — so it
// never runs the first-run flow. An existing profile is still consulted for
// supermarket_site, so `explore` browses the same Supermarket the rest of
// the commands use, but a missing credentials file is not an error.
func newSupermarketExploreCmd() *cobra.Command {
var site string
cmd := &cobra.Command{
Expand All @@ -413,24 +416,25 @@ cinc supermarket explore`,
"quit.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
resolved := resolveSupermarketSite(cmd, site)
return explore.Run(cmd.Context(), explore.Options{
Site: site,
Site: resolved,
Stdin: cmd.InOrStdin(),
Stdout: cmd.OutOrStdout(),
Stderr: cmd.ErrOrStderr(),
Install: supermarketInstaller(cmd, site),
Install: supermarketInstaller(cmd, resolved),
})
},
}
cmd.Flags().StringVar(&site, "supermarket-site", "", "URL of the Chef Supermarket site (default: https://supermarket.chef.io)")
cmd.Flags().StringVar(&site, "supermarket-site", "", "URL of the Chef Supermarket site (default: profile supermarket_site, then https://supermarket.chef.io)")
return cmd
}

// supermarketInstaller returns the closure the explore TUI calls when the
// user installs a cookbook. Credentials are resolved lazily — only when
// the closure runs — so launching `cinc supermarket explore` stays
// credential-free. Any credential or upload failure flows back to the
// TUI footer.
// credential-free. site is already resolved by the caller. Any credential
// or upload failure flows back to the TUI footer.
func supermarketInstaller(cmd *cobra.Command, site string) func(context.Context, string, string) error {
return func(ctx context.Context, name, version string) error {
server, err := resolveClient(cmd)
Expand Down
164 changes: 164 additions & 0 deletions apps/cinc/cmd/supermarket_site_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package cmd

import (
"bytes"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)

// newSiteRecordingServer stands in for a private Supermarket. It records the
// first path it is asked for, so a test can tell whether a command talked to
// it at all, and answers the handful of endpoints the read commands use.
func newSiteRecordingServer(t *testing.T, got *string) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if *got == "" {
*got = r.URL.Path
}
w.Header().Set("Content-Type", "application/json")
switch {
case strings.HasSuffix(r.URL.Path, "/download"):
w.Header().Set("Content-Type", "application/gzip")
_, _ = w.Write([]byte("tar-body"))
case strings.Contains(r.URL.Path, "/search"):
_, _ = w.Write([]byte(`{"start":0,"total":0,"items":[]}`))
case strings.HasSuffix(r.URL.Path, "/cookbooks"):
_, _ = w.Write([]byte(`{"start":0,"total":0,"items":[]}`))
default:
_, _ = w.Write([]byte(`{"name":"nginx","latest_version":"` + srvVersionURL(r) + `","versions":[]}`))
}
}))
t.Cleanup(srv.Close)
return srv
}

func srvVersionURL(r *http.Request) string {
return "http://" + r.Host + "/api/v1/cookbooks/nginx/versions/1_2_0"
}

// writeSupermarketSiteConfig writes a credentials file whose profile points at
// a private Supermarket, the way `cinc config create --supermarket-site` does.
func writeSupermarketSiteConfig(t *testing.T, profile, site string) string {
t.Helper()
cfgPath := filepath.Join(t.TempDir(), "credentials")
cfg := fmt.Sprintf(`[%s]
supermarket_site = %q
client_name = "tim"
client_key = %q
`, profile, site, writeTestKey(t))
if err := os.WriteFile(cfgPath, []byte(cfg), 0o600); err != nil {
t.Fatal(err)
}
return cfgPath
}

// TestSupermarketReadCommandsUseProfileSite covers the split where
// `supermarket share` honored a profile's supermarket_site while every
// read-only command silently queried the public Supermarket instead. A user
// with a private Supermarket configured should not have to repeat
// --supermarket-site on each command, and `install` in particular should not
// pull a public cookbook onto their server when they meant their own.
func TestSupermarketReadCommandsUseProfileSite(t *testing.T) {
t.Setenv("HOME", t.TempDir())
for _, tc := range []struct {
name string
args func(site string) []string
}{
{"download", func(string) []string { return []string{"supermarket", "download", "nginx"} }},
{"show", func(string) []string { return []string{"supermarket", "show", "nginx"} }},
{"search", func(string) []string { return []string{"supermarket", "search", "nginx"} }},
{"list", func(string) []string { return []string{"supermarket", "list"} }},
} {
t.Run(tc.name, func(t *testing.T) {
var got string
srv := newSiteRecordingServer(t, &got)
cfgPath := writeSupermarketSiteConfig(t, "default", srv.URL)

root := newRootCmd()
root.SetOut(new(bytes.Buffer))
root.SetErr(new(bytes.Buffer))
args := append(tc.args(srv.URL), "--config", cfgPath)
if tc.name == "download" {
args = append(args, "--file", filepath.Join(t.TempDir(), "out.tgz"))
}
root.SetArgs(args)
_ = root.Execute()

if got == "" {
t.Fatalf("%s never reached the configured Supermarket at %s; it used the public default", tc.name, srv.URL)
}
})
}
}

// The flag still wins over the profile.
func TestSupermarketSiteFlagBeatsProfile(t *testing.T) {
t.Setenv("HOME", t.TempDir())
cfgPath := writeSupermarketSiteConfig(t, "default", "https://profile.example.test")

cmd := fakeCmd(cfgPath, "", "", new(bytes.Buffer))
if got := resolveSupermarketSite(cmd, "https://flag.example.test"); got != "https://flag.example.test" {
t.Errorf("site = %q, want the --supermarket-site flag to win", got)
}
}

// With no credentials file at all the read commands must still work, falling
// back to the public Supermarket rather than erroring or prompting for setup.
func TestSupermarketSiteFallsBackWithoutCredentials(t *testing.T) {
t.Setenv("HOME", t.TempDir())
missing := filepath.Join(t.TempDir(), "does-not-exist")

cmd := fakeCmd(missing, "", "", new(bytes.Buffer))
if got := resolveSupermarketSite(cmd, ""); got != "" {
t.Errorf("site = %q, want \"\" so the caller uses the public default", got)
}
}

// A profile with no supermarket_site leaves the default in place.
func TestSupermarketSiteEmptyProfileKeyFallsBack(t *testing.T) {
t.Setenv("HOME", t.TempDir())
cfgPath := filepath.Join(t.TempDir(), "credentials")
cfg := fmt.Sprintf(`[default]
cinc_server_url = "https://cinc.example.test/organizations/acme"
client_name = "tim"
client_key = %q
`, writeTestKey(t))
if err := os.WriteFile(cfgPath, []byte(cfg), 0o600); err != nil {
t.Fatal(err)
}

cmd := fakeCmd(cfgPath, "", "", new(bytes.Buffer))
if got := resolveSupermarketSite(cmd, ""); got != "" {
t.Errorf("site = %q, want \"\" when the profile sets no supermarket_site", got)
}
}

// The conventional [supermarket] profile is preferred over [default], the
// same precedence `supermarket share` already uses.
func TestSupermarketSitePrefersSupermarketProfile(t *testing.T) {
t.Setenv("HOME", t.TempDir())
cfgPath := filepath.Join(t.TempDir(), "credentials")
cfg := fmt.Sprintf(`[default]
supermarket_site = "https://default.example.test"
client_name = "tim"
client_key = %q

[supermarket]
supermarket_site = "https://supermarket-profile.example.test"
client_name = "tim"
client_key = %q
`, writeTestKey(t), writeTestKey(t))
if err := os.WriteFile(cfgPath, []byte(cfg), 0o600); err != nil {
t.Fatal(err)
}

cmd := fakeCmd(cfgPath, "", "", new(bytes.Buffer))
if got := resolveSupermarketSite(cmd, ""); !strings.Contains(got, "supermarket-profile") {
t.Errorf("site = %q, want the [supermarket] profile to win over [default]", got)
}
}
2 changes: 1 addition & 1 deletion docs/commands/cinc_supermarket_download.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ cinc supermarket download nginx
--file string output file or directory (default: ./<cookbook>-<version>.tar.gz)
--force overwrite the output file if it already exists
-h, --help help for download
--supermarket-site string URL of the Chef Supermarket site (default: https://supermarket.chef.io)
--supermarket-site string URL of the Chef Supermarket site (default: profile supermarket_site, then https://supermarket.chef.io)
```

### Options inherited from parent commands
Expand Down
2 changes: 1 addition & 1 deletion docs/commands/cinc_supermarket_explore.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ cinc supermarket explore

```
-h, --help help for explore
--supermarket-site string URL of the Chef Supermarket site (default: https://supermarket.chef.io)
--supermarket-site string URL of the Chef Supermarket site (default: profile supermarket_site, then https://supermarket.chef.io)
```

### Options inherited from parent commands
Expand Down
2 changes: 1 addition & 1 deletion docs/commands/cinc_supermarket_install.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ cinc supermarket install nginx 1.2.0

```
-h, --help help for install
--supermarket-site string URL of the Chef Supermarket site (default: https://supermarket.chef.io)
--supermarket-site string URL of the Chef Supermarket site (default: profile supermarket_site, then https://supermarket.chef.io)
```

### Options inherited from parent commands
Expand Down
2 changes: 1 addition & 1 deletion docs/commands/cinc_supermarket_list.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ cinc supermarket list
-h, --help help for list
--limit int cap the number of entries returned (default: all)
--order string sort order: recently_updated, recently_added, most_downloaded, most_followed
--supermarket-site string URL of the Chef Supermarket site (default: https://supermarket.chef.io)
--supermarket-site string URL of the Chef Supermarket site (default: profile supermarket_site, then https://supermarket.chef.io)
--user string only show cookbooks owned by this Supermarket username
-v, --verbose include maintainer and latest version per cookbook
```
Expand Down
2 changes: 1 addition & 1 deletion docs/commands/cinc_supermarket_search.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ cinc supermarket search nginx
```
-h, --help help for search
--limit int cap the number of entries returned (default: all matches)
--supermarket-site string URL of the Chef Supermarket site (default: https://supermarket.chef.io)
--supermarket-site string URL of the Chef Supermarket site (default: profile supermarket_site, then https://supermarket.chef.io)
-v, --verbose include maintainer and latest version per cookbook
```

Expand Down
Loading