diff --git a/README.md b/README.md index 6f9fe9355..b7614e68a 100644 --- a/README.md +++ b/README.md @@ -26,11 +26,12 @@ These steps will get you started with running everything on your local system. Y ```bash sudo cp cert.crt /usr/local/share/ca-certificates sudo update-ca-certificates + ``` 1. Run the OTS daemon: ```bash - ./otsd -ssl -cert-file cert.crt -key-file key.pem + ./otsd --ssl --cert-file=cert.crt --key-file=key.pem ``` The daemon runs in the foreground and can be left to run. @@ -38,28 +39,13 @@ These steps will get you started with running everything on your local system. Y 1. In another terminal create an organization: ```bash - curl -H"Accept: application/vnd.api+json" https://localhost:8080/api/v2/organizations -d'{ - "data": { - "type": "organizations", - "attributes": { - "name": "mycorp", - "email": "sysadmin@mycorp.co" - } - } - }' + ./ots organizations new mycorp --email=sysadmin@mycorp.co ``` -1. Enter some dummy credentials (this is necessary otherwise terraform will complain): + +1. Login to your OTS server (this merely adds some dummy credentials to `~/.terraform.d/credentials.tfrc.json`): ```bash - cat > ~/.terraform.d/credentials.tfrc.json <: +func (c *clientConfig) sanitizeAddress() error { + u, err := url.ParseRequestURI(c.Address) + if err != nil || u.Host == "" { + u, er := url.ParseRequestURI("https://" + c.Address) + if er != nil { + return fmt.Errorf("could not parse hostname: %w", err) + } + c.Address = u.String() + return nil + } + + u.Scheme = "https" + c.Address = u.String() + + return nil +} diff --git a/cmd/ots/client_test.go b/cmd/ots/client_test.go new file mode 100644 index 000000000..400cad517 --- /dev/null +++ b/cmd/ots/client_test.go @@ -0,0 +1,44 @@ +package main + +import ( + "testing" + + "github.com/hashicorp/go-tfe" + "github.com/stretchr/testify/assert" +) + +func TestClientSanitizeAddress(t *testing.T) { + tests := []struct { + name string + address string + want string + }{ + { + name: "add scheme", + address: "localhost:8080", + want: "https://localhost:8080", + }, + { + name: "already has scheme", + address: "https://localhost:8080", + want: "https://localhost:8080", + }, + { + name: "has wrong scheme", + address: "http://localhost:8080", + want: "https://localhost:8080", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := clientConfig{ + Config: tfe.Config{ + Address: tt.address, + }, + } + if assert.NoError(t, cfg.sanitizeAddress()) { + assert.Equal(t, tt.want, cfg.Address) + } + }) + } +} diff --git a/cmd/ots/credentials_store.go b/cmd/ots/credentials_store.go new file mode 100644 index 000000000..3adb3011f --- /dev/null +++ b/cmd/ots/credentials_store.go @@ -0,0 +1,131 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "net/url" + "os" + "path/filepath" +) + +const ( + CredentialsPath = ".terraform.d/credentials.tfrc.json" +) + +type CredentialsConfig struct { + Credentials map[string]TokenConfig `json:"credentials"` +} + +type TokenConfig struct { + Token string `json:"token"` +} + +// CredentialsStore is a JSON file in a user's home dir that stores tokens for +// one or more TFE-type hosts +type CredentialsStore string + +// NewCredentialsStore is a contructor for CredentialsStore +func NewCredentialsStore(dirs Directories) (CredentialsStore, error) { + // Construct full path to creds config + home, err := dirs.UserHomeDir() + if err != nil { + return "", err + } + path := filepath.Join(home, CredentialsPath) + + return CredentialsStore(path), nil +} + +// Load retrieves the token for hostname +func (c CredentialsStore) Load(hostname string) (string, error) { + hostname, err := c.sanitizeHostname(hostname) + if err != nil { + return "", err + } + + config, err := c.read() + if err != nil { + return "", err + } + + tokenConfig, ok := config.Credentials[hostname] + if !ok { + return "", fmt.Errorf("credentials for %s not found in %s", hostname, c) + } + + return tokenConfig.Token, nil +} + +// Save saves the token for the given hostname to the store, overwriting any +// existing tokens for the hostname. +func (c CredentialsStore) Save(hostname, token string) error { + hostname, err := c.sanitizeHostname(hostname) + if err != nil { + return err + } + + config, err := c.read() + if err != nil { + return err + } + + config.Credentials[hostname] = TokenConfig{ + Token: token, + } + + if err := c.write(config); err != nil { + return err + } + + return nil +} + +func (c CredentialsStore) read() (*CredentialsConfig, error) { + // Construct credentials config obj + config := CredentialsConfig{Credentials: make(map[string]TokenConfig)} + + // Read any existing file contents + data, err := os.ReadFile(string(c)) + if err == nil { + if err := json.Unmarshal(data, &config); err != nil { + return nil, err + } + } else if !errors.Is(err, os.ErrNotExist) { + return nil, err + } + + return &config, nil +} + +func (c CredentialsStore) write(config *CredentialsConfig) error { + data, err := json.MarshalIndent(&config, "", " ") + if err != nil { + return err + } + + // Ensure all parent directories of config file exist + if err := os.MkdirAll(filepath.Dir(string(c)), 0775); err != nil { + return err + } + + if err := os.WriteFile(string(c), data, 0600); err != nil { + return err + } + + return nil +} + +// Ensure hostname is in the format : +func (c CredentialsStore) sanitizeHostname(hostname string) (string, error) { + u, err := url.ParseRequestURI(hostname) + if err != nil || u.Host == "" { + u, er := url.ParseRequestURI("https://" + hostname) + if er != nil { + return "", fmt.Errorf("could not parse hostname: %w", err) + } + return u.Host, nil + } + + return u.Host, nil +} diff --git a/cmd/ots/credentials_store_test.go b/cmd/ots/credentials_store_test.go new file mode 100644 index 000000000..362b98c1b --- /dev/null +++ b/cmd/ots/credentials_store_test.go @@ -0,0 +1,97 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCredentialsStore(t *testing.T) { + tmpdir := t.TempDir() + + store, err := NewCredentialsStore(FakeDirectories(tmpdir)) + require.NoError(t, err) + + require.NoError(t, store.Save("ots.dev:8080", "dummy")) + + token, err := store.Load("ots.dev:8080") + require.NoError(t, err) + + assert.Equal(t, "dummy", token) +} + +func TestCredentialsStoreWithExistingCredentials(t *testing.T) { + // Write a config file with existing creds for TFC + existing := `{ + "credentials": { + "app.terraform.io": { + "token": "secret" + } + } + } +` + tmpdir := t.TempDir() + + path := filepath.Join(tmpdir, CredentialsPath) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0755)) + require.NoError(t, os.WriteFile(path, []byte(existing), 0600)) + + store, err := NewCredentialsStore(FakeDirectories(tmpdir)) + require.NoError(t, err) + + require.NoError(t, store.Save("ots.dev:8080", "dummy")) + + got, err := os.ReadFile(path) + require.NoError(t, err) + + want := `{ + "credentials": { + "app.terraform.io": { + "token": "secret" + }, + "ots.dev:8080": { + "token": "dummy" + } + } +}` + + assert.Equal(t, want, string(got)) +} + +func TestCredentialsStoreSanitizeAddress(t *testing.T) { + tests := []struct { + name string + address string + want string + }{ + { + name: "no scheme", + address: "localhost:8080", + want: "localhost:8080", + }, + { + name: "has scheme", + address: "https://localhost:8080", + want: "localhost:8080", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpdir := t.TempDir() + + store, err := NewCredentialsStore(FakeDirectories(tmpdir)) + require.NoError(t, err) + + address, err := store.sanitizeHostname(tt.address) + require.NoError(t, err) + assert.Equal(t, tt.want, address) + }) + } +} + +type FakeDirectories string + +func (f FakeDirectories) UserHomeDir() (string, error) { return string(f), nil } diff --git a/cmd/ots/login.go b/cmd/ots/login.go index fa5099fea..2112a30c4 100644 --- a/cmd/ots/login.go +++ b/cmd/ots/login.go @@ -1,88 +1,38 @@ package main import ( - "encoding/json" - "errors" "fmt" - "os" - "path/filepath" "github.com/spf13/cobra" ) const ( - CredentialsPath = ".terraform.d/credentials.tfrc.json" - DummyToken = "dummy" + DummyToken = "dummy" ) -var ( - ErrMissingHostname = errors.New("--hostname must be set to the hostname of the OTS server") -) - -type CredentialsConfig struct { - Credentials map[string]TokenConfig `json:"credentials"` -} - -type TokenConfig struct { - Token string `json:"token"` -} - func LoginCommand(dirs Directories) *cobra.Command { - var hostname string + var address string cmd := &cobra.Command{ Use: "login", Short: "Login to OTS", RunE: func(cmd *cobra.Command, args []string) error { - if hostname == "" { - return ErrMissingHostname - } - - // Construct full path to creds config - home, err := dirs.UserHomeDir() - if err != nil { - return err - } - path := filepath.Join(home, CredentialsPath) - - // Construct credentials config obj - config := CredentialsConfig{Credentials: make(map[string]TokenConfig)} - - // Read any existing file contents - data, err := os.ReadFile(path) - if err == nil { - if err := json.Unmarshal(data, &config); err != nil { - return err - } - } else if !errors.Is(err, os.ErrNotExist) { - return err - } - - // Update file with dummy token for OTS instance - config.Credentials[hostname] = TokenConfig{ - Token: DummyToken, - } - data, err = json.MarshalIndent(&config, "", " ") + store, err := NewCredentialsStore(dirs) if err != nil { return err } - // Ensure all parent directories of config file exist - if err := os.MkdirAll(filepath.Dir(path), 0775); err != nil { - return err - } - - if err := os.WriteFile(path, data, 0600); err != nil { + if err := store.Save(address, DummyToken); err != nil { return err } - fmt.Printf("Successfully added credentials to %s\n", path) + fmt.Printf("Successfully added credentials for %s to %s\n", address, store) return nil }, } - cmd.Flags().StringVar(&hostname, "hostname", os.Getenv("OTS_HOSTNAME"), "Name of server deployment") + cmd.Flags().StringVar(&address, "address", DefaultAddress, "Address of OTS instance") return cmd } diff --git a/cmd/ots/login_test.go b/cmd/ots/login_test.go index f3482aeb5..0e14902b9 100644 --- a/cmd/ots/login_test.go +++ b/cmd/ots/login_test.go @@ -2,7 +2,6 @@ package main import ( "os" - "path/filepath" "testing" "github.com/stretchr/testify/assert" @@ -12,70 +11,28 @@ import ( func TestLoginCommand(t *testing.T) { tmpdir := t.TempDir() - cmd := LoginCommand(FakeHomeDir(tmpdir)) - cmd.SetArgs([]string{"--hostname", "ots.dev:9898"}) + cmd := LoginCommand(FakeDirectories(tmpdir)) require.NoError(t, cmd.Execute()) - got, err := os.ReadFile(filepath.Join(tmpdir, CredentialsPath)) + store, err := NewCredentialsStore(FakeDirectories(tmpdir)) require.NoError(t, err) - - want := `{ - "credentials": { - "ots.dev:9898": { - "token": "dummy" - } - } -}` - - assert.Equal(t, want, string(got)) + token, err := store.Load("localhost:8080") + require.NoError(t, err) + assert.Equal(t, "dummy", token) } -// Test login command doesn't overwrite any existing credentials for TFE etc -func TestLoginCommandWithExistingCredentials(t *testing.T) { - // Write a config file with existing creds - existing := `{ - "credentials": { - "app.terraform.io": { - "token": "secret" - } - } - } -` - tmpdir := t.TempDir() +func TestLoginCommandWithExplicitAddress(t *testing.T) { + // Ensure env var doesn't interfere with test + os.Unsetenv("OTS_ADDRESS") - path := filepath.Join(tmpdir, CredentialsPath) - require.NoError(t, os.MkdirAll(filepath.Dir(path), 0755)) - require.NoError(t, os.WriteFile(path, []byte(existing), 0600)) + tmpdir := t.TempDir() - cmd := LoginCommand(FakeHomeDir(tmpdir)) - cmd.SetArgs([]string{"--hostname", "ots.dev:9898"}) + cmd := LoginCommand(FakeDirectories(tmpdir)) + cmd.SetArgs([]string{"--address", "ots.dev:8080"}) require.NoError(t, cmd.Execute()) - got, err := os.ReadFile(path) + store, err := NewCredentialsStore(FakeDirectories(tmpdir)) + require.NoError(t, err) + _, err = store.Load("ots.dev:8080") require.NoError(t, err) - - want := `{ - "credentials": { - "app.terraform.io": { - "token": "secret" - }, - "ots.dev:9898": { - "token": "dummy" - } - } -}` - - assert.Equal(t, want, string(got)) -} - -func TestLoginCommandNoHostname(t *testing.T) { - // Ensure env var doesn't interfere with test - os.Unsetenv("OTS_HOSTNAME") - - cmd := LoginCommand(nil) - require.Equal(t, ErrMissingHostname, cmd.Execute()) } - -type FakeHomeDir string - -func (f FakeHomeDir) UserHomeDir() (string, error) { return string(f), nil } diff --git a/cmd/ots/main.go b/cmd/ots/main.go index bba1f7f1d..7aecaf1ab 100644 --- a/cmd/ots/main.go +++ b/cmd/ots/main.go @@ -1,13 +1,19 @@ package main import ( + "context" "fmt" "os" + cmdutil "github.com/leg100/ots/cmd" "github.com/spf13/cobra" ) func main() { + // Configure ^C to terminate program + ctx, cancel := context.WithCancel(context.Background()) + cmdutil.CatchCtrlC(cancel) + cmd := &cobra.Command{ Use: "ots", SilenceUsage: true, @@ -15,8 +21,11 @@ func main() { } cmd.AddCommand(LoginCommand(&SystemDirectories{})) + cmd.AddCommand(OrganizationCommand()) + + cmdutil.SetFlagsFromEnvVariables(cmd.Flags()) - if err := cmd.Execute(); err != nil { + if err := cmd.ExecuteContext(ctx); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } diff --git a/cmd/ots/organization.go b/cmd/ots/organization.go new file mode 100644 index 000000000..42a6f105e --- /dev/null +++ b/cmd/ots/organization.go @@ -0,0 +1,22 @@ +package main + +import ( + "os" + + "github.com/spf13/cobra" +) + +func OrganizationCommand() *cobra.Command { + cfg := clientConfig{} + + cmd := &cobra.Command{ + Use: "organizations", + Short: "Organization management", + } + cmd.Flags().StringVar(&cfg.Address, "address", DefaultAddress, "Address of OTS server") + cmd.Flags().StringVar(&cfg.Token, "token", os.Getenv("OTS_TOKEN"), "Authentication token") + + cmd.AddCommand(OrganizationNewCommand(&cfg)) + + return cmd +} diff --git a/cmd/ots/organization_new.go b/cmd/ots/organization_new.go new file mode 100644 index 000000000..427989b3b --- /dev/null +++ b/cmd/ots/organization_new.go @@ -0,0 +1,41 @@ +package main + +import ( + "fmt" + + "github.com/hashicorp/go-tfe" + "github.com/leg100/ots" + "github.com/spf13/cobra" +) + +func OrganizationNewCommand(config ClientConfig) *cobra.Command { + opts := tfe.OrganizationCreateOptions{} + + cmd := &cobra.Command{ + Use: "new [name]", + Short: "Create a new organization", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + opts.Name = ots.String(args[0]) + + client, err := config.NewClient() + if err != nil { + return err + } + + org, err := client.Organizations().Create(cmd.Context(), opts) + if err != nil { + return err + } + + fmt.Printf("Successfully created organization %s\n", org.Name) + + return nil + }, + } + + opts.Email = cmd.Flags().String("email", "", "Email of the owner of the organization") + cmd.MarkFlagRequired("email") + + return cmd +} diff --git a/cmd/ots/organization_new_test.go b/cmd/ots/organization_new_test.go new file mode 100644 index 000000000..eff7e91a7 --- /dev/null +++ b/cmd/ots/organization_new_test.go @@ -0,0 +1,49 @@ +package main + +import ( + "context" + "testing" + + "github.com/hashicorp/go-tfe" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOrganizationCommand(t *testing.T) { + cmd := OrganizationNewCommand(&FakeClientConfig{}) + cmd.SetArgs([]string{"automatize", "--email", "sysadmin@automatize.co"}) + require.NoError(t, cmd.Execute()) +} + +func TestOrganizationCommandMissingName(t *testing.T) { + cmd := OrganizationNewCommand(&FakeClientConfig{}) + cmd.SetArgs([]string{"--email", "sysadmin@automatize.co"}) + err := cmd.Execute() + assert.EqualError(t, err, "accepts 1 arg(s), received 0") +} + +func TestOrganizationCommandMissingEmail(t *testing.T) { + cmd := OrganizationNewCommand(&FakeClientConfig{}) + cmd.SetArgs([]string{"automatize"}) + err := cmd.Execute() + assert.EqualError(t, err, "required flag(s) \"email\" not set") +} + +type FakeClientConfig struct{} + +func (f FakeClientConfig) NewClient() (Client, error) { return &FakeClient{}, nil } + +type FakeClient struct{} + +func (f FakeClient) Organizations() tfe.Organizations { return &FakeOrganizationsClient{} } + +type FakeOrganizationsClient struct { + tfe.Organizations +} + +func (f *FakeOrganizationsClient) Create(ctx context.Context, opts tfe.OrganizationCreateOptions) (*tfe.Organization, error) { + return &tfe.Organization{ + Name: *opts.Name, + Email: *opts.Email, + }, nil +} diff --git a/cmd/otsd/main.go b/cmd/otsd/main.go index 756cec406..1aa44bfc0 100644 --- a/cmd/otsd/main.go +++ b/cmd/otsd/main.go @@ -2,23 +2,21 @@ package main import ( "context" - "flag" "fmt" "os" - "os/signal" - "strings" + cmdutil "github.com/leg100/ots/cmd" "github.com/leg100/ots/http" "github.com/leg100/ots/sqlite" + "github.com/spf13/cobra" driver "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" ) const ( - DefaultAddress = ":8080" - EnvironmentVariablePrefix = "OTS_" - DefaultDBPath = "ots.db" + DefaultAddress = ":8080" + DefaultDBPath = "ots.db" ) var ( @@ -26,30 +24,29 @@ var ( ) func main() { - // Setup signal handlers. + // Configure ^C to terminate program ctx, cancel := context.WithCancel(context.Background()) - c := make(chan os.Signal, 1) - signal.Notify(c, os.Interrupt) - go func() { <-c; cancel() }() + cmdutil.CatchCtrlC(cancel) server := http.NewServer() - fs := flag.NewFlagSet("otsd", flag.ContinueOnError) - fs.StringVar(&server.Addr, "address", DefaultAddress, "Listening address") - fs.BoolVar(&server.SSL, "ssl", false, "Toggle SSL") - fs.StringVar(&server.CertFile, "cert-file", "", "Path to SSL certificate (required if enabling SSL)") - fs.StringVar(&server.KeyFile, "key-file", "", "Path to SSL key (required if enabling SSL)") - fs.StringVar(&DBPath, "db-path", DefaultDBPath, "Path to SQLite database file") + cmd := &cobra.Command{ + Use: "otsd", + SilenceUsage: true, + SilenceErrors: true, + } - SetFlagsFromEnvVariables(fs) + cmd.Flags().StringVar(&server.Addr, "address", DefaultAddress, "Listening address") + cmd.Flags().BoolVar(&server.SSL, "ssl", false, "Toggle SSL") + cmd.Flags().StringVar(&server.CertFile, "cert-file", "", "Path to SSL certificate (required if enabling SSL)") + cmd.Flags().StringVar(&server.KeyFile, "key-file", "", "Path to SSL key (required if enabling SSL)") + cmd.Flags().StringVar(&DBPath, "db-path", DefaultDBPath, "Path to SQLite database file") - if err := fs.Parse(os.Args[1:]); err != nil { - panic(err.Error()) - } + cmdutil.SetFlagsFromEnvVariables(cmd.Flags()) if server.SSL { if server.CertFile == "" || server.KeyFile == "" { - fmt.Fprintf(os.Stderr, "must provide both -cert-file and -key-file") + fmt.Fprintf(os.Stderr, "must provide both --cert-file and --key-file") os.Exit(1) } } @@ -79,29 +76,3 @@ func main() { os.Exit(1) } } - -// Each flag can also be set with an env variable whose name starts with `OTS_`. -func SetFlagsFromEnvVariables(fs *flag.FlagSet) { - fs.VisitAll(func(f *flag.Flag) { - envVar := flagToEnvVarName(f) - if val, present := os.LookupEnv(envVar); present { - fs.Set(f.Name, val) - } - }) -} - -// Unset env vars prefixed with `OTS_` -func UnsetEtokVars() { - for _, kv := range os.Environ() { - parts := strings.Split(kv, "=") - if strings.HasPrefix(parts[0], EnvironmentVariablePrefix) { - if err := os.Unsetenv(parts[0]); err != nil { - panic(err.Error()) - } - } - } -} - -func flagToEnvVarName(f *flag.Flag) string { - return fmt.Sprintf("%s%s", EnvironmentVariablePrefix, strings.Replace(strings.ToUpper(f.Name), "-", "_", -1)) -} diff --git a/cmd/signals.go b/cmd/signals.go new file mode 100644 index 000000000..d1a479981 --- /dev/null +++ b/cmd/signals.go @@ -0,0 +1,22 @@ +package cmd + +import ( + "context" + "os" + "os/signal" + "syscall" +) + +func CatchCtrlC(cancel context.CancelFunc) { + signals := make(chan os.Signal, 1) + signal.Notify(signals, + syscall.SIGTERM, + syscall.SIGINT, + ) + + go func() { + <-signals + signal.Stop(signals) + cancel() + }() +} diff --git a/cmd/signals_test.go b/cmd/signals_test.go new file mode 100644 index 000000000..fe52a1071 --- /dev/null +++ b/cmd/signals_test.go @@ -0,0 +1,25 @@ +package cmd + +import ( + "context" + "sync" + "syscall" + "testing" +) + +func TestCatchCtrlC(t *testing.T) { + var wg sync.WaitGroup + wg.Add(1) + + ctx, cancel := context.WithCancel(context.Background()) + CatchCtrlC(cancel) + + go func() { + <-ctx.Done() + wg.Done() + }() + + syscall.Kill(syscall.Getpid(), syscall.SIGINT) + + wg.Wait() +} diff --git a/go.mod b/go.mod index 65436354c..464ed3e49 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/hashicorp/jsonapi v0.0.0-20210518035559-1e50d74c8db3 github.com/mattn/go-sqlite3 v1.14.7 // indirect github.com/spf13/cobra v1.1.3 + github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.3.0 github.com/urfave/negroni v1.0.0 gorm.io/driver/sqlite v1.1.4