Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 6 additions & 0 deletions commands/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ func newListCommand(cnf *config.Config) *cobra.Command {
list.AddCommand(&appConfigValidateCommand)
}

routeLoopsCommand := innerRouteLoopsCommand(cnf)

if !list.DescribesNamespace() || list.Namespace == routeLoopsCommand.Name.Namespace {
list.AddCommand(&routeLoopsCommand)
}

appProjectConvertCommand := innerProjectConvertCommand(cnf)

if cnf.Service.ProjectConfigFlavor == "upsun" &&
Expand Down
1 change: 1 addition & 0 deletions commands/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ func newRootCommand(cnf *config.Config, assets *vendorization.VendorAssets) *cob
newHelpCommand(cnf),
newInitCommand(cnf, assets),
newListCommand(cnf),
newRouteLoopsCommand(cnf),
validateCmd,
versionCommand,
)
Expand Down
287 changes: 287 additions & 0 deletions commands/route_loops.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,287 @@
package commands

import (
"bytes"
"encoding/json"
"fmt"
"os"
"strings"

"github.com/spf13/cobra"
"github.com/spf13/viper"
orderedmap "github.com/wk8/go-ordered-map/v2"

"github.com/upsun/cli/internal/config"
"github.com/upsun/cli/internal/routeloops"
)

const routeLoopsHelp = "Detects redirect loops in a project's routes. By default this reads " +
".platform/routes.yaml or .upsun/*.yaml from the current directory. Pass --live " +
"to fetch the deployed routes via `route:list` instead (requires a deployed environment)."

func innerRouteLoopsCommand(cnf *config.Config) Command {
noInteractionOption := NoInteractionOption(cnf)

options := orderedmap.New[string, Option](orderedmap.WithInitialData[string, Option](
orderedmap.Pair[string, Option]{
Key: "file",
Value: Option{
Name: "--file",
Shortcut: "-f",
AcceptValue: true,
IsValueRequired: true,
Description: "Path to a routes.yaml or Upsun config file (with a top-level `routes:` key).",
Default: Any{any: ""},
},
},
orderedmap.Pair[string, Option]{
Key: "live",
Value: Option{
Name: "--live",
AcceptValue: false,
Description: "Fetch routes via `route:list` instead of reading the local repo (requires auth + a deployed environment).",
Default: Any{any: false},
},
},
orderedmap.Pair[string, Option]{
Key: "format",
Value: Option{
Name: "--format",
AcceptValue: true,
IsValueRequired: true,
Description: "Output format: text or json.",
Default: Any{any: "text"},
},
},
orderedmap.Pair[string, Option]{
Key: "no-fail",
Value: Option{
Name: "--no-fail",
AcceptValue: false,
Description: "Exit 0 even when redirect loops are found.",
Default: Any{any: false},
},
},
orderedmap.Pair[string, Option]{
Key: "route-project",
Value: Option{
Name: "--project",
Shortcut: "-p",
AcceptValue: true,
IsValueRequired: true,
Description: "The project ID (only used with --live).",
Default: Any{any: ""},
},
},
Comment thread
mattsinagra-upsun marked this conversation as resolved.
orderedmap.Pair[string, Option]{
Key: "route-environment",
Value: Option{
Name: "--environment",
Shortcut: "-e",
AcceptValue: true,
IsValueRequired: true,
Description: "The environment ID (only used with --live).",
Default: Any{any: ""},
},
},
Comment thread
mattsinagra-upsun marked this conversation as resolved.
orderedmap.Pair[string, Option]{
Key: HelpOption.GetName(),
Value: HelpOption,
},
orderedmap.Pair[string, Option]{
Key: VerboseOption.GetName(),
Value: VerboseOption,
},
orderedmap.Pair[string, Option]{
Key: VersionOption.GetName(),
Value: VersionOption,
},
orderedmap.Pair[string, Option]{
Key: YesOption.GetName(),
Value: YesOption,
},
orderedmap.Pair[string, Option]{
Key: noInteractionOption.GetName(),
Value: noInteractionOption,
},
))

return Command{
Name: CommandName{
Namespace: "route",
Command: "loops",
},
Usage: []string{
cnf.Application.Executable + " route:loops",
},
Description: "Detect redirect loops in a project's routes",
Help: CleanString(routeLoopsHelp),
Examples: []Example{
{
Commandline: "",
Description: "Check routes.yaml in the current project for redirect loops",
},
{
Commandline: "--live",
Description: "Check the deployed routes for the current environment",
},
{
Commandline: "--file /path/to/routes.yaml --format=json",
Description: "Check a specific file, output JSON",
},
},
Definition: Definition{
Arguments: &orderedmap.OrderedMap[string, Argument]{},
Options: options,
},
Hidden: false,
}
}

func newRouteLoopsCommand(cnf *config.Config) *cobra.Command {
cmd := &cobra.Command{
Use: "route:loops",
Short: "Detect redirect loops in a project's routes",
RunE: func(cmd *cobra.Command, _ []string) error {
return runRouteLoops(cnf, cmd)
},
}

cmd.Flags().StringP("file", "f", "", "Path to a routes.yaml or Upsun config file")
cmd.Flags().Bool("live", false, "Fetch routes via `route:list` instead of reading the local repo")
cmd.Flags().String("format", "text", "Output format: text or json")
cmd.Flags().Bool("no-fail", false, "Exit 0 even when loops are found")
cmd.Flags().StringP("route-project", "p", "", "The project ID (only used with --live)")
cmd.Flags().StringP("route-environment", "e", "", "The environment ID (only used with --live)")

// Bind each flag individually so we can use distinct viper keys and avoid
// clashing with any global --project/--environment on other commands.
for _, name := range []string{"file", "live", "format", "no-fail", "route-project", "route-environment"} {
_ = viper.BindPFlag("route-loops."+name, cmd.Flags().Lookup(name))
}

cmd.SetHelpFunc(func(_ *cobra.Command, _ []string) {
internalCmd := innerRouteLoopsCommand(cnf)
fmt.Println(internalCmd.HelpPage(cnf))
})
return cmd
}

type routeLoopsJSON struct {
Cycles [][]string `json:"cycles"`
Warnings []routeLoopsWarningOut `json:"warnings"`
Source string `json:"source"`
}

type routeLoopsWarningOut struct {
URL string `json:"url"`
Reason string `json:"reason"`
}

func runRouteLoops(cnf *config.Config, cmd *cobra.Command) error {
format := strings.ToLower(viper.GetString("route-loops.format"))
if format != "text" && format != "json" {
return fmt.Errorf("invalid --format %q: expected text or json", format)
}

routes, source, err := loadRoutes(cnf, cmd)
if err != nil {
return err
}

result := routeloops.Detect(routes)

if format == "json" {
out := routeLoopsJSON{
Cycles: make([][]string, 0, len(result.Cycles)),
Warnings: make([]routeLoopsWarningOut, 0, len(result.Warnings)),
Source: source,
}
for _, c := range result.Cycles {
out.Cycles = append(out.Cycles, c.URLs)
}
for _, w := range result.Warnings {
out.Warnings = append(out.Warnings, routeLoopsWarningOut{URL: w.URL, Reason: w.Reason})
}
b, err := json.MarshalIndent(out, "", " ")
if err != nil {
return err
}
fmt.Fprintln(cmd.OutOrStdout(), string(b))
} else {
printRouteLoopsText(cmd, result, source)
}

if len(result.Cycles) > 0 && !viper.GetBool("route-loops.no-fail") {
os.Exit(1)
}
return nil
}

func loadRoutes(cnf *config.Config, cmd *cobra.Command) ([]routeloops.Route, string, error) {
if viper.GetBool("route-loops.live") {
return loadLiveRoutes(cnf, cmd)
}
if path := viper.GetString("route-loops.file"); path != "" {
routes, err := routeloops.ParseFile(path)
if err != nil {
return nil, "", err
}
return routes, path, nil
}
cwd, err := os.Getwd()
if err != nil {
return nil, "", fmt.Errorf("could not get current working directory: %w", err)
}
return routeloops.DiscoverProjectRoutes(cwd)
}

func loadLiveRoutes(cnf *config.Config, cmd *cobra.Command) ([]routeloops.Route, string, error) {
args := []string{"route:list", "--format=csv", "--columns=route,type,to", "--no-header"}
if p := viper.GetString("route-loops.route-project"); p != "" {
args = append(args, "--project="+p)
}
if e := viper.GetString("route-loops.route-environment"); e != "" {
args = append(args, "--environment="+e)
}

var buf bytes.Buffer
wrapper := makeLegacyCLIWrapper(cnf, &buf, cmd.ErrOrStderr(), cmd.InOrStdin())
if err := wrapper.Exec(cmd.Context(), args...); err != nil {
return nil, "", fmt.Errorf("fetch routes via legacy CLI: %w", err)
}
routes, err := routeloops.ParseLiveCSV(buf.Bytes())
if err != nil {
return nil, "", err
}
return routes, "live (route:list)", nil
}

func printRouteLoopsText(cmd *cobra.Command, r routeloops.Result, source string) {
arrow := " -> "
if useUnicodeArrow() {
arrow = " → "
}
out := cmd.OutOrStdout()
if source != "" {
fmt.Fprintf(out, "Source: %s\n", source)
}
if len(r.Cycles) == 0 {
fmt.Fprintln(out, "No redirect loops detected.")
} else {
fmt.Fprintf(out, "Detected %d redirect loop(s):\n", len(r.Cycles))
for _, c := range r.Cycles {
fmt.Fprintln(out, " Loop: "+strings.Join(c.URLs, arrow))
}
}
for _, w := range r.Warnings {
fmt.Fprintf(out, "Warning: %s: %s\n", w.URL, w.Reason)
}
}

func useUnicodeArrow() bool {
if os.Getenv("NO_COLOR") != "" {
return false
}
return true
}
Loading