diff --git a/.golangci.yml b/.golangci.yml index edd9818..80b94c3 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -2,6 +2,7 @@ linters: disable: - goimports - gochecknoglobals + - gosec - prealloc enable-all: true diff --git a/go.mod b/go.mod index 2fd373d..a00833f 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/docker/docker v1.13.1 github.com/docker/go-connections v0.4.0 // indirect github.com/docker/go-units v0.4.0 // indirect - github.com/guumaster/tablewriter v0.0.6 + github.com/guumaster/tablewriter v0.0.9 github.com/mattn/go-runewidth v0.0.9 // indirect github.com/opencontainers/go-digest v1.0.0-rc1 // indirect github.com/pkg/errors v0.9.1 // indirect diff --git a/go.sum b/go.sum index 361c10c..6ae7c55 100644 --- a/go.sum +++ b/go.sum @@ -49,8 +49,10 @@ github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoA github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= -github.com/guumaster/tablewriter v0.0.6 h1:s2n2YeOJn8dmH9RvrHPwxVOrZjPdwzWJhT5tzJJqnUk= -github.com/guumaster/tablewriter v0.0.6/go.mod h1:9B1xy1BLPtcVAeYjC1EXPxcklqnzk7dU2c3ywGbUnKY= +github.com/guumaster/tablewriter v0.0.8 h1:bQGbe9Tx+i2CyX1+LwqBtrP/6gUowNbrnUUvyikuq1I= +github.com/guumaster/tablewriter v0.0.8/go.mod h1:9B1xy1BLPtcVAeYjC1EXPxcklqnzk7dU2c3ywGbUnKY= +github.com/guumaster/tablewriter v0.0.9 h1:qyswXhSCI1SWYH78MLApi8AfL8JsWZWAUkZLONNMiYI= +github.com/guumaster/tablewriter v0.0.9/go.mod h1:9B1xy1BLPtcVAeYjC1EXPxcklqnzk7dU2c3ywGbUnKY= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= diff --git a/pkg/cmd/add_domains.go b/pkg/cmd/add_domains.go index 54519c3..23100b7 100644 --- a/pkg/cmd/add_domains.go +++ b/pkg/cmd/add_domains.go @@ -6,7 +6,7 @@ import ( "github.com/spf13/cobra" - "github.com/guumaster/hostctl/pkg/host" + "github.com/guumaster/hostctl/pkg/host/file" ) func newAddRemoveDomainsCmd() (*cobra.Command, *cobra.Command) { @@ -25,7 +25,7 @@ If the profile already exists it will be added to it.`, name := args[0] routes := args[1:] - h, err := host.NewFile(src) + h, err := file.NewFile(src) if err != nil { return err } diff --git a/pkg/cmd/add_replace.go b/pkg/cmd/add_replace.go index 578b2d9..bc9b49b 100644 --- a/pkg/cmd/add_replace.go +++ b/pkg/cmd/add_replace.go @@ -6,10 +6,12 @@ import ( "github.com/spf13/cobra" - "github.com/guumaster/hostctl/pkg/host" + "github.com/guumaster/hostctl/pkg/host/file" + "github.com/guumaster/hostctl/pkg/host/profile" + "github.com/guumaster/hostctl/pkg/host/types" ) -type addRemoveFn func(h *host.File, p *host.Profile) error +type addRemoveFn func(h *file.File, p *types.Profile) error func newAddRemoveCmd() (*cobra.Command, *cobra.Command) { addCmd := newAddCmd() @@ -30,8 +32,8 @@ func newAddCmd() *cobra.Command { Reads from a file and set content to a profile in your hosts file. If the profile already exists it will be added to it.`, Args: commonCheckArgs, - RunE: makeAddReplace(func(h *host.File, p *host.Profile) error { - return h.AddProfile(*p) + RunE: makeAddReplace(func(h *file.File, p *types.Profile) error { + return h.AddProfile(p) }), } } @@ -45,8 +47,8 @@ Reads from a file and set content to a profile in your hosts file. If the profile already exists it will be overwritten. `, Args: commonCheckArgs, - RunE: makeAddReplace(func(h *host.File, p *host.Profile) error { - return h.ReplaceProfile(*p) + RunE: makeAddReplace(func(h *file.File, p *types.Profile) error { + return h.ReplaceProfile(p) }), PostRunE: postRunListOnly, } @@ -54,37 +56,23 @@ If the profile already exists it will be overwritten. func makeAddReplace(actionFn addRemoveFn) func(cmd *cobra.Command, profiles []string) error { return func(cmd *cobra.Command, profiles []string) error { - var ( - r io.Reader - err error - ) - src, _ := cmd.Flags().GetString("host-file") from, _ := cmd.Flags().GetString("from") uniq, _ := cmd.Flags().GetBool("uniq") in := cmd.InOrStdin() - if isPiped() || in != os.Stdin { - r = in - } else { - r, err = os.Open(from) - if err != nil { - return err - } - } - - p, err := host.NewProfileFromReader(r, uniq) + p, err := getProfileFromInput(in, from, uniq) if err != nil { return err } - h, err := host.NewFile(src) + h, err := file.NewFile(src) if err != nil { return err } p.Name = profiles[0] - p.Status = host.Enabled + p.Status = types.Enabled err = actionFn(h, p) if err != nil { @@ -94,3 +82,27 @@ func makeAddReplace(actionFn addRemoveFn) func(cmd *cobra.Command, profiles []st return h.Flush() } } + +func getProfileFromInput(in io.Reader, from string, uniq bool) (*types.Profile, error) { + var ( + r io.Reader + err error + ) + + switch { + case isPiped() || in != os.Stdin: + r = in + + case isValidURL(from): + r, err = readerFromURL(from) + + default: + r, err = os.Open(from) + } + + if err != nil { + return nil, err + } + + return profile.NewProfileFromReader(r, uniq) +} diff --git a/pkg/cmd/backup.go b/pkg/cmd/backup.go index 1793456..8c041fe 100644 --- a/pkg/cmd/backup.go +++ b/pkg/cmd/backup.go @@ -5,7 +5,7 @@ import ( "github.com/spf13/cobra" - "github.com/guumaster/hostctl/pkg/host" + "github.com/guumaster/hostctl/pkg/host/file" ) func newBackupCmd() *cobra.Command { @@ -22,7 +22,7 @@ as extension. dst, _ := cmd.Flags().GetString("path") quiet, _ := cmd.Flags().GetBool("quiet") - h, err := host.NewFile(src) + h, err := file.NewFile(src) if err != nil { return err } diff --git a/pkg/cmd/enable_disable.go b/pkg/cmd/enable_disable.go index 5ca7831..8beac31 100644 --- a/pkg/cmd/enable_disable.go +++ b/pkg/cmd/enable_disable.go @@ -3,32 +3,10 @@ package cmd import ( "github.com/spf13/cobra" - "github.com/guumaster/hostctl/pkg/host" + "github.com/guumaster/hostctl/pkg/host/file" ) -type enableDisableFn func(h *host.File, profiles []string, only, all bool) error - -var enableActionFn = func(h *host.File, profiles []string, only, all bool) error { - switch { - case only: - return h.EnableOnly(profiles) - case all: - return h.EnableAll() - default: - return h.Enable(profiles) - } -} - -var disableActionFn = func(h *host.File, profiles []string, only, all bool) error { - switch { - case only: - return h.DisableOnly(profiles) - case all: - return h.DisableAll() - default: - return h.Disable(profiles) - } -} +type enableDisableFn func(h *file.File, profiles []string, only, all bool) error func newEnableDisableCmd() (*cobra.Command, *cobra.Command) { enableCmd := &cobra.Command{ @@ -39,7 +17,16 @@ Enables an existing profile. It will be listed as "on" while it is enabled. `, Args: commonCheckArgsWithAll, - RunE: makeEnableDisable(enableActionFn), + RunE: makeEnableDisable(func(h *file.File, profiles []string, only, all bool) error { + switch { + case only: + return h.EnableOnly(profiles) + case all: + return h.EnableAll() + default: + return h.Enable(profiles) + } + }), } disableCmd := &cobra.Command{ @@ -50,7 +37,16 @@ Disable a profile from your hosts file without removing it. It will be listed as "off" while it is disabled. `, Args: commonCheckArgsWithAll, - RunE: makeEnableDisable(disableActionFn), + RunE: makeEnableDisable(func(h *file.File, profiles []string, only, all bool) error { + switch { + case only: + return h.DisableOnly(profiles) + case all: + return h.DisableAll() + default: + return h.Disable(profiles) + } + }), } enableCmd.PostRunE = func(cmd *cobra.Command, args []string) error { @@ -70,7 +66,7 @@ func makeEnableDisable(actionFn enableDisableFn) func(cmd *cobra.Command, profil only, _ := cmd.Flags().GetBool("only") all, _ := cmd.Flags().GetBool("all") - h, err := host.NewFile(src) + h, err := file.NewFile(src) if err != nil { return err } diff --git a/pkg/cmd/enable_disable_test.go b/pkg/cmd/enable_disable_test.go index ed7fa7f..bb0d899 100644 --- a/pkg/cmd/enable_disable_test.go +++ b/pkg/cmd/enable_disable_test.go @@ -8,7 +8,7 @@ import ( "github.com/stretchr/testify/assert" - "github.com/guumaster/hostctl/pkg/host" + "github.com/guumaster/hostctl/pkg/host/errors" ) func Test_Disable(t *testing.T) { @@ -48,7 +48,7 @@ func Test_Disable(t *testing.T) { cmd.SetArgs([]string{"disable", "unknown", "--host-file", tmp.Name()}) err := cmd.Execute() - assert.EqualError(t, err, host.ErrUnknownProfile.Error()) + assert.EqualError(t, err, errors.ErrUnknownProfile.Error()) }) t.Run("Disable Only", func(t *testing.T) { @@ -209,7 +209,7 @@ func Test_Enable(t *testing.T) { cmd.SetArgs([]string{"enable", "unknown", "--host-file", tmp.Name()}) err := cmd.Execute() - assert.EqualError(t, err, host.ErrUnknownProfile.Error()) + assert.EqualError(t, err, errors.ErrUnknownProfile.Error()) }) t.Run("Enable Only", func(t *testing.T) { diff --git a/pkg/cmd/helpers.go b/pkg/cmd/helpers.go index 9c2a0f3..51d5636 100644 --- a/pkg/cmd/helpers.go +++ b/pkg/cmd/helpers.go @@ -1,17 +1,23 @@ package cmd import ( + "bytes" + "io" + "io/ioutil" + "net/http" + "net/url" "os" "runtime" "github.com/spf13/cobra" - "github.com/guumaster/hostctl/pkg/host" + "github.com/guumaster/hostctl/pkg/host/errors" + "github.com/guumaster/hostctl/pkg/host/render" ) func commonCheckProfileOnly(cmd *cobra.Command, args []string) error { if len(args) == 0 { - return host.ErrMissingProfile + return errors.ErrMissingProfile } if err := containsDefault(args); err != nil { @@ -28,7 +34,7 @@ func commonCheckArgsWithAll(cmd *cobra.Command, args []string) error { } if !all && len(args) == 0 { - return host.ErrMissingProfile + return errors.ErrMissingProfile } if err := containsDefault(args); err != nil { @@ -40,7 +46,7 @@ func commonCheckArgsWithAll(cmd *cobra.Command, args []string) error { func commonCheckArgs(_ *cobra.Command, args []string) error { if len(args) == 0 { - return host.ErrMissingProfile + return errors.ErrMissingProfile } else if len(args) > 1 { return ErrMultipleProfiles } @@ -67,7 +73,7 @@ func isPiped() bool { func containsDefault(args []string) error { for _, p := range args { if p == "default" { - return host.ErrDefaultProfileError + return errors.ErrDefaultProfileError } } @@ -102,9 +108,71 @@ func checkSnapRestrictions(cmd *cobra.Command, isSnap bool) error { return nil } - if from != "" || src != defaultSrc { - return host.ErrSnapConfinement + if from != "" || src != defaultSrc && !isValidURL(from) { + return errors.ErrSnapConfinement } return nil } + +// isValidURL tests a string to determine if it is a well-structured url or not. +func isValidURL(s string) bool { + _, err := url.ParseRequestURI(s) + if err != nil { + return false + } + + u, err := url.Parse(s) + if err != nil || u.Scheme == "" || u.Host == "" { + return false + } + + return true +} + +func readerFromURL(url string) (io.Reader, error) { + resp, err := http.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + b, err := ioutil.ReadAll(resp.Body) + + return bytes.NewReader(b), err +} + +func getRenderer(cmd *cobra.Command, opts *render.TableRendererOptions) render.Renderer { + raw, _ := cmd.Flags().GetBool("raw") + out, _ := cmd.Flags().GetString("out") + cols, _ := cmd.Flags().GetStringSlice("column") + + if opts == nil { + opts = &render.TableRendererOptions{} + } + + if len(opts.Columns) == 0 { + opts.Columns = cols + } + + if opts.Writer == nil { + opts.Writer = cmd.OutOrStdout() + } + + switch { + case raw || out == "raw": + return render.NewRawRenderer(opts) + + case out == "md" || out == "markdown": + return render.NewMarkdownRenderer(opts) + + case out == "json": + return render.NewJSONRenderer(&render.JSONRendererOptions{ + Writer: cmd.OutOrStdout(), + Columns: cols, + }) + + default: + return render.NewTableRenderer(opts) + } +} diff --git a/pkg/cmd/list.go b/pkg/cmd/list.go index 1fcd6c8..46d3a23 100644 --- a/pkg/cmd/list.go +++ b/pkg/cmd/list.go @@ -5,7 +5,8 @@ import ( "github.com/spf13/cobra" - "github.com/guumaster/hostctl/pkg/host" + "github.com/guumaster/hostctl/pkg/host/file" + "github.com/guumaster/hostctl/pkg/host/types" ) func newListCmd() *cobra.Command { @@ -20,38 +21,35 @@ The "default" profile is all the content that is not handled by hostctl tool. `, RunE: func(cmd *cobra.Command, profiles []string) error { src, _ := cmd.Flags().GetString("host-file") - raw, _ := cmd.Flags().GetBool("raw") - cols, _ := cmd.Flags().GetStringSlice("column") - h, err := host.NewFile(src) + h, err := file.NewFile(src) if err != nil { return err } - h.List(&host.ListOptions{ - Writer: cmd.OutOrStdout(), + r := getRenderer(cmd, nil) + + h.List(r, &file.ListOptions{ Profiles: profiles, - RawTable: raw, - Columns: cols, }) return nil }, } - listCmd.AddCommand(makeListStatusCmd(host.Enabled)) - listCmd.AddCommand(makeListStatusCmd(host.Disabled)) + listCmd.AddCommand(makeListStatusCmd(types.Enabled)) + listCmd.AddCommand(makeListStatusCmd(types.Disabled)) return listCmd } -var makeListStatusCmd = func(status host.ProfileStatus) *cobra.Command { +var makeListStatusCmd = func(status types.Status) *cobra.Command { cmd := "" alias := "" switch status { - case host.Enabled: + case types.Enabled: cmd = "enabled" alias = "on" - case host.Disabled: + case types.Disabled: cmd = "disabled" alias = "off" } @@ -62,20 +60,18 @@ var makeListStatusCmd = func(status host.ProfileStatus) *cobra.Command { Long: fmt.Sprintf(` Shows a detailed list of %s profiles on your hosts file with name, ip and host name. `, cmd), - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(cmd *cobra.Command, profiles []string) error { src, _ := cmd.Flags().GetString("host-file") - raw, _ := cmd.Flags().GetBool("raw") - cols, _ := cmd.Flags().GetStringSlice("column") - h, err := host.NewFile(src) + h, err := file.NewFile(src) if err != nil { return err } - h.List(&host.ListOptions{ - Writer: cmd.OutOrStdout(), - RawTable: raw, - Columns: cols, + r := getRenderer(cmd, nil) + + h.List(r, &file.ListOptions{ + Profiles: profiles, StatusFilter: status, }) diff --git a/pkg/cmd/remove.go b/pkg/cmd/remove.go index 309cf8d..fb20578 100644 --- a/pkg/cmd/remove.go +++ b/pkg/cmd/remove.go @@ -6,7 +6,7 @@ import ( "github.com/spf13/cobra" - "github.com/guumaster/hostctl/pkg/host" + "github.com/guumaster/hostctl/pkg/host/file" ) func newRemoveCmd() *cobra.Command { @@ -27,7 +27,7 @@ use 'hosts disable' instead. quiet, _ := cmd.Flags().GetBool("quiet") all, _ := cmd.Flags().GetBool("all") - h, err := host.NewFile(src) + h, err := file.NewFile(src) if err != nil { return err } diff --git a/pkg/cmd/remove_domains.go b/pkg/cmd/remove_domains.go index 43ad637..ad9f73c 100644 --- a/pkg/cmd/remove_domains.go +++ b/pkg/cmd/remove_domains.go @@ -6,7 +6,7 @@ import ( "github.com/spf13/cobra" - "github.com/guumaster/hostctl/pkg/host" + "github.com/guumaster/hostctl/pkg/host/file" ) func newRemoveDomainsCmd() *cobra.Command { @@ -26,7 +26,7 @@ It cannot be undone unless you have a backup and restore it. name := args[0] domains := args[1:] - h, err := host.NewFile(src) + h, err := file.NewFile(src) if err != nil { return err } diff --git a/pkg/cmd/remove_domains_test.go b/pkg/cmd/remove_domains_test.go index a53b000..88f3091 100644 --- a/pkg/cmd/remove_domains_test.go +++ b/pkg/cmd/remove_domains_test.go @@ -52,12 +52,7 @@ func Test_RemoveDomains(t *testing.T) { assert.NoError(t, err) actual := "\n" + string(out) - const expected = ` -+---------+--------+----+--------+ -| PROFILE | STATUS | IP | DOMAIN | -+---------+--------+----+--------+ -+---------+--------+----+--------+ -` - assert.Contains(t, actual, expected) + + assert.Contains(t, actual, `Profile 'profile1' removed.`) }) } diff --git a/pkg/cmd/remove_test.go b/pkg/cmd/remove_test.go index eeb7fcc..1ad1e2f 100644 --- a/pkg/cmd/remove_test.go +++ b/pkg/cmd/remove_test.go @@ -8,7 +8,7 @@ import ( "github.com/stretchr/testify/assert" - "github.com/guumaster/hostctl/pkg/host" + "github.com/guumaster/hostctl/pkg/host/errors" ) func Test_Remove(t *testing.T) { @@ -30,7 +30,7 @@ func Test_Remove(t *testing.T) { actual := "\n" + string(out) expected := listHeader - assert.NotContains(t, actual, expected) + assert.NotContains(t, expected, actual) }) t.Run("Remove multiple", func(t *testing.T) { @@ -62,7 +62,7 @@ func Test_Remove(t *testing.T) { cmd.SetArgs([]string{"remove", "unknown", "--host-file", tmp.Name()}) err := cmd.Execute() - assert.EqualError(t, err, host.ErrUnknownProfile.Error()) + assert.EqualError(t, err, errors.ErrUnknownProfile.Error()) }) t.Run("Remove all", func(t *testing.T) { diff --git a/pkg/cmd/restore.go b/pkg/cmd/restore.go index cae5503..77d0c21 100644 --- a/pkg/cmd/restore.go +++ b/pkg/cmd/restore.go @@ -5,7 +5,7 @@ import ( "github.com/spf13/cobra" - "github.com/guumaster/hostctl/pkg/host" + "github.com/guumaster/hostctl/pkg/host/file" ) func newRestoreCmd() *cobra.Command { @@ -23,7 +23,7 @@ WARNING: the complete hosts file will be overwritten with the backup data. from, _ := cmd.Flags().GetString("from") quiet, _ := cmd.Flags().GetBool("quiet") - h, err := host.NewFile(dst) + h, err := file.NewFile(dst) if err != nil { return err } diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go index 9f52a83..6843681 100644 --- a/pkg/cmd/root.go +++ b/pkg/cmd/root.go @@ -36,10 +36,12 @@ you need each time with a simple interface. return err } host, _ := cmd.Flags().GetString("host-file") - quiet, _ := cmd.Flags().GetBool("quiet") - defaultHostsFile := getDefaultHostFile(isSnapBuild) - if (host != defaultHostsFile || os.Getenv("HOSTCTL_FILE") != "") && !quiet { + showHostFile := host != getDefaultHostFile(isSnapBuild) || os.Getenv("HOSTCTL_FILE") != "" + + quiet := needsQuietOutput(cmd) + + if showHostFile && !quiet { _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Using hosts file: %s\n", host) } @@ -54,7 +56,8 @@ you need each time with a simple interface. // rootCmd rootCmd.PersistentFlags().String("host-file", getDefaultHostFile(isSnapBuild), "Hosts file path") rootCmd.PersistentFlags().BoolP("quiet", "q", false, "Run command without output") - rootCmd.PersistentFlags().Bool("raw", false, "Output without table borders") + rootCmd.PersistentFlags().Bool("raw", false, "Output without borders (same as -o raw)") + rootCmd.PersistentFlags().StringP("out", "o", "table", "Output type (table|raw|markdown|json)") rootCmd.PersistentFlags().StringSliceP("column", "c", nil, "Columns to show on lists") registerCommands(rootCmd) @@ -62,6 +65,27 @@ you need each time with a simple interface. return rootCmd } +func needsQuietOutput(cmd *cobra.Command) bool { + quiet, _ := cmd.Flags().GetBool("quiet") + out, _ := cmd.Flags().GetString("out") + + if quiet { + return true + } + + switch { + case quiet: + return true + case out == "json": + return true + case out == "md" || out == "markdown": + return false + + default: + return false + } +} + func registerCommands(rootCmd *cobra.Command) { cwd, _ := os.Getwd() @@ -138,7 +162,6 @@ func registerCommands(rootCmd *cobra.Command) { // status statusCmd := newStatusCmd() - statusCmd.Flags().Bool("raw", false, "Output without table borders") // register sub-commands addCmd.AddCommand(addDomainsCmd) diff --git a/pkg/cmd/status.go b/pkg/cmd/status.go index b70547c..705c3b9 100644 --- a/pkg/cmd/status.go +++ b/pkg/cmd/status.go @@ -3,7 +3,8 @@ package cmd import ( "github.com/spf13/cobra" - "github.com/guumaster/hostctl/pkg/host" + "github.com/guumaster/hostctl/pkg/host/file" + "github.com/guumaster/hostctl/pkg/host/render" ) func newStatusCmd() *cobra.Command { @@ -15,21 +16,20 @@ Shows a list of unique profile names on your hosts file with its status. The "default" profile is always on and will be skipped. `, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(cmd *cobra.Command, profiles []string) error { src, _ := cmd.Flags().GetString("host-file") - raw, _ := cmd.Flags().GetBool("raw") - h, err := host.NewFile(src) + h, err := file.NewFile(src) if err != nil { return err } - h.ProfileStatus(&host.ListOptions{ - Writer: cmd.OutOrStdout(), - Profiles: args, - RawTable: raw, + r := getRenderer(cmd, &render.TableRendererOptions{ + Columns: render.ProfilesOnlyColumns, }) + h.ProfileStatus(r, profiles) + return err }, } diff --git a/pkg/cmd/sync_docker.go b/pkg/cmd/sync_docker.go index e0489dd..0f8daab 100644 --- a/pkg/cmd/sync_docker.go +++ b/pkg/cmd/sync_docker.go @@ -5,7 +5,9 @@ import ( "github.com/spf13/cobra" - "github.com/guumaster/hostctl/pkg/host" + "github.com/guumaster/hostctl/pkg/host/docker" + "github.com/guumaster/hostctl/pkg/host/file" + "github.com/guumaster/hostctl/pkg/host/types" ) func newSyncDockerCmd(removeCmd *cobra.Command) *cobra.Command { @@ -23,7 +25,7 @@ Reads from Docker the list of containers and add names and IPs to a profile in y ctx := context.Background() - p, err := host.NewProfileFromDocker(ctx, &host.DockerOptions{ + p, err := docker.NewProfileFromDocker(ctx, &docker.Options{ Domain: domain, Network: network, Cli: nil, @@ -32,15 +34,15 @@ Reads from Docker the list of containers and add names and IPs to a profile in y return err } - h, err := host.NewFile(src) + h, err := file.NewFile(src) if err != nil { return err } p.Name = profiles[0] - p.Status = host.Enabled + p.Status = types.Enabled - err = h.AddProfile(*p) + err = h.AddProfile(p) if err != nil { return err } diff --git a/pkg/cmd/sync_docker_compose.go b/pkg/cmd/sync_docker_compose.go index a8fbc17..93386e5 100644 --- a/pkg/cmd/sync_docker_compose.go +++ b/pkg/cmd/sync_docker_compose.go @@ -10,7 +10,10 @@ import ( "github.com/spf13/cobra" - "github.com/guumaster/hostctl/pkg/host" + "github.com/guumaster/hostctl/pkg/host/docker" + "github.com/guumaster/hostctl/pkg/host/errors" + "github.com/guumaster/hostctl/pkg/host/file" + "github.com/guumaster/hostctl/pkg/host/types" ) type composeInfo struct { @@ -26,10 +29,10 @@ func newSyncDockerComposeCmd(removeCmd *cobra.Command) *cobra.Command { Reads from a docker-compose.yml file the list of containers and add names and IPs to a profile in your hosts file. `, PreRunE: func(cmd *cobra.Command, args []string) error { - profile, _ := cmd.Flags().GetString("profile") + name, _ := cmd.Flags().GetString("profile") - if profile == "default" { - return host.ErrDefaultProfileError + if name == "default" { + return errors.ErrDefaultProfileError } return nil }, @@ -45,25 +48,25 @@ Reads from a docker-compose.yml file the list of containers and add names and I return err } - profile := profiles[0] + name := profiles[0] - if profile == "" && compose.ProjectName == "" { - return host.ErrMissingProfile + if name == "" && compose.ProjectName == "" { + return errors.ErrMissingProfile } - if profile == "" { - profile = compose.ProjectName - profiles = append(profiles, profile) + if name == "" { + name = compose.ProjectName + profiles = append(profiles, name) cmd.SetArgs(profiles) } if domain == "" { - domain = fmt.Sprintf("%s.loc", profile) + domain = fmt.Sprintf("%s.loc", name) } ctx := context.Background() - p, err := host.NewProfileFromDocker(ctx, &host.DockerOptions{ + p, err := docker.NewProfileFromDocker(ctx, &docker.Options{ Domain: domain, Network: network, ComposeFile: compose.File, @@ -75,15 +78,15 @@ Reads from a docker-compose.yml file the list of containers and add names and I return err } - h, err := host.NewFile(src) + h, err := file.NewFile(src) if err != nil { return err } - p.Name = profile - p.Status = host.Enabled + p.Name = name + p.Status = types.Enabled - err = h.AddProfile(*p) + err = h.AddProfile(p) if err != nil { return err } diff --git a/pkg/cmd/toggle.go b/pkg/cmd/toggle.go index 5a6a218..f5e51fa 100644 --- a/pkg/cmd/toggle.go +++ b/pkg/cmd/toggle.go @@ -3,7 +3,7 @@ package cmd import ( "github.com/spf13/cobra" - "github.com/guumaster/hostctl/pkg/host" + "github.com/guumaster/hostctl/pkg/host/file" ) func newToggleCmd() *cobra.Command { @@ -17,7 +17,7 @@ Alternates between on/off status of an existing profile. RunE: func(cmd *cobra.Command, profiles []string) error { src, _ := cmd.Flags().GetString("host-file") - h, err := host.NewFile(src) + h, err := file.NewFile(src) if err != nil { return err } diff --git a/pkg/cmd/toggle_test.go b/pkg/cmd/toggle_test.go index af50bdb..f872690 100644 --- a/pkg/cmd/toggle_test.go +++ b/pkg/cmd/toggle_test.go @@ -8,7 +8,7 @@ import ( "github.com/stretchr/testify/assert" - "github.com/guumaster/hostctl/pkg/host" + "github.com/guumaster/hostctl/pkg/host/errors" ) func Test_Toggle(t *testing.T) { @@ -48,6 +48,6 @@ func Test_Toggle(t *testing.T) { cmd.SetArgs([]string{"toggle", "unknown", "--host-file", tmp.Name()}) err := cmd.Execute() - assert.EqualError(t, err, host.ErrUnknownProfile.Error()) + assert.EqualError(t, err, errors.ErrUnknownProfile.Error()) }) } diff --git a/pkg/host/add.go b/pkg/host/add.go deleted file mode 100644 index 0039b43..0000000 --- a/pkg/host/add.go +++ /dev/null @@ -1,17 +0,0 @@ -package host - -// AddProfile adds a profile to the list -func (f *File) AddProfile(profile Profile) error { - if profile.Name == Default { - return ErrDefaultProfileError - } - - f.MergeProfiles(&Content{ - ProfileNames: []string{profile.Name}, - Profiles: map[string]*Profile{ - profile.Name: &profile, - }, - }) - - return nil -} diff --git a/pkg/host/docker.go b/pkg/host/docker/docker.go similarity index 84% rename from pkg/host/docker.go rename to pkg/host/docker/docker.go index 075a647..9808974 100644 --- a/pkg/host/docker.go +++ b/pkg/host/docker/docker.go @@ -1,4 +1,4 @@ -package host +package docker import ( "context" @@ -8,6 +8,8 @@ import ( "github.com/docker/docker/api/types" "gopkg.in/yaml.v2" + + "github.com/guumaster/hostctl/pkg/host/errors" ) type composeData struct { @@ -50,7 +52,7 @@ func parseComposeFile(file, projectName string) ([]string, error) { return containers, nil } -func getNetworkID(ctx context.Context, opts *DockerOptions) (string, error) { +func getNetworkID(ctx context.Context, opts *Options) (string, error) { var networkID string if opts == nil || opts.Network == "" { @@ -72,7 +74,7 @@ func getNetworkID(ctx context.Context, opts *DockerOptions) (string, error) { } if networkID == "" { - return "", fmt.Errorf("%w: '%s'", ErrUnknownNetworkID, opts.Network) + return "", fmt.Errorf("%w: '%s'", errors.ErrUnknownNetworkID, opts.Network) } return networkID, nil diff --git a/pkg/host/profile_docker.go b/pkg/host/docker/profile_docker.go similarity index 82% rename from pkg/host/profile_docker.go rename to pkg/host/docker/profile_docker.go index 72a2100..897ad64 100644 --- a/pkg/host/profile_docker.go +++ b/pkg/host/docker/profile_docker.go @@ -1,4 +1,4 @@ -package host +package docker import ( "context" @@ -9,10 +9,12 @@ import ( "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/filters" "github.com/docker/docker/client" + + types2 "github.com/guumaster/hostctl/pkg/host/types" ) // DockerOptions contains parameters to sync with docker and docker-compose -type DockerOptions struct { +type Options struct { Domain string Network string ComposeFile string @@ -21,7 +23,7 @@ type DockerOptions struct { Cli *client.Client } -func containerList(ctx context.Context, opts *DockerOptions) ([]types.Container, error) { +func containerList(ctx context.Context, opts *Options) ([]types.Container, error) { var err error cli := opts.Cli @@ -51,7 +53,7 @@ func containerList(ctx context.Context, opts *DockerOptions) ([]types.Container, } // NewProfileFromDocker creates a new profile from docker info -func NewProfileFromDocker(ctx context.Context, opts *DockerOptions) (*Profile, error) { +func NewProfileFromDocker(ctx context.Context, opts *Options) (*types2.Profile, error) { containers, err := containerList(ctx, opts) if err != nil { return nil, err @@ -65,19 +67,19 @@ func NewProfileFromDocker(ctx context.Context, opts *DockerOptions) (*Profile, e } } - profile := &Profile{ - Routes: map[string]*Route{}, + p := &types2.Profile{ + Routes: map[string]*types2.Route{}, } - return addToProfile(ctx, profile, containers, composeServices, opts) + return addToProfile(ctx, p, containers, composeServices, opts) } func addToProfile( ctx context.Context, - profile *Profile, + profile *types2.Profile, containers []types.Container, composeServices []string, - opts *DockerOptions) (*Profile, error) { + opts *Options) (*types2.Profile, error) { networkID, err := getNetworkID(ctx, opts) if err != nil { return nil, err diff --git a/pkg/host/errors.go b/pkg/host/errors/errors.go similarity index 98% rename from pkg/host/errors.go rename to pkg/host/errors/errors.go index 937a5e9..7fdd516 100644 --- a/pkg/host/errors.go +++ b/pkg/host/errors/errors.go @@ -1,4 +1,4 @@ -package host +package errors import ( "errors" diff --git a/pkg/host/file/add.go b/pkg/host/file/add.go new file mode 100644 index 0000000..33aa06f --- /dev/null +++ b/pkg/host/file/add.go @@ -0,0 +1,22 @@ +package file + +import ( + "github.com/guumaster/hostctl/pkg/host/errors" + "github.com/guumaster/hostctl/pkg/host/types" +) + +// AddProfile adds a profile to the list +func (f *File) AddProfile(p *types.Profile) error { + if p.Name == types.Default { + return errors.ErrDefaultProfileError + } + + f.MergeProfiles(&types.Content{ + ProfileNames: []string{p.Name}, + Profiles: map[string]*types.Profile{ + p.Name: p, + }, + }) + + return nil +} diff --git a/pkg/host/add_test.go b/pkg/host/file/add_test.go similarity index 73% rename from pkg/host/add_test.go rename to pkg/host/file/add_test.go index e2add4d..3f4e739 100644 --- a/pkg/host/add_test.go +++ b/pkg/host/file/add_test.go @@ -1,14 +1,17 @@ -package host +package file import ( "strings" "testing" "github.com/stretchr/testify/assert" + + "github.com/guumaster/hostctl/pkg/host/profile" + "github.com/guumaster/hostctl/pkg/host/types" ) func TestFile_AddProfile(t *testing.T) { - mem := createBasicFS(t) + mem := CreateBasicFS(t) f, err := mem.Open("/tmp/etc/hosts") assert.NoError(t, err) @@ -17,12 +20,12 @@ func TestFile_AddProfile(t *testing.T) { assert.NoError(t, err) r := strings.NewReader(`127.0.0.1 added.loc`) - p, err := NewProfileFromReader(r, true) + p, err := profile.NewProfileFromReader(r, true) assert.NoError(t, err) p.Name = "awesome" - p.Status = Enabled + p.Status = types.Enabled - err = m.AddProfile(*p) + err = m.AddProfile(p) assert.NoError(t, err) assert.Equal(t, []string{"profile1", "awesome"}, m.GetEnabled()) @@ -38,18 +41,18 @@ func TestFile_AddProfile(t *testing.T) { assert.NoError(t, err) r := strings.NewReader(`127.0.0.1 added.loc`) - p, err := NewProfileFromReader(r, true) + p, err := profile.NewProfileFromReader(r, true) assert.NoError(t, err) p.Name = "profile1" - err = m.AddProfile(*p) + err = m.AddProfile(p) assert.NoError(t, err) assert.Equal(t, []string{"profile1"}, m.GetEnabled()) added, err := m.GetProfile("profile1") assert.NoError(t, err) - hosts, err := added.GetHostNames(localhost.String()) + hosts, err := added.GetHostNames(Localhost.String()) assert.NoError(t, err) assert.Equal(t, hosts, []string{"first.loc", "second.loc", "added.loc"}) diff --git a/pkg/host/enable_disable.go b/pkg/host/file/enable_disable.go similarity index 51% rename from pkg/host/enable_disable.go rename to pkg/host/file/enable_disable.go index 665c837..1aaf15b 100644 --- a/pkg/host/enable_disable.go +++ b/pkg/host/file/enable_disable.go @@ -1,82 +1,87 @@ -package host +package file + +import ( + "github.com/guumaster/hostctl/pkg/host/errors" + "github.com/guumaster/hostctl/pkg/host/types" +) // Enable marks profiles as enable by uncommenting all hosts lines // making the routing work again. func (f *File) Enable(profiles []string) error { - return f.changeTo(profiles, Enabled) + return f.changeTo(profiles, types.Enabled) } // Disable marks profiles as disable by commenting all hosts lines. // The content remains on the file and can be enabled later. func (f *File) Disable(profiles []string) error { - return f.changeTo(profiles, Disabled) + return f.changeTo(profiles, types.Disabled) } // EnableAll marks all profiles as enable by uncommenting all hosts lines // making the routing work again. func (f *File) EnableAll() error { - return f.changeTo(f.GetProfileNames(), Enabled) + return f.changeTo(f.GetProfileNames(), types.Enabled) } // DisableAll marks all profiles as disable by commenting all hosts lines. // The content remains on the file and can be enabled later. func (f *File) DisableAll() error { - return f.changeTo(f.GetProfileNames(), Disabled) + return f.changeTo(f.GetProfileNames(), types.Disabled) } // DisableOnly marks profiles as disable and enable all other profiles func (f *File) DisableOnly(profiles []string) error { - return f.changeToSplitted(profiles, Disabled) + return f.changeToSplitted(profiles, types.Disabled) } // EnableOnly marks profiles as enable and disable all other profiles func (f *File) EnableOnly(profiles []string) error { - return f.changeToSplitted(profiles, Enabled) + return f.changeToSplitted(profiles, types.Enabled) } -func (f *File) changeToSplitted(profiles []string, status ProfileStatus) error { +func (f *File) changeToSplitted(profiles []string, status types.Status) error { for _, name := range f.data.ProfileNames { - if name == Default { + if name == types.Default { continue } - profile, ok := f.data.Profiles[name] + p, ok := f.data.Profiles[name] if !ok { - return ErrUnknownProfile + return errors.ErrUnknownProfile } if contains(profiles, name) { - profile.Status = status + p.Status = status } else { - profile.Status = invertStatus(status) + p.Status = invertStatus(status) } - f.data.Profiles[name] = profile + f.data.Profiles[name] = p } return nil } -func invertStatus(s ProfileStatus) ProfileStatus { - if s == Enabled { - return Disabled +func invertStatus(s types.Status) types.Status { + if s == types.Enabled { + return types.Disabled } - return Enabled + return types.Enabled } -func (f *File) changeTo(profiles []string, status ProfileStatus) error { - for _, p := range profiles { - if p == Default { +func (f *File) changeTo(profiles []string, status types.Status) error { + for _, name := range profiles { + if name == types.Default { continue } - profile, ok := f.data.Profiles[p] + p, ok := f.data.Profiles[name] if !ok { - return ErrUnknownProfile + return errors.ErrUnknownProfile } - profile.Status = status - f.data.Profiles[p] = profile + p.Status = status + f.data.Profiles[name] = p } return nil diff --git a/pkg/host/enable_disable_test.go b/pkg/host/file/enable_disable_test.go similarity index 86% rename from pkg/host/enable_disable_test.go rename to pkg/host/file/enable_disable_test.go index 8a226b8..1f0d35e 100644 --- a/pkg/host/enable_disable_test.go +++ b/pkg/host/file/enable_disable_test.go @@ -1,13 +1,15 @@ -package host +package file import ( "testing" "github.com/stretchr/testify/assert" + + "github.com/guumaster/hostctl/pkg/host/errors" ) func TestFile_Enable(t *testing.T) { - mem := createBasicFS(t) + mem := CreateBasicFS(t) f, err := mem.Open("/tmp/etc/hosts") assert.NoError(t, err) @@ -39,6 +41,6 @@ func TestFile_Enable(t *testing.T) { t.Run("Enable error", func(t *testing.T) { err = m.Enable([]string{"unknown"}) - assert.EqualError(t, err, ErrUnknownProfile.Error()) + assert.EqualError(t, err, errors.ErrUnknownProfile.Error()) }) } diff --git a/pkg/host/file.go b/pkg/host/file/file.go similarity index 73% rename from pkg/host/file.go rename to pkg/host/file/file.go index 4fb72a7..9dfcb7b 100644 --- a/pkg/host/file.go +++ b/pkg/host/file/file.go @@ -1,14 +1,28 @@ -package host +package file import ( "errors" "fmt" "io" "os" + "sync" "github.com/spf13/afero" + + errors2 "github.com/guumaster/hostctl/pkg/host/errors" + "github.com/guumaster/hostctl/pkg/host/parser" + "github.com/guumaster/hostctl/pkg/host/types" ) +// File container to handle a hosts file +type File struct { + fs afero.Fs + src afero.File + data *types.Content + hasBanner bool + mutex sync.Mutex +} + // NewFile creates a new File from the given src on default OS filesystem func NewFile(src string) (*File, error) { return NewWithFs(src, afero.NewOsFs()) @@ -28,7 +42,7 @@ func NewWithFs(src string, fs afero.Fs) (*File, error) { f := &File{src: s, fs: fs} _, _ = f.src.Seek(0, io.SeekStart) - data, err := Parse(f.src) + data, err := parser.Parse(f.src) f.data = data if err != nil { @@ -39,16 +53,16 @@ func NewWithFs(src string, fs afero.Fs) (*File, error) { } // GetStatus returns a map with the status of the given profiles -func (f *File) GetStatus(profiles []string) map[string]ProfileStatus { - st := map[string]ProfileStatus{} +func (f *File) GetStatus(profiles []string) map[string]types.Status { + st := map[string]types.Status{} for _, name := range profiles { - profile, ok := f.data.Profiles[name] + p, ok := f.data.Profiles[name] if !ok { continue } - st[name] = profile.Status + st[name] = p.Status } return st @@ -59,7 +73,7 @@ func (f *File) GetEnabled() []string { enabled := []string{} for _, name := range f.data.ProfileNames { - if f.data.Profiles[name].Status == Enabled { + if f.data.Profiles[name].Status == types.Enabled { enabled = append(enabled, name) } } @@ -72,7 +86,7 @@ func (f *File) GetDisabled() []string { disabled := []string{} for _, name := range f.data.ProfileNames { - if f.data.Profiles[name].Status == Disabled { + if f.data.Profiles[name].Status == types.Disabled { disabled = append(disabled, name) } } @@ -81,13 +95,13 @@ func (f *File) GetDisabled() []string { } // GetProfile return a Profile from the list -func (f *File) GetProfile(name string) (*Profile, error) { - profile, ok := f.data.Profiles[name] +func (f *File) GetProfile(name string) (*types.Profile, error) { + p, ok := f.data.Profiles[name] if !ok { - return nil, ErrUnknownProfile + return nil, errors2.ErrUnknownProfile } - return profile, nil + return p, nil } // GetProfileNames return a list of all profile names @@ -97,16 +111,16 @@ func (f *File) GetProfileNames() []string { // AddRoutes add route information to a given profile func (f *File) AddRoutes(name, ip string, hostnames []string) error { - profile, err := f.GetProfile(name) - if err != nil && !errors.Is(err, ErrUnknownProfile) { + p, err := f.GetProfile(name) + if err != nil && !errors.Is(err, errors2.ErrUnknownProfile) { return err } - if profile == nil { - p := Profile{ + if p == nil { + p = &types.Profile{ Name: name, - Status: Enabled, - Routes: map[string]*Route{}, + Status: types.Enabled, + Routes: map[string]*types.Route{}, } p.AddRoutes(ip, hostnames) @@ -114,7 +128,7 @@ func (f *File) AddRoutes(name, ip string, hostnames []string) error { return f.AddProfile(p) } - profile.AddRoutes(ip, hostnames) + p.AddRoutes(ip, hostnames) return nil } @@ -168,7 +182,7 @@ func (f *File) writeToFile(dst afero.File) error { defer f.mutex.Unlock() if f.data == nil { - return ErrNoContent + return errors2.ErrNoContent } err := dst.Truncate(0) @@ -184,13 +198,13 @@ func (f *File) writeToFile(dst afero.File) error { f.writeBanner(dst) for _, name := range f.data.ProfileNames { - if name == Default { + if name == types.Default { continue } - profile := f.data.Profiles[name] + p := f.data.Profiles[name] - err := profile.Render(dst) + err := p.Render(dst) if err != nil { return err } @@ -204,7 +218,7 @@ func (f *File) writeBanner(w io.StringWriter) { return } - _, _ = w.WriteString(fmt.Sprintf("%s\n", banner)) + _, _ = w.WriteString(fmt.Sprintf("%s\n", types.Banner)) f.hasBanner = true } @@ -222,15 +236,3 @@ func contains(s []string, n string) bool { return false } - -func remove(s []string, n string) []string { - list := []string{} - - for _, x := range s { - if x != n { - list = append(list, x) - } - } - - return list -} diff --git a/pkg/host/file_backup.go b/pkg/host/file/file_backup.go similarity index 97% rename from pkg/host/file_backup.go rename to pkg/host/file/file_backup.go index d429ff9..2f5f127 100644 --- a/pkg/host/file_backup.go +++ b/pkg/host/file/file_backup.go @@ -1,4 +1,4 @@ -package host +package file import ( "fmt" diff --git a/pkg/host/file_backup_test.go b/pkg/host/file/file_backup_test.go similarity index 91% rename from pkg/host/file_backup_test.go rename to pkg/host/file/file_backup_test.go index 26068b2..59f873f 100644 --- a/pkg/host/file_backup_test.go +++ b/pkg/host/file/file_backup_test.go @@ -1,4 +1,4 @@ -package host +package file import ( "testing" @@ -8,7 +8,7 @@ import ( ) func TestFile_Backup(t *testing.T) { - mem := createBasicFS(t) + mem := CreateBasicFS(t) h, err := NewWithFs("/tmp/etc/hosts", mem) assert.NoError(t, err) diff --git a/pkg/host/file_restore.go b/pkg/host/file/file_restore.go similarity index 97% rename from pkg/host/file_restore.go rename to pkg/host/file/file_restore.go index 5315b96..4474487 100644 --- a/pkg/host/file_restore.go +++ b/pkg/host/file/file_restore.go @@ -1,4 +1,4 @@ -package host +package file import ( "io" diff --git a/pkg/host/file_restore_test.go b/pkg/host/file/file_restore_test.go similarity index 91% rename from pkg/host/file_restore_test.go rename to pkg/host/file/file_restore_test.go index 2267b12..4bb3f72 100644 --- a/pkg/host/file_restore_test.go +++ b/pkg/host/file/file_restore_test.go @@ -1,4 +1,4 @@ -package host +package file import ( "io" @@ -8,7 +8,7 @@ import ( ) func TestFile_Restore(t *testing.T) { - mem := createBasicFS(t) + mem := CreateBasicFS(t) h, err := NewWithFs("/tmp/etc/hosts", mem) assert.NoError(t, err) diff --git a/pkg/host/file_test.go b/pkg/host/file/file_test.go similarity index 72% rename from pkg/host/file_test.go rename to pkg/host/file/file_test.go index 659fde2..c88edaa 100644 --- a/pkg/host/file_test.go +++ b/pkg/host/file/file_test.go @@ -1,4 +1,4 @@ -package host +package file import ( "os" @@ -7,11 +7,14 @@ import ( "github.com/spf13/afero" "github.com/stretchr/testify/assert" + + "github.com/guumaster/hostctl/pkg/host/profile" + "github.com/guumaster/hostctl/pkg/host/types" ) func TestManagerStatus(t *testing.T) { t.Run("Get Status", func(t *testing.T) { - mem := createBasicFS(t) + mem := CreateBasicFS(t) m, err := NewWithFs("/tmp/etc/hosts", mem) assert.NoError(t, err) @@ -27,9 +30,9 @@ func TestManagerStatus(t *testing.T) { }) t.Run("GetStatus", func(t *testing.T) { actual := m.GetStatus([]string{"profile1", "profile2"}) - expected := map[string]ProfileStatus{ - "profile1": Enabled, - "profile2": Disabled, + expected := map[string]types.Status{ + "profile1": types.Enabled, + "profile2": types.Disabled, } assert.Equal(t, expected, actual) }) @@ -37,13 +40,13 @@ func TestManagerStatus(t *testing.T) { } func TestManagerRoutes(t *testing.T) { t.Run("AddRoutes", func(t *testing.T) { - mem := createBasicFS(t) + mem := CreateBasicFS(t) f, err := NewWithFs("/tmp/etc/hosts", mem) assert.NoError(t, err) r := strings.NewReader(`3.3.3.4 some.profile.loc`) - p, err := NewProfileFromReader(r, true) + p, err := profile.NewProfileFromReader(r, true) assert.NoError(t, err) h, _ := mem.OpenFile("/tmp/etc/hosts", os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0644) @@ -61,9 +64,9 @@ func TestManagerRoutes(t *testing.T) { c, err := afero.ReadFile(mem, h.Name()) assert.NoError(t, err) - assert.Contains(t, string(c), defaultProfile) - assert.Contains(t, string(c), banner) - assert.Contains(t, string(c), testEnabledProfile) + assert.Contains(t, string(c), DefaultProfile) + assert.Contains(t, string(c), types.Banner) + assert.Contains(t, string(c), TestEnabledProfile) var added = ` # profile.off profile2 # 127.0.0.1 first.loc @@ -75,7 +78,7 @@ func TestManagerRoutes(t *testing.T) { }) t.Run("RemoveRoutes", func(t *testing.T) { - mem := createBasicFS(t) + mem := CreateBasicFS(t) f, err := NewWithFs("/tmp/etc/hosts", mem) assert.NoError(t, err) @@ -92,9 +95,9 @@ func TestManagerRoutes(t *testing.T) { c, err := afero.ReadFile(mem, h.Name()) assert.NoError(t, err) - assert.Contains(t, string(c), defaultProfile) - assert.Contains(t, string(c), banner) - assert.Contains(t, string(c), testEnabledProfile) + assert.Contains(t, string(c), DefaultProfile) + assert.Contains(t, string(c), types.Banner) + assert.Contains(t, string(c), TestEnabledProfile) var added = ` # profile.off profile2 # 127.0.0.1 first.loc @@ -104,7 +107,7 @@ func TestManagerRoutes(t *testing.T) { }) t.Run("RemoveRoutes", func(t *testing.T) { - mem := createBasicFS(t) + mem := CreateBasicFS(t) f, err := NewWithFs("/tmp/etc/hosts", mem) assert.NoError(t, err) @@ -121,16 +124,16 @@ func TestManagerRoutes(t *testing.T) { c, err := afero.ReadFile(mem, h.Name()) assert.NoError(t, err) - assert.Contains(t, string(c), defaultProfile) - assert.Contains(t, string(c), banner) - assert.Contains(t, string(c), testEnabledProfile) - assert.NotContains(t, string(c), testDisabledProfile) + assert.Contains(t, string(c), DefaultProfile) + assert.Contains(t, string(c), types.Banner) + assert.Contains(t, string(c), TestEnabledProfile) + assert.NotContains(t, string(c), TestDisabledProfile) }) } func TestManagerWrite(t *testing.T) { t.Run("WriteToFile", func(t *testing.T) { - mem := createBasicFS(t) + mem := CreateBasicFS(t) f, err := NewWithFs("/tmp/etc/hosts", mem) assert.NoError(t, err) @@ -142,14 +145,14 @@ func TestManagerWrite(t *testing.T) { c, err := afero.ReadFile(mem, h.Name()) assert.NoError(t, err) - assert.Contains(t, string(c), defaultProfile) - assert.Contains(t, string(c), banner) - assert.Contains(t, string(c), testEnabledProfile) - assert.Contains(t, string(c), testDisabledProfile) + assert.Contains(t, string(c), DefaultProfile) + assert.Contains(t, string(c), types.Banner) + assert.Contains(t, string(c), TestEnabledProfile) + assert.Contains(t, string(c), TestDisabledProfile) }) t.Run("writeBanner", func(t *testing.T) { - mem := createBasicFS(t) + mem := CreateBasicFS(t) h, _ := mem.OpenFile("/tmp/etc/hosts", os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0644) f, err := NewWithFs("/tmp/etc/hosts", mem) @@ -161,6 +164,6 @@ func TestManagerWrite(t *testing.T) { content, err := afero.ReadFile(mem, h.Name()) assert.NoError(t, err) - assert.Contains(t, string(content), banner) + assert.Contains(t, string(content), types.Banner) }) } diff --git a/pkg/host/helpers_test.go b/pkg/host/file/helpers.go similarity index 51% rename from pkg/host/helpers_test.go rename to pkg/host/file/helpers.go index 9dc539f..eb6e197 100644 --- a/pkg/host/helpers_test.go +++ b/pkg/host/file/helpers.go @@ -1,29 +1,31 @@ -package host +package file import ( "net" "testing" "github.com/spf13/afero" + + "github.com/guumaster/hostctl/pkg/host/types" ) -var localhost = net.ParseIP("127.0.0.1") +var Localhost = net.ParseIP("127.0.0.1") -var defaultProfile = "127.0.0.1 localhost\n" -var testEnabledProfile = ` +var DefaultProfile = "127.0.0.1 localhost\n" +var TestEnabledProfile = ` # profile.on profile1 127.0.0.1 first.loc 127.0.0.1 second.loc # end ` -var testDisabledProfile = ` +var TestDisabledProfile = ` # profile.off profile2 # 127.0.0.1 first.loc # 127.0.0.1 second.loc # end ` -func createBasicFS(t *testing.T) afero.Fs { +func CreateBasicFS(t *testing.T) afero.Fs { t.Helper() appFS := afero.NewMemMapFs() @@ -32,7 +34,7 @@ func createBasicFS(t *testing.T) afero.Fs { f, _ := appFS.Create("/tmp/etc/hosts") defer f.Close() - _, _ = f.WriteString(defaultProfile + banner + testEnabledProfile + testDisabledProfile) + _, _ = f.WriteString(DefaultProfile + types.Banner + TestEnabledProfile + TestDisabledProfile) return appFS } diff --git a/pkg/host/file/list.go b/pkg/host/file/list.go new file mode 100644 index 0000000..671f097 --- /dev/null +++ b/pkg/host/file/list.go @@ -0,0 +1,100 @@ +package file + +import ( + "github.com/guumaster/hostctl/pkg/host/render" + "github.com/guumaster/hostctl/pkg/host/types" +) + +// ListOptions contains available options for listing. +type ListOptions struct { + Renderer render.Renderer + Profiles []string + ProfilesOnly bool + StatusFilter types.Status +} + +func includeProfile(needle string, stack []string) bool { + if len(stack) == 0 { + return true + } + + for _, s := range stack { + if s == needle { + return true + } + } + + return false +} + +// ProfileStatus shows a table only with profile names status +func (f *File) ProfileStatus(r render.Renderer, profiles []string) { + for _, name := range f.data.ProfileNames { + currProfile := f.data.Profiles[name] + + if profiles != nil && !includeProfile(name, profiles) { + continue + } + + r.AppendRow(&render.Row{ + Profile: currProfile.Name, + Status: currProfile.GetStatus(), + }) + } + + _ = r.Render() +} + +// List shows a table with profile names status and routing information +func (f *File) List(r render.Renderer, opts *ListOptions) { + addDefault(f, r, opts) + + for _, name := range f.data.ProfileNames { + addProfiles(f.data.Profiles[name], r, opts) + } + + _ = r.Render() +} + +func addDefault(f *File, r render.Renderer, opts *ListOptions) { + // First check if default should be shown + if !includeProfile(types.Default, opts.Profiles) { + return + } + + for _, row := range f.data.DefaultProfile { + if row.Comment == "" && row.Profile != "" { + r.AppendRow(row) + } + } + + if len(f.data.DefaultProfile) > 0 && len(f.data.Profiles) > 0 { + r.AddSeparator() + } +} + +func addProfiles(p *types.Profile, r render.Renderer, opts *ListOptions) { + if !includeProfile(p.Name, opts.Profiles) { + return + } + + if opts.StatusFilter != "" && p.Status != opts.StatusFilter { + return + } + + for _, ip := range p.IPList { + route := p.Routes[ip] + for _, h := range route.HostNames { + r.AppendRow(&render.Row{ + Profile: p.Name, + Status: p.GetStatus(), + IP: route.IP.String(), + Host: h, + }) + } + } + + if len(p.IPList) > 0 { + r.AddSeparator() + } +} diff --git a/pkg/host/file/list_test.go b/pkg/host/file/list_test.go new file mode 100644 index 0000000..dc32b00 --- /dev/null +++ b/pkg/host/file/list_test.go @@ -0,0 +1,226 @@ +package file + +import ( + "bytes" + "regexp" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/guumaster/hostctl/pkg/host/render" +) + +func TestFile_List(t *testing.T) { + mem := CreateBasicFS(t) + + f, err := mem.Open("/tmp/etc/hosts") + assert.NoError(t, err) + + m, err := NewWithFs(f.Name(), mem) + assert.NoError(t, err) + + t.Run("Table", func(t *testing.T) { + out := bytes.NewBufferString("\n") + + r := render.NewTableRenderer(&render.TableRendererOptions{Writer: out}) + + m.List(r, &ListOptions{}) + + const expected = ` ++----------+--------+-----------+------------+ +| PROFILE | STATUS | IP | DOMAIN | ++----------+--------+-----------+------------+ +| default | on | 127.0.0.1 | localhost | ++----------+--------+-----------+------------+ +| profile1 | on | 127.0.0.1 | first.loc | +| profile1 | on | 127.0.0.1 | second.loc | ++----------+--------+-----------+------------+ +| profile2 | off | 127.0.0.1 | first.loc | +| profile2 | off | 127.0.0.1 | second.loc | ++----------+--------+-----------+------------+ +` + assertListOutput(t, expected, out.String()) + }) + + t.Run("Table Column order", func(t *testing.T) { + out := bytes.NewBufferString("\n") + tabOpts := &render.TableRendererOptions{ + Writer: out, + Columns: []string{"domain", "ip", "status"}, + } + opts := &ListOptions{} + + r := render.NewTableRenderer(tabOpts) + + m.List(r, opts) + + const expected = ` ++------------+-----------+--------+ +| DOMAIN | IP | STATUS | ++------------+-----------+--------+ +| localhost | 127.0.0.1 | on | ++------------+-----------+--------+ +| first.loc | 127.0.0.1 | on | +| second.loc | 127.0.0.1 | on | ++------------+-----------+--------+ +| first.loc | 127.0.0.1 | off | +| second.loc | 127.0.0.1 | off | ++------------+-----------+--------+ +` + assertListOutput(t, expected, out.String()) + }) +} + +func Test_TableRaw(t *testing.T) { + mem := CreateBasicFS(t) + + f, err := mem.Open("/tmp/etc/hosts") + assert.NoError(t, err) + + m, err := NewWithFs(f.Name(), mem) + assert.NoError(t, err) + + t.Run("Table Raw", func(t *testing.T) { + out := bytes.NewBufferString("\n") + opts := &render.TableRendererOptions{Writer: out} + + r := render.NewRawRenderer(opts) + + m.List(r, &ListOptions{}) + + const expected = ` +PROFILE STATUS IP DOMAIN +default on 127.0.0.1 localhost +profile1 on 127.0.0.1 first.loc +profile1 on 127.0.0.1 second.loc +profile2 off 127.0.0.1 first.loc +profile2 off 127.0.0.1 second.loc +` + assertListOutput(t, expected, out.String()) + }) + + t.Run("Table Raw Filtered", func(t *testing.T) { + out := bytes.NewBufferString("\n") + opts := &render.TableRendererOptions{Writer: out} + + r := render.NewRawRenderer(opts) + + m.List(r, &ListOptions{Profiles: []string{"profile1"}}) + + const expected = ` +PROFILE STATUS IP DOMAIN +profile1 on 127.0.0.1 first.loc +profile1 on 127.0.0.1 second.loc +` + assertListOutput(t, expected, out.String()) + }) + + t.Run("Table Raw Filtered with columns", func(t *testing.T) { + out := bytes.NewBufferString("\n") + opts := &render.TableRendererOptions{ + Writer: out, + Columns: []string{"ip", "domain"}, + } + r := render.NewRawRenderer(opts) + + m.List(r, &ListOptions{ + Profiles: []string{"profile1"}, + }) + + const expected = ` +IP DOMAIN +127.0.0.1 first.loc +127.0.0.1 second.loc +` + assertListOutput(t, expected, out.String()) + }) +} + +func Test_TableMarkdown(t *testing.T) { + mem := CreateBasicFS(t) + + f, err := mem.Open("/tmp/etc/hosts") + assert.NoError(t, err) + + m, err := NewWithFs(f.Name(), mem) + assert.NoError(t, err) + + t.Run("Table Markdown", func(t *testing.T) { + out := bytes.NewBufferString("\n") + opts := &render.TableRendererOptions{Writer: out} + + r := render.NewMarkdownRenderer(opts) + + m.List(r, &ListOptions{}) + + const expected = ` +| PROFILE | STATUS | IP | DOMAIN | +|----------|--------|-----------|------------| +| default | on | 127.0.0.1 | localhost | +|----------|--------|-----------|------------| +| profile1 | on | 127.0.0.1 | first.loc | +| profile1 | on | 127.0.0.1 | second.loc | +|----------|--------|-----------|------------| +| profile2 | off | 127.0.0.1 | first.loc | +| profile2 | off | 127.0.0.1 | second.loc | +` + assertListOutput(t, expected, out.String()) + }) +} + +func TestFile_ProfileStatus(t *testing.T) { + mem := CreateBasicFS(t) + + f, err := mem.Open("/tmp/etc/hosts") + assert.NoError(t, err) + + m, err := NewWithFs(f.Name(), mem) + assert.NoError(t, err) + + t.Run("Profile status", func(t *testing.T) { + out := bytes.NewBufferString("\n") + r := render.NewTableRenderer(&render.TableRendererOptions{ + Writer: out, + Columns: render.ProfilesOnlyColumns, + }) + + m.ProfileStatus(r, nil) + + const expected = ` ++----------+--------+ +| PROFILE | STATUS | ++----------+--------+ +| profile1 | on | +| profile2 | off | ++----------+--------+ +` + assertListOutput(t, expected, out.String()) + }) + + t.Run("Profiles status Raw", func(t *testing.T) { + out := bytes.NewBufferString("\n") + r := render.NewRawRenderer(&render.TableRendererOptions{ + Writer: out, + Columns: render.ProfilesOnlyColumns, + }) + + m.ProfileStatus(r, nil) + + const expected = ` +PROFILE STATUS +profile1 on +profile2 off +` + assertListOutput(t, expected, out.String()) + }) +} + +func assertListOutput(t *testing.T, actual, expected string) { + t.Helper() + + compact := regexp.MustCompile(`[ \t]+`) + actual = compact.ReplaceAllString(actual, "") + expected = compact.ReplaceAllString(expected, "") + + assert.Contains(t, expected, actual) +} diff --git a/pkg/host/merge.go b/pkg/host/file/merge.go similarity index 62% rename from pkg/host/merge.go rename to pkg/host/file/merge.go index a58a13c..fa07e6e 100644 --- a/pkg/host/merge.go +++ b/pkg/host/file/merge.go @@ -1,7 +1,11 @@ -package host +package file + +import ( + "github.com/guumaster/hostctl/pkg/host/types" +) // MergeProfiles joins new content with existing content -func (f *File) MergeProfiles(content *Content) { +func (f *File) MergeProfiles(content *types.Content) { for _, newName := range content.ProfileNames { newP := content.Profiles[newName] @@ -15,17 +19,12 @@ func (f *File) MergeProfiles(content *Content) { baseP := f.data.Profiles[newName] if baseP.Routes == nil { - baseP.Routes = map[string]*Route{} + baseP.Routes = map[string]*types.Route{} } for _, r := range newP.Routes { ip := r.IP.String() - if _, ok := baseP.Routes[ip]; ok { - baseP.Routes[ip].HostNames = append(baseP.Routes[ip].HostNames, r.HostNames...) - } else { - baseP.appendIP(ip) - baseP.Routes[ip] = r - } + baseP.AddRoutes(ip, r.HostNames) } f.data.Profiles[newName] = baseP diff --git a/pkg/host/merge_test.go b/pkg/host/file/merge_test.go similarity index 71% rename from pkg/host/merge_test.go rename to pkg/host/file/merge_test.go index 7e98967..0da6df1 100644 --- a/pkg/host/merge_test.go +++ b/pkg/host/file/merge_test.go @@ -1,14 +1,16 @@ -package host +package file import ( "net" "testing" "github.com/stretchr/testify/assert" + + "github.com/guumaster/hostctl/pkg/host/types" ) func TestFile_MergeProfiles(t *testing.T) { - mem := createBasicFS(t) + mem := CreateBasicFS(t) f, err := mem.Open("/tmp/etc/hosts") assert.NoError(t, err) @@ -19,21 +21,21 @@ func TestFile_MergeProfiles(t *testing.T) { ip3 := net.ParseIP("2.2.2.2") ip4 := net.ParseIP("3.3.3.3") - c := &Content{ + c := &types.Content{ DefaultProfile: nil, ProfileNames: []string{"profile2", "profile3"}, - Profiles: map[string]*Profile{ + Profiles: map[string]*types.Profile{ "profile2": { Name: "profile2", - Status: Enabled, - Routes: map[string]*Route{ + Status: types.Enabled, + Routes: map[string]*types.Route{ ip3.String(): {IP: ip3, HostNames: []string{"third.new.loc"}}, }, }, "profile3": { Name: "profile3", - Status: Enabled, - Routes: map[string]*Route{ + Status: types.Enabled, + Routes: map[string]*types.Route{ ip4.String(): {IP: ip4, HostNames: []string{"third.new.loc", "fourth.new.loc"}}, }, }, @@ -53,7 +55,7 @@ func TestFile_MergeProfiles(t *testing.T) { modP2 := c.Profiles["profile2"] modP2.IPList = []string{"127.0.0.1", "2.2.2.2"} - modP2.Routes[localhost.String()] = &Route{IP: localhost, HostNames: []string{"first.loc", "second.loc"}} - modP2.Status = Disabled + modP2.Routes[Localhost.String()] = &types.Route{IP: Localhost, HostNames: []string{"first.loc", "second.loc"}} + modP2.Status = types.Disabled assert.Equal(t, modP2, p2) } diff --git a/pkg/host/remove.go b/pkg/host/file/remove.go similarity index 72% rename from pkg/host/remove.go rename to pkg/host/file/remove.go index 940b745..b9fee4b 100644 --- a/pkg/host/remove.go +++ b/pkg/host/file/remove.go @@ -1,4 +1,9 @@ -package host +package file + +import ( + "github.com/guumaster/hostctl/pkg/host/errors" + "github.com/guumaster/hostctl/pkg/host/types" +) // RemoveProfiles removes given profiles from the list func (f *File) RemoveProfiles(profiles []string) error { @@ -16,13 +21,13 @@ func (f *File) RemoveProfiles(profiles []string) error { func (f *File) RemoveProfile(name string) error { var names []string - if name == Default { - return ErrDefaultProfileError + if name == types.Default { + return errors.ErrDefaultProfileError } _, ok := f.data.Profiles[name] if !ok { - return ErrUnknownProfile + return errors.ErrUnknownProfile } delete(f.data.Profiles, name) diff --git a/pkg/host/remove_test.go b/pkg/host/file/remove_test.go similarity index 80% rename from pkg/host/remove_test.go rename to pkg/host/file/remove_test.go index bc12876..ff910fa 100644 --- a/pkg/host/remove_test.go +++ b/pkg/host/file/remove_test.go @@ -1,13 +1,15 @@ -package host +package file import ( "testing" "github.com/stretchr/testify/assert" + + "github.com/guumaster/hostctl/pkg/host/errors" ) func TestFile_RemoveProfile(t *testing.T) { - mem := createBasicFS(t) + mem := CreateBasicFS(t) f, err := mem.Open("/tmp/etc/hosts") assert.NoError(t, err) @@ -22,14 +24,14 @@ func TestFile_RemoveProfile(t *testing.T) { assert.Equal(t, []string{}, m.GetDisabled()) _, err = m.GetProfile("profile2") - assert.EqualError(t, err, ErrUnknownProfile.Error()) + assert.EqualError(t, err, errors.ErrUnknownProfile.Error()) }) t.Run("Remove unknown", func(t *testing.T) { m, err := NewWithFs(f.Name(), mem) assert.NoError(t, err) err = m.RemoveProfile("unknown") - assert.EqualError(t, err, ErrUnknownProfile.Error()) + assert.EqualError(t, err, errors.ErrUnknownProfile.Error()) }) t.Run("Remove profiles", func(t *testing.T) { diff --git a/pkg/host/file/replace.go b/pkg/host/file/replace.go new file mode 100644 index 0000000..5a2d587 --- /dev/null +++ b/pkg/host/file/replace.go @@ -0,0 +1,18 @@ +package file + +import ( + "errors" + + errors2 "github.com/guumaster/hostctl/pkg/host/errors" + "github.com/guumaster/hostctl/pkg/host/types" +) + +// ReplaceProfile removes previous profile with same name and add new profile to the list +func (f *File) ReplaceProfile(p *types.Profile) error { + err := f.RemoveProfile(p.Name) + if err != nil && !errors.Is(err, errors2.ErrUnknownProfile) { + return err + } + + return f.AddProfile(p) +} diff --git a/pkg/host/replace_test.go b/pkg/host/file/replace_test.go similarity index 67% rename from pkg/host/replace_test.go rename to pkg/host/file/replace_test.go index 23fda19..9f07274 100644 --- a/pkg/host/replace_test.go +++ b/pkg/host/file/replace_test.go @@ -1,14 +1,18 @@ -package host +package file import ( "strings" "testing" "github.com/stretchr/testify/assert" + + "github.com/guumaster/hostctl/pkg/host/errors" + "github.com/guumaster/hostctl/pkg/host/profile" + "github.com/guumaster/hostctl/pkg/host/types" ) func TestFile_ReplaceProfile(t *testing.T) { - mem := createBasicFS(t) + mem := CreateBasicFS(t) f, err := mem.Open("/tmp/etc/hosts") assert.NoError(t, err) @@ -18,12 +22,12 @@ func TestFile_ReplaceProfile(t *testing.T) { r := strings.NewReader(`4.4.4.4 replaced.loc`) - p, err := NewProfileFromReader(r, true) + p, err := profile.NewProfileFromReader(r, true) assert.NoError(t, err) p.Name = "profile1" - p.Status = Enabled + p.Status = types.Enabled - err = m.ReplaceProfile(*p) + err = m.ReplaceProfile(p) assert.NoError(t, err) replaced, err := m.GetProfile("profile1") @@ -39,12 +43,12 @@ func TestFile_ReplaceProfile(t *testing.T) { r := strings.NewReader(`4.4.4.4 replaced.loc`) - p, err := NewProfileFromReader(r, true) + p, err := profile.NewProfileFromReader(r, true) assert.NoError(t, err) p.Name = "awesome" - p.Status = Enabled + p.Status = types.Enabled - err = m.ReplaceProfile(*p) + err = m.ReplaceProfile(p) assert.NoError(t, err) added, err := m.GetProfile("awesome") @@ -60,12 +64,12 @@ func TestFile_ReplaceProfile(t *testing.T) { r := strings.NewReader(`4.4.4.4 replaced.loc`) - p, err := NewProfileFromReader(r, true) + p, err := profile.NewProfileFromReader(r, true) assert.NoError(t, err) - p.Name = Default - p.Status = Enabled + p.Name = types.Default + p.Status = types.Enabled - err = m.ReplaceProfile(*p) - assert.EqualError(t, err, ErrDefaultProfileError.Error()) + err = m.ReplaceProfile(p) + assert.EqualError(t, err, errors.ErrDefaultProfileError.Error()) }) } diff --git a/pkg/host/file/toggle.go b/pkg/host/file/toggle.go new file mode 100644 index 0000000..1e901d7 --- /dev/null +++ b/pkg/host/file/toggle.go @@ -0,0 +1,30 @@ +package file + +import ( + "github.com/guumaster/hostctl/pkg/host/errors" + "github.com/guumaster/hostctl/pkg/host/types" +) + +// Toggle alternates between enable and disable status of a profile. +func (f *File) Toggle(profiles []string) error { + for _, name := range profiles { + if name == types.Default { + continue + } + + p, ok := f.data.Profiles[name] + if !ok { + return errors.ErrUnknownProfile + } + + if p.Status == types.Enabled { + p.Status = types.Disabled + } else { + p.Status = types.Enabled + } + + f.data.Profiles[name] = p + } + + return nil +} diff --git a/pkg/host/toggle_test.go b/pkg/host/file/toggle_test.go similarity index 78% rename from pkg/host/toggle_test.go rename to pkg/host/file/toggle_test.go index 373cf15..eba6ddc 100644 --- a/pkg/host/toggle_test.go +++ b/pkg/host/file/toggle_test.go @@ -1,13 +1,15 @@ -package host +package file import ( "testing" "github.com/stretchr/testify/assert" + + "github.com/guumaster/hostctl/pkg/host/errors" ) func TestFile_Toggle(t *testing.T) { - mem := createBasicFS(t) + mem := CreateBasicFS(t) f, err := mem.Open("/tmp/etc/hosts") assert.NoError(t, err) @@ -24,6 +26,6 @@ func TestFile_Toggle(t *testing.T) { t.Run("Toggle error", func(t *testing.T) { err = m.Toggle([]string{"unknown"}) - assert.EqualError(t, err, ErrUnknownProfile.Error()) + assert.EqualError(t, err, errors.ErrUnknownProfile.Error()) }) } diff --git a/pkg/host/list.go b/pkg/host/list.go deleted file mode 100644 index b1a6d3b..0000000 --- a/pkg/host/list.go +++ /dev/null @@ -1,126 +0,0 @@ -package host - -import ( - "io" - - "github.com/guumaster/tablewriter" -) - -// DefaultColumns is the list of default columns to use when showing table list -var DefaultColumns = []string{"profile", "status", "ip", "domain"} - -// ProfilesOnlyColumns are the columns used for profile status list -var ProfilesOnlyColumns = []string{"profile", "status"} - -// ListOptions contains available options for listing. -type ListOptions struct { - Profiles []string - Columns []string - RawTable bool - ProfilesOnly bool - StatusFilter ProfileStatus - Writer io.Writer -} - -func includeProfile(needle string, stack []string) bool { - if len(stack) == 0 { - return true - } - - for _, s := range stack { - if s == needle { - return true - } - } - - return false -} - -// ProfileStatus shows a table only with profile names status -func (f *File) ProfileStatus(opts *ListOptions) { - opts.Columns = ProfilesOnlyColumns - - table := createTableWriter(opts) - - for _, name := range f.data.ProfileNames { - currProfile := f.data.Profiles[name] - - if !includeProfile(name, opts.Profiles) { - continue - } - - table.Append([]string{currProfile.Name, currProfile.GetStatus()}) - } - - table.Render() -} - -// List shows a table with profile names status and routing information -func (f *File) List(opts *ListOptions) { - if len(opts.Columns) == 0 { - opts.Columns = DefaultColumns - } - - table := createTableWriter(opts) - - added := addDefault(f, table, opts) - if added && len(f.data.Profiles) > 0 && !opts.RawTable { - table.AddSeparator() - } - - for _, name := range f.data.ProfileNames { - added := addProfiles(f.data.Profiles[name], table, opts) - if added && !opts.RawTable { - table.AddSeparator() - } - } - - table.Render() -} - -func addDefault(f *File, table *tablewriter.Table, opts *ListOptions) bool { - // First check if default should be shown - if !includeProfile(Default, opts.Profiles) { - return false - } - - for _, line := range f.data.DefaultProfile { - if line.Comment == "" && line.Profile != "" { - row := getRow(line, opts.Columns) - if len(row) > 0 { - table.Append(row) - } - } - } - - return len(f.data.DefaultProfile) > 0 -} - -func addProfiles(p *Profile, table *tablewriter.Table, opts *ListOptions) bool { - if !includeProfile(p.Name, opts.Profiles) { - return false - } - - if opts.StatusFilter != "" && p.Status != opts.StatusFilter { - return false - } - - for _, ip := range p.IPList { - route := p.Routes[ip] - for _, h := range route.HostNames { - line := &tableRow{ - Profile: p.Name, - Status: p.GetStatus(), - IP: route.IP.String(), - Host: h, - } - - row := getRow(line, opts.Columns) - if len(row) > 0 { - table.Append(row) - } - } - } - - return true -} diff --git a/pkg/host/list_test.go b/pkg/host/list_test.go deleted file mode 100644 index c1bddb5..0000000 --- a/pkg/host/list_test.go +++ /dev/null @@ -1,138 +0,0 @@ -package host - -import ( - "bytes" - "regexp" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestFile_List(t *testing.T) { - mem := createBasicFS(t) - - f, err := mem.Open("/tmp/etc/hosts") - assert.NoError(t, err) - - m, err := NewWithFs(f.Name(), mem) - assert.NoError(t, err) - - t.Run("Table", func(t *testing.T) { - out := bytes.NewBufferString("\n") - m.List(&ListOptions{Writer: out}) - const expected = ` -+----------+--------+-----------+------------+ -| PROFILE | STATUS | IP | DOMAIN | -+----------+--------+-----------+------------+ -| default | on | 127.0.0.1 | localhost | -+----------+--------+-----------+------------+ -| profile1 | on | 127.0.0.1 | first.loc | -| profile1 | on | 127.0.0.1 | second.loc | -+----------+--------+-----------+------------+ -| profile2 | off | 127.0.0.1 | first.loc | -| profile2 | off | 127.0.0.1 | second.loc | -+----------+--------+-----------+------------+ -` - assertListOutput(t, out.String(), expected) - }) - - t.Run("Table Column order", func(t *testing.T) { - out := bytes.NewBufferString("\n") - m.List(&ListOptions{Writer: out, Columns: []string{"domain", "ip", "status"}}) - const expected = ` -+------------+-----------+--------+ -| DOMAIN | IP | STATUS | -+------------+-----------+--------+ -| localhost | 127.0.0.1 | on | -+------------+-----------+--------+ -| first.loc | 127.0.0.1 | on | -| second.loc | 127.0.0.1 | on | -+------------+-----------+--------+ -| first.loc | 127.0.0.1 | off | -| second.loc | 127.0.0.1 | off | -+------------+-----------+--------+ -` - assertListOutput(t, out.String(), expected) - }) - - t.Run("Table Raw", func(t *testing.T) { - out := bytes.NewBufferString("\n") - m.List(&ListOptions{Writer: out, RawTable: true}) - - const expected = ` -PROFILE STATUS IP DOMAIN -default on 127.0.0.1 localhost -profile1 on 127.0.0.1 first.loc -profile1 on 127.0.0.1 second.loc -profile2 off 127.0.0.1 first.loc -profile2 off 127.0.0.1 second.loc -` - assertListOutput(t, out.String(), expected) - }) - - t.Run("Table Raw Filtered", func(t *testing.T) { - out := bytes.NewBufferString("\n") - m.List(&ListOptions{Writer: out, Profiles: []string{"profile1"}, RawTable: true}) - - const expected = ` -PROFILE STATUS IP DOMAIN -profile1 on 127.0.0.1 first.loc -profile1 on 127.0.0.1 second.loc -` - assertListOutput(t, out.String(), expected) - }) - - t.Run("Table Raw Filtered", func(t *testing.T) { - out := bytes.NewBufferString("\n") - m.List(&ListOptions{ - Writer: out, - Columns: []string{"ip", "domain"}, - Profiles: []string{"profile1"}, - RawTable: true, - }) - - const expected = ` -IP DOMAIN -127.0.0.1 first.loc -127.0.0.1 second.loc -` - assertListOutput(t, expected, out.String()) - }) - - t.Run("Profile status", func(t *testing.T) { - out := bytes.NewBufferString("\n") - m.ProfileStatus(&ListOptions{Writer: out}) - - const expected = ` -+----------+--------+ -| PROFILE | STATUS | -+----------+--------+ -| profile1 | on | -| profile2 | off | -+----------+--------+ -` - assertListOutput(t, out.String(), expected) - }) - - t.Run("Profiles status Raw", func(t *testing.T) { - out := bytes.NewBufferString("\n") - m.ProfileStatus(&ListOptions{Writer: out, RawTable: true}) - - const expected = ` -PROFILE STATUS -profile1 on -profile2 off -` - assertListOutput(t, out.String(), expected) - }) -} - -func assertListOutput(t *testing.T, actual, expected string) { - t.Helper() - - compact := regexp.MustCompile(`[ \t]+`) - got := compact.ReplaceAllString(actual, "") - want := compact.ReplaceAllString(expected, "") - - assert.Contains(t, want, got) -} diff --git a/pkg/host/parser.go b/pkg/host/parser/parser.go similarity index 51% rename from pkg/host/parser.go rename to pkg/host/parser/parser.go index 3d2e26e..e8fe76c 100644 --- a/pkg/host/parser.go +++ b/pkg/host/parser/parser.go @@ -1,4 +1,4 @@ -package host +package parser import ( "bufio" @@ -6,6 +6,10 @@ import ( "net" "regexp" "strings" + + "github.com/guumaster/hostctl/pkg/host/errors" + "github.com/guumaster/hostctl/pkg/host/render" + "github.com/guumaster/hostctl/pkg/host/types" ) var ( @@ -16,11 +20,15 @@ var ( tabReplacer = regexp.MustCompile(`\t+`) ) +type Parser interface { + Parse(reader io.Reader) types.Content +} + // Parse reads content from reader into Data struct. -func Parse(r io.Reader) (*Content, error) { - data := &Content{ +func Parse(r io.Reader) (*types.Content, error) { + data := &types.Content{ ProfileNames: []string{}, - Profiles: map[string]*Profile{}, + Profiles: map[string]*types.Profile{}, } currProfile := "" @@ -35,7 +43,7 @@ func Parse(r io.Reader) (*Content, error) { currProfile = p.Name data.ProfileNames = append(data.ProfileNames, currProfile) - data.Profiles[currProfile] = &Profile{ + data.Profiles[currProfile] = &types.Profile{ Name: currProfile, Status: p.Status, } @@ -44,8 +52,9 @@ func Parse(r io.Reader) (*Content, error) { currProfile = "" case currProfile != "": - profile := data.Profiles[currProfile] - data.Profiles[currProfile] = appendLine(profile, string(b)) + p := data.Profiles[currProfile] + appendLine(p, string(b)) + data.Profiles[currProfile] = p default: row := parseToDefault(b, currProfile) @@ -60,29 +69,43 @@ func Parse(r io.Reader) (*Content, error) { return data, nil } -func parseToDefault(b []byte, currProfile string) *tableRow { - var row *tableRow +func appendLine(p *types.Profile, line string) { + if line == "" { + return + } + + route, ok := ParseLine(line) + if !ok { + return + } + + ip := route.IP.String() + p.AddRoutes(ip, route.HostNames) +} + +func parseToDefault(b []byte, currProfile string) *render.Row { + var row *render.Row if len(b) == 0 { if currProfile == "" { - row = &tableRow{Comment: ""} + row = &render.Row{Comment: ""} } return row } - line, ok := parseLine(string(b)) + line, ok := ParseLine(string(b)) if !ok { - row = &tableRow{ + row = &render.Row{ Comment: string(b), } } else { - status := Enabled + status := types.Enabled if off, _ := regexp.Match("^#", b); off { - status = Disabled + status = types.Disabled } - row = &tableRow{ - Profile: Default, + row = &render.Row{ + Profile: types.Default, Status: string(status), IP: line.IP.String(), Host: line.HostNames[0], @@ -92,68 +115,30 @@ func parseToDefault(b []byte, currProfile string) *tableRow { return row } -func parseProfileHeader(b []byte) (*Profile, error) { +func parseProfileHeader(b []byte) (*types.Profile, error) { rs := profileNameRe.FindSubmatch(b) if len(rs) != 3 || string(rs[2]) == "" { - return nil, ErrInvalidProfileHeader + return nil, errors.ErrInvalidProfileHeader } - status := Enabled - if string(rs[1]) == string(Disabled) { - status = Disabled + status := types.Enabled + if string(rs[1]) == string(types.Disabled) { + status = types.Disabled } - return &Profile{ + return &types.Profile{ Name: strings.TrimSpace(string(rs[2])), Status: status, }, nil } -func appendLine(p *Profile, line string) *Profile { - if line == "" { - return p - } - - route, ok := parseLine(line) - if !ok { - return p - } - - ip := route.IP.String() - p.appendIP(ip) - - switch { - case p.Routes == nil: - p.Routes = map[string]*Route{} - p.Routes[ip] = route - case p.Routes[ip] == nil: - p.Routes[ip] = route - default: - p.Routes[ip].HostNames = append(p.Routes[ip].HostNames, route.HostNames...) - } - - return p -} - -func uniqueStrings(xs []string) []string { - var list []string - - keys := make(map[string]bool) - - for _, entry := range xs { - if _, value := keys[entry]; !value { - keys[entry] = true - - list = append(list, entry) - } - } - - return list -} +// ParseLine checks if a line is a host line or a comment line. +func ParseLine(str string) (*types.Route, bool) { + clean := spaceRemover.ReplaceAllString(str, " ") + clean = tabReplacer.ReplaceAllString(clean, " ") + clean = strings.TrimSpace(clean) -// parseLine checks if a line is a host line or a comment line. -func parseLine(str string) (*Route, bool) { - p := strings.Split(cleanLine(str), " ") + p := strings.Split(clean, " ") i := 0 if p[0] == "#" && len(p) > 1 { @@ -166,13 +151,5 @@ func parseLine(str string) (*Route, bool) { return nil, false } - return &Route{IP: ip, HostNames: p[i+1:]}, true -} - -func cleanLine(line string) string { - clean := spaceRemover.ReplaceAllString(line, " ") - clean = tabReplacer.ReplaceAllString(clean, " ") - clean = strings.TrimSpace(clean) - - return clean + return &types.Route{IP: ip, HostNames: p[i+1:]}, true } diff --git a/pkg/host/parser_test.go b/pkg/host/parser/parser_test.go similarity index 62% rename from pkg/host/parser_test.go rename to pkg/host/parser/parser_test.go index 229f9d7..d93afb1 100644 --- a/pkg/host/parser_test.go +++ b/pkg/host/parser/parser_test.go @@ -1,33 +1,51 @@ -package host +package parser import ( + "net" + "strings" "testing" "github.com/stretchr/testify/assert" + + "github.com/guumaster/hostctl/pkg/host/types" ) func TestHostFile(t *testing.T) { + testFile := ` +127.0.0.1 localhost + +# profile.on profile1 +127.0.0.1 first.loc +127.0.0.1 second.loc +# end + +# profile.off profile2 +# 127.0.0.1 first.loc +# 127.0.0.1 second.loc +# end +` + t.Run("New", func(t *testing.T) { - appFS := createBasicFS(t) + f := strings.NewReader(testFile) + localhost := net.ParseIP("127.0.0.1") - f, _ := appFS.Open("/tmp/etc/hosts") data, err := Parse(f) assert.NoError(t, err) assert.Equal(t, data.ProfileNames, []string{"profile1", "profile2"}) - assert.Equal(t, Enabled, data.Profiles["profile1"].Status) - assert.Equal(t, Disabled, data.Profiles["profile2"].Status) - assert.EqualValues(t, &Route{ - localhost, - []string{"first.loc", "second.loc"}, + assert.Equal(t, types.Enabled, data.Profiles["profile1"].Status) + assert.Equal(t, types.Disabled, data.Profiles["profile2"].Status) + assert.EqualValues(t, &types.Route{ + IP: localhost, + HostNames: []string{"first.loc", "second.loc"}, }, data.Profiles["profile1"].Routes["127.0.0.1"]) }) } func TestParser(t *testing.T) { t.Run("appendLine enabled", func(t *testing.T) { - p := &Profile{ + p := &types.Profile{ Name: "test", - Routes: map[string]*Route{}, + Routes: map[string]*types.Route{}, } appendLine(p, "127.0.0.1 first.loc") appendLine(p, "127.0.0.1 second.loc") @@ -35,18 +53,18 @@ func TestParser(t *testing.T) { }) t.Run("appendLine disabled", func(t *testing.T) { - p := &Profile{ + p := &types.Profile{ Name: "test", - Routes: map[string]*Route{}, + Routes: map[string]*types.Route{}, } appendLine(p, "# 127.0.0.1 first.loc") assert.Len(t, p.Routes["127.0.0.1"].HostNames, 1) }) t.Run("appendLine invalid lines", func(t *testing.T) { - p := &Profile{ + p := &types.Profile{ Name: "test", - Routes: map[string]*Route{}, + Routes: map[string]*types.Route{}, } appendLine(p, "") appendLine(p, "3333 asdfasdfa") @@ -54,22 +72,22 @@ func TestParser(t *testing.T) { }) t.Run("parseProfileHeader", func(t *testing.T) { - list := map[string]*Profile{ + list := map[string]*types.Profile{ "# profile.on something ": { Name: "something", - Status: Enabled, + Status: types.Enabled, }, "# profile.off thisoneoff ": { Name: "thisoneoff", - Status: Disabled, + Status: types.Disabled, }, "# profile another ": { Name: "another", - Status: Enabled, + Status: types.Enabled, }, "# profile another with spaces": { Name: "another with spaces", - Status: Enabled, + Status: types.Enabled, }, "wrong line": nil, } @@ -82,12 +100,4 @@ func TestParser(t *testing.T) { assert.Equal(t, wanted, p, "profile should match") } }) - - t.Run("cleanLines", func(t *testing.T) { - l := cleanLine("# 127.0.0.1 dirty.line ") - assert.Equal(t, "# 127.0.0.1 dirty.line", l) - - l = cleanLine("127.0.0.1\t\t dirty.loc \t second.loc ") - assert.Equal(t, "127.0.0.1 dirty.loc second.loc", l) - }) } diff --git a/pkg/host/profile/profile_new.go b/pkg/host/profile/profile_new.go new file mode 100644 index 0000000..c6b8313 --- /dev/null +++ b/pkg/host/profile/profile_new.go @@ -0,0 +1,61 @@ +package profile + +import ( + "bufio" + "io" + + "github.com/guumaster/hostctl/pkg/host/parser" + "github.com/guumaster/hostctl/pkg/host/types" +) + +// NewProfileFromReader creates a new profile reading lines from a reader +func NewProfileFromReader(r io.Reader, uniq bool) (*types.Profile, error) { + p := &types.Profile{} + s := bufio.NewScanner(r) + + for s.Scan() { + appendLine(p, string(s.Bytes())) + + if err := s.Err(); err != nil { + return nil, err + } + } + + if uniq { + for _, r := range p.Routes { + r.HostNames = uniqueStrings(r.HostNames) + } + } + + return p, nil +} + +func appendLine(p *types.Profile, line string) { + if line == "" { + return + } + + route, ok := parser.ParseLine(line) + if !ok { + return + } + + ip := route.IP.String() + p.AddRoutes(ip, route.HostNames) +} + +func uniqueStrings(xs []string) []string { + var list []string + + keys := make(map[string]bool) + + for _, entry := range xs { + if _, value := keys[entry]; !value { + keys[entry] = true + + list = append(list, entry) + } + } + + return list +} diff --git a/pkg/host/profile_new_test.go b/pkg/host/profile/profile_new_test.go similarity index 96% rename from pkg/host/profile_new_test.go rename to pkg/host/profile/profile_new_test.go index 63b7580..e3227b4 100644 --- a/pkg/host/profile_new_test.go +++ b/pkg/host/profile/profile_new_test.go @@ -1,4 +1,4 @@ -package host +package profile import ( "strings" diff --git a/pkg/host/profile_test.go b/pkg/host/profile/profile_test.go similarity index 80% rename from pkg/host/profile_test.go rename to pkg/host/profile/profile_test.go index 75e7e1b..1c40750 100644 --- a/pkg/host/profile_test.go +++ b/pkg/host/profile/profile_test.go @@ -1,38 +1,46 @@ -package host +package profile import ( + "bytes" + "io/ioutil" "strings" "testing" - "github.com/spf13/afero" "github.com/stretchr/testify/assert" + + "github.com/guumaster/hostctl/pkg/host/types" ) func TestProfile(t *testing.T) { t.Run("String", func(t *testing.T) { - p := Profile{ + p := types.Profile{ Name: "awesome", - Status: Enabled, + Status: types.Enabled, } assert.Equal(t, "[on]awesome", p.String()) - p.Status = Disabled + p.Status = types.Disabled assert.Equal(t, "[off]awesome", p.String()) }) t.Run("Render", func(t *testing.T) { - mem := createBasicFS(t) - - h, err := NewWithFs("/tmp/etc/hosts", mem) + testEnabledProfile := ` +# profile.on profile1 +127.0.0.1 first.loc +127.0.0.1 second.loc +# end +` + r := strings.NewReader(testEnabledProfile) + p, err := NewProfileFromReader(r, true) assert.NoError(t, err) - b, err := mem.Create("memory") - assert.NoError(t, err) + p.Name = "profile1" + p.Status = types.Enabled + b := bytes.NewBufferString("") - p := h.data.Profiles["profile1"] err = p.Render(b) assert.NoError(t, err) - c, err := afero.ReadFile(mem, b.Name()) + c, err := ioutil.ReadAll(b) assert.NoError(t, err) assert.Contains(t, string(c), testEnabledProfile) diff --git a/pkg/host/profile_new.go b/pkg/host/profile_new.go deleted file mode 100644 index 8beaad6..0000000 --- a/pkg/host/profile_new.go +++ /dev/null @@ -1,28 +0,0 @@ -package host - -import ( - "bufio" - "io" -) - -// NewProfileFromReader creates a new profile reading lines from a reader -func NewProfileFromReader(r io.Reader, uniq bool) (*Profile, error) { - p := &Profile{} - s := bufio.NewScanner(r) - - for s.Scan() { - appendLine(p, string(s.Bytes())) - - if err := s.Err(); err != nil { - return nil, err - } - } - - if uniq { - for _, r := range p.Routes { - r.HostNames = uniqueStrings(r.HostNames) - } - } - - return p, nil -} diff --git a/pkg/host/render/json.go b/pkg/host/render/json.go new file mode 100644 index 0000000..418809e --- /dev/null +++ b/pkg/host/render/json.go @@ -0,0 +1,65 @@ +package render + +import ( + "encoding/json" + "io" +) + +type JSONRendererOptions struct { + Writer io.Writer + Columns []string + OnlyEnabled bool +} + +type JSONRenderer struct { + w io.Writer + Columns []string + data *data +} + +type data struct { + lines []line +} + +func NewJSONRenderer(opts *JSONRendererOptions) JSONRenderer { + if len(opts.Columns) == 0 { + opts.Columns = DefaultColumns + } + + return JSONRenderer{ + w: opts.Writer, + Columns: opts.Columns, + data: &data{}, + } +} + +func (j JSONRenderer) AddSeparator() { + // not used +} + +type line struct { + Profile string + Status string + IP string + Host string +} + +func (j JSONRenderer) AppendRow(row *Row) { + if row.Comment != "" { + return + } + + l := line{ + Profile: row.Profile, + Status: row.Status, + IP: row.IP, + Host: row.Host, + } + j.data.lines = append(j.data.lines, l) +} + +func (j JSONRenderer) Render() error { + enc := json.NewEncoder(j.w) + + return enc.Encode(j.data.lines) +} diff --git a/pkg/host/render/markdown.go b/pkg/host/render/markdown.go new file mode 100644 index 0000000..ec300b0 --- /dev/null +++ b/pkg/host/render/markdown.go @@ -0,0 +1,25 @@ +package render + +import ( + "github.com/guumaster/tablewriter" +) + +func NewMarkdownRenderer(opts *TableRendererOptions) TableRenderer { + table := createTableWriter(opts) + + table.SetBorders(tablewriter.Border{Left: true, Top: false, Right: true, Bottom: false}) + table.SetCenterSeparator("|") + + table.SetAutoWrapText(false) + table.SetAutoFormatHeaders(true) + table.SetTablePadding("\t") // pad with tabs + + return TableRenderer{ + Columns: opts.Columns, + table: table, + opts: opts, + meta: &meta{ + rows: 0, + }, + } +} diff --git a/pkg/host/render/raw.go b/pkg/host/render/raw.go new file mode 100644 index 0000000..0bf373d --- /dev/null +++ b/pkg/host/render/raw.go @@ -0,0 +1,31 @@ +package render + +import ( + "github.com/guumaster/tablewriter" +) + +func NewRawRenderer(opts *TableRendererOptions) TableRenderer { + table := createTableWriter(opts) + + table.SetAutoWrapText(false) + table.SetAutoFormatHeaders(true) + table.SetHeaderAlignment(tablewriter.ALIGN_LEFT) + table.SetAlignment(tablewriter.ALIGN_LEFT) + table.SetCenterSeparator("") + table.SetColumnSeparator("\t") + table.SetRowSeparator("") + table.SetHeaderLine(false) + table.SetBorder(false) + table.SetTablePadding("\t") // pad with tabs + table.SetNoWhiteSpace(true) + + return TableRenderer{ + Columns: opts.Columns, + table: table, + opts: opts, + meta: &meta{ + rows: 0, + raw: true, + }, + } +} diff --git a/pkg/host/render/table.go b/pkg/host/render/table.go new file mode 100644 index 0000000..ebb4b1e --- /dev/null +++ b/pkg/host/render/table.go @@ -0,0 +1,89 @@ +package render + +import ( + "io" + "os" + + "github.com/guumaster/tablewriter" +) + +type TableRendererOptions struct { + Writer io.Writer + Columns []string +} + +type TableRenderer struct { + Columns []string + table *tablewriter.Table + opts *TableRendererOptions + meta *meta +} + +func createTableWriter(opts *TableRendererOptions) *tablewriter.Table { + if len(opts.Columns) == 0 { + opts.Columns = DefaultColumns + } + + out := opts.Writer + if out == nil { + out = os.Stdout + } + + table := tablewriter.NewWriter(out) + table.SetHeader(opts.Columns) + + return table +} + +func NewTableRenderer(opts *TableRendererOptions) TableRenderer { + table := createTableWriter(opts) + + return TableRenderer{ + Columns: opts.Columns, + table: table, + opts: opts, + meta: &meta{ + rows: 0, + }, + } +} + +func (t TableRenderer) AppendRow(row *Row) { + r := []string{} + + if row.Comment != "" { + return + } + + for _, c := range t.Columns { + switch c { + case "profile": + r = append(r, row.Profile) + case "status": + r = append(r, row.Status) + case "ip", "ips": + r = append(r, row.IP) + case "domain", "domains": + r = append(r, row.Host) + } + } + + if len(r) > 0 { + t.meta.rows++ + t.table.Append(r) + } +} + +func (t TableRenderer) AddSeparator() { + if !t.meta.raw && t.meta.rows > 0 { + t.table.AddSeparator() + } +} + +func (t TableRenderer) Render() error { + if t.meta.rows > 0 { + t.table.Render() + } + + return nil +} diff --git a/pkg/host/render/types.go b/pkg/host/render/types.go new file mode 100644 index 0000000..686b396 --- /dev/null +++ b/pkg/host/render/types.go @@ -0,0 +1,27 @@ +package render + +// DefaultColumns is the list of default columns to use when showing table list +var DefaultColumns = []string{"profile", "status", "ip", "domain"} + +// ProfilesOnlyColumns are the columns used for profile status list +var ProfilesOnlyColumns = []string{"profile", "status"} + +// Renderer is the interface to render hosts file content +type Renderer interface { + AppendRow(row *Row) + AddSeparator() + Render() error +} + +type Row struct { + Comment string + Profile string + Status string + IP string + Host string +} + +type meta struct { + rows int + raw bool +} diff --git a/pkg/host/replace.go b/pkg/host/replace.go deleted file mode 100644 index 7362064..0000000 --- a/pkg/host/replace.go +++ /dev/null @@ -1,15 +0,0 @@ -package host - -import ( - "errors" -) - -// ReplaceProfile removes previous profile with same name and add new profile to the list -func (f *File) ReplaceProfile(p Profile) error { - err := f.RemoveProfile(p.Name) - if err != nil && !errors.Is(err, ErrUnknownProfile) { - return err - } - - return f.AddProfile(p) -} diff --git a/pkg/host/table.go b/pkg/host/table.go deleted file mode 100644 index 3f244db..0000000 --- a/pkg/host/table.go +++ /dev/null @@ -1,56 +0,0 @@ -package host - -import ( - "os" - - "github.com/guumaster/tablewriter" -) - -func createTableWriter(opts *ListOptions) *tablewriter.Table { - out := opts.Writer - if out == nil { - out = os.Stdout - } - - table := tablewriter.NewWriter(out) - table.SetHeader(opts.Columns) - - if opts.RawTable { - table.SetAutoWrapText(false) - table.SetAutoFormatHeaders(true) - table.SetHeaderAlignment(tablewriter.ALIGN_LEFT) - table.SetAlignment(tablewriter.ALIGN_LEFT) - table.SetCenterSeparator("") - table.SetColumnSeparator("\t") - table.SetRowSeparator("") - table.SetHeaderLine(false) - table.SetBorder(false) - table.SetTablePadding("\t") // pad with tabs - table.SetNoWhiteSpace(true) - } - - return table -} - -func getRow(line *tableRow, columns []string) []string { - row := []string{} - - if line.Comment != "" { - return row - } - - for _, c := range columns { - switch c { - case "profile": - row = append(row, line.Profile) - case "status": - row = append(row, line.Status) - case "ip", "ips": - row = append(row, line.IP) - case "domain", "domains": - row = append(row, line.Host) - } - } - - return row -} diff --git a/pkg/host/toggle.go b/pkg/host/toggle.go deleted file mode 100644 index 3915eb4..0000000 --- a/pkg/host/toggle.go +++ /dev/null @@ -1,25 +0,0 @@ -package host - -// Toggle alternates between enable and disable status of a profile. -func (f *File) Toggle(profiles []string) error { - for _, p := range profiles { - if p == Default { - continue - } - - profile, ok := f.data.Profiles[p] - if !ok { - return ErrUnknownProfile - } - - if profile.Status == Enabled { - profile.Status = Disabled - } else { - profile.Status = Enabled - } - - f.data.Profiles[p] = profile - } - - return nil -} diff --git a/pkg/host/types.go b/pkg/host/types.go deleted file mode 100644 index 00ab3e2..0000000 --- a/pkg/host/types.go +++ /dev/null @@ -1,67 +0,0 @@ -package host - -import ( - "net" - "sync" - - "github.com/spf13/afero" -) - -const banner = ` -################################################################## -# Content under this line is handled by hostctl. DO NOT EDIT. -##################################################################` - -// File container to handle a hosts file -type File struct { - fs afero.Fs - src afero.File - data *Content - hasBanner bool - mutex sync.Mutex -} - -// Content contains complete data of all profiles -type Content struct { - DefaultProfile DefaultProfile - ProfileNames []string - Profiles map[string]*Profile -} - -// Profile contains all data of a single profile -type Profile struct { - Name string - Status ProfileStatus - IPList []string - Routes map[string]*Route -} - -// DefaultProfile contains data for the default profile -type DefaultProfile []*tableRow - -type tableRow struct { - Comment string - Profile string - Status string - IP string - Host string -} - -// Route contains hostnames of all routes with the same IP -type Route struct { - IP net.IP - HostNames []string -} - -// ProfileStatus represents the status of a Profile -type ProfileStatus string - -const ( - // Enabled marks a profile active on your hosts file. - Enabled ProfileStatus = "on" - // Disabled marks a profile not active on your hosts file. - Disabled ProfileStatus = "off" - - // Default is the name of the default profile - Default = "default" -) diff --git a/pkg/host/profile.go b/pkg/host/types/profile.go similarity index 71% rename from pkg/host/profile.go rename to pkg/host/types/profile.go index b217fb4..3dba838 100644 --- a/pkg/host/profile.go +++ b/pkg/host/types/profile.go @@ -1,9 +1,42 @@ -package host +package types import ( "fmt" "io" "net" + + "github.com/guumaster/hostctl/pkg/host/errors" + "github.com/guumaster/hostctl/pkg/host/render" +) + +// Profile contains all data of a single profile +type Profile struct { + Name string + Status Status + IPList []string + Routes map[string]*Route +} + +// DefaultProfile contains data for the default profile +type DefaultProfile []*render.Row + +// Route contains hostnames of all routes with the same IP +type Route struct { + IP net.IP + HostNames []string +} + +// ProfileStatus represents the status of a Profile +type Status string + +const ( + // Enabled marks a profile active on your hosts file. + Enabled Status = "on" + // Disabled marks a profile not active on your hosts file. + Disabled Status = "off" + + // Default is the name of the default profile + Default = "default" ) // String returns a string representation of the profile @@ -41,6 +74,10 @@ func (p *Profile) AddRoute(ip, hostname string) { // AddRoutes adds multiple routes to the profile func (p *Profile) AddRoutes(ip string, hostnames []string) { + if p.Routes == nil { + p.Routes = map[string]*Route{} + } + if p.Routes[ip] == nil { p.appendIP(ip) p.Routes[ip] = &Route{ @@ -68,12 +105,12 @@ func (p *Profile) RemoveRoutes(hostnames []string) { func (p *Profile) GetHostNames(ip string) ([]string, error) { key := net.ParseIP(ip) if key == nil { - return nil, fmt.Errorf("%w '%s'", ErrInvalidIP, ip) + return nil, fmt.Errorf("%w '%s'", errors.ErrInvalidIP, ip) } hosts, ok := p.Routes[key.String()] if !ok { - return nil, fmt.Errorf("%w: %s[%s] ", ErrNotPresentIP, key, p.Name) + return nil, fmt.Errorf("%w: %s[%s] ", errors.ErrNotPresentIP, key, p.Name) } return hosts.HostNames, nil @@ -143,3 +180,15 @@ func (d DefaultProfile) Render(w io.StringWriter) error { return nil } + +func remove(s []string, n string) []string { + list := []string{} + + for _, x := range s { + if x != n { + list = append(list, x) + } + } + + return list +} diff --git a/pkg/host/types/types.go b/pkg/host/types/types.go new file mode 100644 index 0000000..96e4b9d --- /dev/null +++ b/pkg/host/types/types.go @@ -0,0 +1,13 @@ +package types + +const Banner = ` +################################################################## +# Content under this line is handled by hostctl. DO NOT EDIT. +##################################################################` + +// Content contains complete data of all profiles +type Content struct { + DefaultProfile DefaultProfile + ProfileNames []string + Profiles map[string]*Profile +}