diff --git a/README.md b/README.md index 90662e9c1..63e1cc223 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,7 @@ Install the Defang CLI from one of the following sources: ## Support - File any issues [here](https://github.com/DefangLabs/defang/issues) +- Join our [Discord community](https://s.defang.io/discord) for real-time help and discussions ## Command completion @@ -155,13 +156,12 @@ The Defang CLI recognizes the following environment variables: - `DEFANG_ISSUER` - The OAuth2 issuer to use for authentication; defaults to `https://auth.defang.io` - `DEFANG_MODEL_ID` - The model ID of the LLM to use for the generate/debug AI integration (Pro users only) - `DEFANG_NO_CACHE` - If set to `true`, disables pull-through caching of container images; defaults to `false` -- `DEFANG_ORG` - The name of the organization to use; defaults to the user's GitHub name +- `DEFANG_ORG` - The name of the organization to use; defaults to the user's personal org - `DEFANG_PREFIX` - The prefix to use for all BYOC resources; defaults to `Defang` - `DEFANG_PROVIDER` - The name of the cloud provider to use, `auto` (default), `aws`, `digitalocean`, `gcp`, or `defang` - `DEFANG_PULUMI_BACKEND` - The Pulumi backend URL or `"pulumi-cloud"`; defaults to a self-hosted backend - `DEFANG_PULUMI_DIR` - Run Pulumi from this folder, instead of spawning a cloud task; requires `--debug` (BYOC only) - `DEFANG_PULUMI_VERSION` - Override the version of the Pulumi image to use (`aws` provider only) -- `DEFANG_SUFFIX` - The suffix to use for all BYOC resources; defaults to the stack name, or `beta` if unset. - `NO_COLOR` - If set to any value, disables color output; by default, color output is enabled depending on the terminal - `PULUMI_ACCESS_TOKEN` - The Pulumi access token to use for authentication to Pulumi Cloud; see `DEFANG_PULUMI_BACKEND` - `PULUMI_CONFIG_PASSPHRASE` - Passphrase used to generate a unique key for your stack, and configuration and encrypted state values diff --git a/pkgs/npm/README.md b/pkgs/npm/README.md index a6a53bf8e..47da11ff1 100644 --- a/pkgs/npm/README.md +++ b/pkgs/npm/README.md @@ -16,7 +16,8 @@ The Defang Command-Line Interface [(CLI)](https://docs.defang.io/docs/getting-st ## Support -- File any issues [here](https://github.com/DefangLabs/defang/issues) +- File any issues [right here on GitHub](https://github.com/DefangLabs/defang/issues) +- Join our [Discord community](https://s.defang.io/discord) for real-time help and discussions ## Environment Variables @@ -46,7 +47,6 @@ The Defang CLI recognizes the following environment variables: - `DEFANG_PULUMI_BACKEND` - The Pulumi backend URL or `"pulumi-cloud"`; defaults to a self-hosted backend - `DEFANG_PULUMI_DIR` - Run Pulumi from this folder, instead of spawning a cloud task; requires `--debug` (BYOC only) - `DEFANG_PULUMI_VERSION` - Override the version of the Pulumi image to use (`aws` provider only) -- `DEFANG_SUFFIX` - The suffix to use for all BYOC resources; defaults to the stack name, or `beta` if unset. - `NO_COLOR` - If set to any value, disables color output; by default, color output is enabled depending on the terminal - `PULUMI_ACCESS_TOKEN` - The Pulumi access token to use for authentication to Pulumi Cloud; see `DEFANG_PULUMI_BACKEND` - `PULUMI_CONFIG_PASSPHRASE` - Passphrase used to generate a unique key for your stack, and configuration and encrypted state values diff --git a/src/README.md b/src/README.md index a6a53bf8e..5c5088d5d 100644 --- a/src/README.md +++ b/src/README.md @@ -17,6 +17,7 @@ The Defang Command-Line Interface [(CLI)](https://docs.defang.io/docs/getting-st ## Support - File any issues [here](https://github.com/DefangLabs/defang/issues) +- Join our [Discord community](https://s.defang.io/discord) for real-time help and discussions ## Environment Variables @@ -46,7 +47,6 @@ The Defang CLI recognizes the following environment variables: - `DEFANG_PULUMI_BACKEND` - The Pulumi backend URL or `"pulumi-cloud"`; defaults to a self-hosted backend - `DEFANG_PULUMI_DIR` - Run Pulumi from this folder, instead of spawning a cloud task; requires `--debug` (BYOC only) - `DEFANG_PULUMI_VERSION` - Override the version of the Pulumi image to use (`aws` provider only) -- `DEFANG_SUFFIX` - The suffix to use for all BYOC resources; defaults to the stack name, or `beta` if unset. - `NO_COLOR` - If set to any value, disables color output; by default, color output is enabled depending on the terminal - `PULUMI_ACCESS_TOKEN` - The Pulumi access token to use for authentication to Pulumi Cloud; see `DEFANG_PULUMI_BACKEND` - `PULUMI_CONFIG_PASSPHRASE` - Passphrase used to generate a unique key for your stack, and configuration and encrypted state values diff --git a/src/cmd/cli/command/commands.go b/src/cmd/cli/command/commands.go index a571880a8..fbab3e8be 100644 --- a/src/cmd/cli/command/commands.go +++ b/src/cmd/cli/command/commands.go @@ -10,17 +10,20 @@ import ( "os/exec" "path/filepath" "regexp" + "slices" "strings" "time" "github.com/AlecAivazis/survey/v2" "github.com/DefangLabs/defang/src/pkg" + "github.com/DefangLabs/defang/src/pkg/auth" "github.com/DefangLabs/defang/src/pkg/cli" cliClient "github.com/DefangLabs/defang/src/pkg/cli/client" "github.com/DefangLabs/defang/src/pkg/cli/client/byoc" "github.com/DefangLabs/defang/src/pkg/cli/client/byoc/gcp" "github.com/DefangLabs/defang/src/pkg/cli/compose" "github.com/DefangLabs/defang/src/pkg/clouds/aws" + pcluster "github.com/DefangLabs/defang/src/pkg/cluster" "github.com/DefangLabs/defang/src/pkg/dryrun" "github.com/DefangLabs/defang/src/pkg/login" "github.com/DefangLabs/defang/src/pkg/logs" @@ -206,6 +209,9 @@ func SetupCommands(ctx context.Context, version string) { // loginCmd.Flags().Bool("skip-prompt", false, "skip the login prompt if already logged in"); TODO: Implement this RootCmd.AddCommand(loginCmd) + // Tenants Command + RootCmd.AddCommand(tenantsCmd) + // Whoami Command whoamiCmd.PersistentFlags().Bool("json", pkg.GetenvBool("DEFANG_JSON"), "print output in JSON format") RootCmd.AddCommand(whoamiCmd) @@ -341,12 +347,10 @@ var RootCmd = &cobra.Command{ ctx := cmd.Context() term.SetDebug(global.Debug) - // Don't track/connect the completion commands if IsCompletionCommand(cmd) { return nil } - // Use "defer" to track any errors that occur during the command defer func() { var errString = "" if err != nil { @@ -356,7 +360,6 @@ var RootCmd = &cobra.Command{ track.Cmd(cmd, "Invoked", P("args", args), P("err", errString), P("non-interactive", global.NonInteractive), P("provider", global.ProviderID)) }() - // Do this first, since any errors will be printed to the console switch global.ColorMode { case ColorNever: term.ForceColor(false) @@ -365,13 +368,11 @@ var RootCmd = &cobra.Command{ } if cwd, _ := cmd.Flags().GetString("cwd"); cwd != "" { - // Change directory before running the command if err = os.Chdir(cwd); err != nil { return err } } - // Read the global flags again from any .defang files in the cwd err = global.loadDotDefang(global.getStackName(cmd.Flags())) if err != nil { return err @@ -382,18 +383,22 @@ var RootCmd = &cobra.Command{ return err } + auth.SetSelectedTenantName(global.Org) + global.Client, err = cli.Connect(ctx, getCluster()) + if err != nil { + return err + } if v, err := global.Client.GetVersions(ctx); err == nil { - version := cmd.Root().Version // HACK to avoid circular dependency with RootCmd + version := cmd.Root().Version term.Debug("Fabric:", v.Fabric, "CLI:", version, "CLI-Min:", v.CliMin) if global.HasTty && isNewer(version, v.CliMin) && !isUpgradeCommand(cmd) { term.Warn("Your CLI version is outdated. Please upgrade to the latest version by running:\n\n defang upgrade\n") - global.HideUpdate = true // hide the upgrade hint at the end + global.HideUpdate = true } } - // Check if we are correctly logged in, but only if the command needs authorization if _, ok := cmd.Annotations[authNeeded]; !ok { return nil } @@ -404,7 +409,65 @@ var RootCmd = &cobra.Command{ err = login.InteractiveRequireLoginAndToS(ctx, global.Client, getCluster()) } - return err + if err != nil { + return err + } + + if tok := pcluster.GetExistingToken(getCluster()); tok != "" { + if err2 := auth.ResolveAndSetTenantFromToken(ctx, tok); err2 != nil { + return err2 + } + term.Debugf("Selected tenant: %q (%s)", auth.GetSelectedTenantName(), auth.GetSelectedTenantID()) + } + + return nil + }, +} + +var tenantsCmd = &cobra.Command{ + Use: "tenants", + Aliases: []string{"tenant", "orgs", "org"}, + Args: cobra.NoArgs, + Annotations: authNeededAnnotation, + Short: "List tenants available to the logged-in user", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + tok := pcluster.GetExistingToken(getCluster()) + tenants, err := auth.ListTenantsFromToken(ctx, tok) + if err != nil { + return err + } + + if len(tenants) == 0 { + term.Warn("No tenants found") + return nil + } + + slices.SortStableFunc(tenants, func(a, b auth.Tenant) int { + return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) + }) + + printTenants := make([]struct { + Active string + auth.Tenant + }, len(tenants)) + + currentID := auth.GetSelectedTenantID() + currentName := auth.GetSelectedTenantName() + for i, t := range tenants { + printTenants[i].Tenant = t + selected := t.ID == currentID || (currentID == "" && t.Name == currentName && strings.TrimSpace(currentName) != "") + if selected { + printTenants[i].Active = "*" // highlight selected + } + } + + attrs := []string{"Active", "Name"} + if global.Verbose { + attrs = append(attrs, "ID") + } + return term.Table(printTenants, attrs) }, } diff --git a/src/go.mod b/src/go.mod index 0049f62a3..dc8c1575b 100644 --- a/src/go.mod +++ b/src/go.mod @@ -4,7 +4,7 @@ go 1.24 toolchain go1.24.5 -replace github.com/spf13/cobra v1.8.0 => github.com/DefangLabs/cobra v1.8.0-defang +replace github.com/spf13/cobra v1.10.1 => github.com/DefangLabs/cobra v1.10.1-defang require ( cloud.google.com/go/artifactregistry v1.16.1 @@ -52,8 +52,8 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 github.com/ross96D/cancelreader v0.2.6 github.com/sirupsen/logrus v1.9.3 - github.com/spf13/cobra v1.8.0 - github.com/spf13/pflag v1.0.6 + github.com/spf13/cobra v1.10.1 + github.com/spf13/pflag v1.0.9 github.com/stretchr/testify v1.10.0 go.yaml.in/yaml/v3 v3.0.4 golang.org/x/mod v0.21.0 diff --git a/src/go.sum b/src/go.sum index 8a23fcffd..eccfe736a 100644 --- a/src/go.sum +++ b/src/go.sum @@ -34,8 +34,8 @@ github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkk github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= -github.com/DefangLabs/cobra v1.8.0-defang h1:rTzAg1XbEk3yXUmQPumcwkLgi8iNCby5CjyG3sCwzKk= -github.com/DefangLabs/cobra v1.8.0-defang/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/DefangLabs/cobra v1.10.1-defang h1:Jsj/7J/hcEVnOnRB/qyNQgZY8pjAONfhHntw3w+UwQA= +github.com/DefangLabs/cobra v1.10.1-defang/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= github.com/DefangLabs/secret-detector v0.0.0-20250811234530-d4b4214cd679 h1:qNT7R4qrN+5u5ajSbqSW1opHP4LA8lzA+ASyw5MQZjs= github.com/DefangLabs/secret-detector v0.0.0-20250811234530-d4b4214cd679/go.mod h1:blbwPQh4DTlCZEfk1BLU4oMIhLda2U+A840Uag9DsZw= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.26.0 h1:f2Qw/Ehhimh5uO1fayV0QIW7DShEQqhtUfhYc+cBPlw= @@ -304,8 +304,8 @@ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= -github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE= github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/src/pkg/auth/tenant.go b/src/pkg/auth/tenant.go new file mode 100644 index 000000000..93e0426af --- /dev/null +++ b/src/pkg/auth/tenant.go @@ -0,0 +1,210 @@ +package auth + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/url" + "strings" + + "github.com/DefangLabs/defang/src/pkg/http" + "github.com/golang-jwt/jwt/v5" +) + +var ( + selectedTenantName string + selectedTenantID string + autoSelectBySub bool + + // Returned when multiple tenants share the same name in the userinfo response. + ErrMultipleTenantMatches = errors.New("multiple tenants match the name") + // Returned when no tenant matches the provided name in the userinfo response. + ErrTenantNotFound = errors.New("tenant not found") + // Returned when no access token is available yet (user not logged in). + ErrNoAccessToken = errors.New("no access token available; please login first") +) + +// SetSelectedTenantName stores the desired tenant name for selection. +func SetSelectedTenantName(name string) { + selectedTenantName = strings.TrimSpace(name) + autoSelectBySub = name == "" +} + +// SetAutoSelectBySub enables or disables auto-select by JWT sub. +func SetAutoSelectBySub(enabled bool) { + autoSelectBySub = enabled +} + +// subFromJWT extracts the "sub" claim from the given JWT without verification. +func subFromJWT(token string) (string, error) { + var claims jwt.RegisteredClaims + _, _, err := new(jwt.Parser).ParseUnverified(token, &claims) + if err != nil { + return "", fmt.Errorf("failed to parse access token: %w", err) + } + if claims.Subject == "" { + return "", errors.New("invalid subject (sub) claim in token") + } + return claims.Subject, nil +} + +// GetSelectedTenantName returns the currently selected tenant name. +func GetSelectedTenantName() string { return selectedTenantName } + +// SetSelectedTenantID stores the resolved tenant ID used in Fabric requests. +func SetSelectedTenantID(id string) { selectedTenantID = strings.TrimSpace(id) } + +// GetSelectedTenantID returns the currently selected tenant ID. +func GetSelectedTenantID() string { return selectedTenantID } + +// issuerFromJWT extracts the "iss" claim from the given JWT without verification. +func issuerFromJWT(token string) (string, error) { + var claims jwt.RegisteredClaims + _, _, err := new(jwt.Parser).ParseUnverified(token, &claims) + if err != nil { + return "", fmt.Errorf("failed to parse access token: %w", err) + } + if claims.Issuer == "" { + return "", errors.New("invalid issuer (iss) claim in token") + } + return claims.Issuer, nil +} + +// userinfoTenant represents a tenant entry in the /userinfo payload. +type userinfoTenant struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// userinfoResponse represents the relevant portion of the /userinfo response. +type userinfoResponse struct { + AllTenants []userinfoTenant `json:"allTenants"` +} + +func invokeUserinfoEndpoint(ctx context.Context, accessToken string) (*userinfoResponse, error) { + iss, err := issuerFromJWT(accessToken) + if err != nil { + return nil, err + } + + url, _ := url.JoinPath(iss, "userinfo") + header := http.Header{} + header.Set("Accept", "application/json") + header.Set("Authorization", "Bearer "+accessToken) + resp, err := http.GetWithHeader(ctx, url, header) + if err != nil { + return nil, fmt.Errorf("userinfo request failed: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("userinfo request failed: %s", resp.Status) + } + + var ui userinfoResponse + if err := json.NewDecoder(resp.Body).Decode(&ui); err != nil { + return nil, fmt.Errorf("failed to decode userinfo: %w", err) + } + return &ui, nil +} + +// ResolveAndSetTenantFromToken resolves the tenant ID for the previously set tenant name +// by calling issuer + "/userinfo" with the current access token. On success, it sets the +// global selected tenant ID so subsequent Fabric requests include the header. +func ResolveAndSetTenantFromToken(ctx context.Context, accessToken string) error { + // If neither a specific name was requested nor auto-select was enabled, do nothing + if strings.TrimSpace(selectedTenantName) == "" && !autoSelectBySub { + return nil + } + + token := strings.TrimSpace(accessToken) + if token == "" { + return ErrNoAccessToken + } + + iss, err := issuerFromJWT(token) + if err != nil { + return err + } + + // If the token is from GitHub Actions, then we do not + // use the userinfo endpoint to resolve the tenant ID. + if iss == "https://fabric-prod1.defang.dev" { + return nil + } + + ui, err := invokeUserinfoEndpoint(ctx, token) + if err != nil { + return err + } + + if autoSelectBySub { + sub, err := subFromJWT(token) + if err != nil { + return err + } + matches := 0 + var id string + for _, t := range ui.AllTenants { + if t.ID == sub { + id = t.ID + matches++ + } + } + switch matches { + case 0: + return fmt.Errorf("%w: no tenant with id matching JWT sub", ErrTenantNotFound) + case 1: + SetSelectedTenantID(id) + return nil + default: + return fmt.Errorf("%w: multiple tenants with id %q", ErrMultipleTenantMatches, sub) + } + } else { + var ( + id string + count int + ) + for _, t := range ui.AllTenants { + if t.Name == selectedTenantName { + id = t.ID + count++ + } + } + switch count { + case 0: + return fmt.Errorf("%w: %q", ErrTenantNotFound, selectedTenantName) + case 1: + SetSelectedTenantID(id) + return nil + default: + return fmt.Errorf("%w: %q", ErrMultipleTenantMatches, selectedTenantName) + } + } +} + +// Tenant represents a tenant entry returned by the /userinfo endpoint. +type Tenant struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// ListTenantsFromToken calls issuer + "/userinfo" with the provided access token +// and returns the list of tenants available to the user. +func ListTenantsFromToken(ctx context.Context, accessToken string) ([]Tenant, error) { + token := strings.TrimSpace(accessToken) + if token == "" { + return nil, ErrNoAccessToken + } + + ui, err := invokeUserinfoEndpoint(ctx, token) + if err != nil { + return nil, err + } + + tenants := make([]Tenant, 0, len(ui.AllTenants)) + for _, t := range ui.AllTenants { + tenants = append(tenants, Tenant{ID: t.ID, Name: t.Name}) + } + return tenants, nil +} diff --git a/src/pkg/cli/compose/validation.go b/src/pkg/cli/compose/validation.go index f9f20e460..a689ee82f 100644 --- a/src/pkg/cli/compose/validation.go +++ b/src/pkg/cli/compose/validation.go @@ -8,7 +8,6 @@ import ( "path/filepath" "regexp" "slices" - "sort" "strconv" "strings" "time" @@ -39,8 +38,8 @@ func ValidateProject(project *composeTypes.Project, mode modes.Mode) error { for _, svccfg := range project.Services { services = append(services, svccfg) } - sort.Slice(services, func(i, j int) bool { - return services[i].Name < services[j].Name + slices.SortFunc(services, func(a, b composeTypes.ServiceConfig) int { + return strings.Compare(a.Name, b.Name) }) var errs []error diff --git a/src/pkg/cli/deploymentsList.go b/src/pkg/cli/deploymentsList.go index 5289cc4c8..f7ce9a33a 100644 --- a/src/pkg/cli/deploymentsList.go +++ b/src/pkg/cli/deploymentsList.go @@ -2,7 +2,7 @@ package cli import ( "context" - "sort" + "slices" "strings" "time" @@ -60,8 +60,8 @@ func DeploymentsList(ctx context.Context, listType defangv1.DeploymentType, proj // TODO: allow user to specify sort order sortKeys[i] = strings.Join([]string{d.ProjectName, d.Provider, d.AccountId, d.Region}, "|") } - sort.SliceStable(sortKeys, func(i, j int) bool { - return sortKeys[i] < sortKeys[j] + slices.SortStableFunc(sortKeys, func(a, b string) int { + return strings.Compare(a, b) }) return term.Table(deployments, "ProjectName", "Provider", "AccountId", "Region", "Deployment", "DeployedAt") diff --git a/src/pkg/cli/estimate.go b/src/pkg/cli/estimate.go index 0e77afd5d..071022515 100644 --- a/src/pkg/cli/estimate.go +++ b/src/pkg/cli/estimate.go @@ -6,7 +6,7 @@ import ( "fmt" "io" "os" - "sort" + "slices" "strconv" "strings" "time" @@ -166,8 +166,8 @@ func prepareEstimateLineItemTableItems(lineItems []*defangv1.EstimateLineItem) [ } // sort line items by service + description - sort.Slice(tableItems, func(i, j int) bool { - return tableItems[i].Service+tableItems[i].Description < tableItems[j].Service+tableItems[j].Description + slices.SortFunc(tableItems, func(a, b EstimateLineItemTableItem) int { + return strings.Compare(a.Service+a.Description, b.Service+b.Description) }) return tableItems diff --git a/src/pkg/dns/resolver.go b/src/pkg/dns/resolver.go index dcd8c3b52..04f616ef8 100644 --- a/src/pkg/dns/resolver.go +++ b/src/pkg/dns/resolver.go @@ -6,7 +6,7 @@ import ( "fmt" "net" "slices" - "sort" + "strings" "github.com/DefangLabs/defang/src/pkg" "github.com/miekg/dns" @@ -76,7 +76,9 @@ func FindNSServers(ctx context.Context, domain string) ([]*net.NS, error) { index := pkg.RandomIndex(len(nsServers)) nsServer := nsServers[index].Host ns, err := ResolverAt(nsServer).LookupNS(ctx, domain) - sort.Slice(ns, func(i, j int) bool { return ns[i].Host < ns[j].Host }) + slices.SortFunc(ns, func(a, b *net.NS) int { + return strings.Compare(a.Host, b.Host) + }) if err != nil { if retries--; retries > 0 { continue diff --git a/src/pkg/types/tenant.go b/src/pkg/types/tenant.go index dd462bb89..3bcfda597 100644 --- a/src/pkg/types/tenant.go +++ b/src/pkg/types/tenant.go @@ -2,13 +2,24 @@ package types type TenantName string +// Set implements pflag.Value. +func (t *TenantName) Set(s string) error { + *t = TenantName(s) + return nil +} + +// Type implements pflag.Value. +func (t *TenantName) Type() string { + return "name|id" +} + const ( - DEFAULT_TENANT TenantName = "" // the default tenant (GitHub login) + DEFAULT_TENANT TenantName = "" // the default tenant ) func (t TenantName) String() string { if t == DEFAULT_TENANT { - return "default" + return "" } return string(t) }