diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index e4c481b3..9303f3a9 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -32,6 +32,8 @@ jobs: go-version: ^1.18 - name: Test + env: + COCKROACHDB_INTEGRATION: "1" run: make test - name: Validate diff --git a/plugins/cockroachdb/cockroach.go b/plugins/cockroachdb/cockroach.go new file mode 100644 index 00000000..f550a080 --- /dev/null +++ b/plugins/cockroachdb/cockroach.go @@ -0,0 +1,26 @@ +package cockroachdb + +import ( + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/needsauth" + "github.com/1Password/shell-plugins/sdk/schema" + "github.com/1Password/shell-plugins/sdk/schema/credname" +) + +func Cockroach() schema.Executable { + return schema.Executable{ + Name: "cockroach", + Runs: []string{"cockroach"}, + DocsURL: sdk.URL("https://www.cockroachlabs.com/docs/stable/cockroach-sql.html"), + NeedsAuth: needsauth.IfAll( + needsauth.NotForHelpOrVersion(), + needsauth.ForCommand("sql"), + needsauth.NotWithoutArgs(), + ), + Uses: []schema.CredentialUsage{ + { + Name: credname.DatabaseCredentials, + }, + }, + } +} diff --git a/plugins/cockroachdb/database_credentials.go b/plugins/cockroachdb/database_credentials.go new file mode 100644 index 00000000..76b92dc3 --- /dev/null +++ b/plugins/cockroachdb/database_credentials.go @@ -0,0 +1,59 @@ +package cockroachdb + +import ( + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/importer" + "github.com/1Password/shell-plugins/sdk/provision" + "github.com/1Password/shell-plugins/sdk/schema" + "github.com/1Password/shell-plugins/sdk/schema/credname" + "github.com/1Password/shell-plugins/sdk/schema/fieldname" +) + +func DatabaseCredentials() schema.CredentialType { + return schema.CredentialType{ + Name: credname.DatabaseCredentials, + DocsURL: sdk.URL("https://www.cockroachlabs.com/docs/stable/connection-parameters.html"), + Fields: []schema.CredentialField{ + { + Name: fieldname.Host, + MarkdownDescription: "CockroachDB host to connect to.", + }, + { + Name: fieldname.Port, + MarkdownDescription: "Port used to connect to CockroachDB.", + Optional: true, + }, + { + Name: fieldname.User, + MarkdownDescription: "CockroachDB user to authenticate as.", + }, + { + Name: fieldname.Password, + MarkdownDescription: "Password used to authenticate to CockroachDB.", + Secret: true, + Optional: true, + }, + { + Name: fieldname.Database, + MarkdownDescription: "Database name to connect to. Defaults to 'defaultdb'.", + Optional: true, + }, + { + Name: "Insecure", + MarkdownDescription: "Connect without TLS. Set to '1' only for an insecure development cluster.", + Optional: true, + }, + }, + DefaultProvisioner: provision.EnvVars(defaultEnvVarMapping), + Importer: importer.TryEnvVarPair(defaultEnvVarMapping), + } +} + +var defaultEnvVarMapping = map[string]sdk.FieldName{ + "COCKROACH_HOST": fieldname.Host, + "COCKROACH_PORT": fieldname.Port, + "COCKROACH_USER": fieldname.User, + "PGPASSWORD": fieldname.Password, + "COCKROACH_DATABASE": fieldname.Database, + "COCKROACH_INSECURE": "Insecure", +} diff --git a/plugins/cockroachdb/database_credentials_integration_test.go b/plugins/cockroachdb/database_credentials_integration_test.go new file mode 100644 index 00000000..b252de06 --- /dev/null +++ b/plugins/cockroachdb/database_credentials_integration_test.go @@ -0,0 +1,165 @@ +package cockroachdb + +import ( + "context" + "maps" + "os" + "os/exec" + "strings" + "testing" + "time" + + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/schema/fieldname" +) + +// Run with COCKROACHDB_INTEGRATION=1 make test. Docker pulls the pinned image if needed. +func TestDatabaseCredentialsIntegration(t *testing.T) { + if os.Getenv("COCKROACHDB_INTEGRATION") != "1" { + t.Skip("Set COCKROACHDB_INTEGRATION=1 to test against CockroachDB in Docker") + } + + for _, secure := range []bool{false, true} { + name := "insecure" + if secure { + name = "TLS" + } + t.Run(name, func(t *testing.T) { + start := "exec cockroach start-single-node --listen-addr=localhost:26258 --store=type=mem,size=1GiB --cache=64MiB --max-sql-memory=64MiB " + adminArgs := []string{"--host=localhost:26258"} + if secure { + start = "mkdir /certs && cockroach cert create-ca --certs-dir=/certs --ca-key=/certs/ca.key && " + + "cockroach cert create-node localhost --certs-dir=/certs --ca-key=/certs/ca.key && " + + "cockroach cert create-client root --certs-dir=/certs --ca-key=/certs/ca.key && " + start + "--certs-dir=/certs" + adminArgs = append(adminArgs, "--certs-dir=/certs") + } else { + start += "--insecure" + adminArgs = append(adminArgs, "--insecure") + } + container, err := cockroachDocker("run", "--detach", "--entrypoint=sh", "cockroachdb/cockroach:v25.2.4", "-c", start) + if err != nil { + t.Fatalf("Start CockroachDB: %v\n%s", err, container) + } + container = strings.TrimSpace(container) + t.Cleanup(func() { + if output, err := cockroachDocker("rm", "--force", container); err != nil { + t.Errorf("Remove test container: %v\n%s", err, output) + } + }) + admin := append([]string{"exec", container, "cockroach", "sql"}, adminArgs...) + deadline := time.Now().Add(90 * time.Second) + for { + output, err := cockroachDocker(append(admin, "--execute=SELECT 1")...) + if err == nil { + break + } + running, inspectErr := cockroachDocker("inspect", "--format={{.State.Running}}", container) + if inspectErr != nil || strings.TrimSpace(running) != "true" || time.Now().After(deadline) { + logs, _ := cockroachDocker("logs", container) + t.Fatalf("CockroachDB did not become ready: %v\n%s\n%s", err, output, logs) + } + time.Sleep(time.Second) + } + setup := "CREATE DATABASE plugin_test; CREATE USER shell_plugin; GRANT ALL ON DATABASE plugin_test TO shell_plugin;" + if secure { + setup += "ALTER USER shell_plugin WITH PASSWORD 'integration-password';" + } + if output, err := cockroachDocker(append(admin, "--execute="+setup)...); err != nil { + t.Fatalf("Create test credentials: %v\n%s", err, output) + } + + fields := map[sdk.FieldName]string{ + fieldname.Host: "localhost", fieldname.Port: "26258", + fieldname.User: "shell_plugin", fieldname.Database: "plugin_test", + "Insecure": "1", + } + if secure { + fields["Insecure"] = "0" + fields[fieldname.Password] = "integration-password" + } + for name := range defaultEnvVarMapping { + t.Setenv(name, "") + } + for name, field := range defaultEnvVarMapping { + t.Setenv(name, fields[field]) + } + var imported sdk.ImportOutput + DatabaseCredentials().Importer(context.Background(), sdk.ImportInput{}, &imported) + candidates := imported.AllCandidates() + if len(imported.Errors()) != 0 || len(candidates) != 1 { + t.Fatalf("Expected one imported credential without errors, got %d candidates and %d errors", len(candidates), len(imported.Errors())) + } + provision := func(t *testing.T, fields map[sdk.FieldName]string) map[string]string { + t.Helper() + out := sdk.ProvisionOutput{Environment: make(map[string]string)} + DatabaseCredentials().DefaultProvisioner.Provision(context.Background(), sdk.ProvisionInput{ItemFields: fields}, &out) + if len(out.Diagnostics.Errors) != 0 { + t.Fatal("Credential provisioning failed") + } + return out.Environment + } + query := func(environment map[string]string) (string, error) { + args := []string{"exec"} + for name, value := range environment { + args = append(args, "--env", name+"="+value) + } + args = append(args, container, "cockroach", "sql", "--format=csv", "--execute=SELECT current_user, current_database()") + if secure { + args = append(args, "--certs-dir=/certs") + } + return cockroachDocker(args...) + } + checkQuery := func(t *testing.T, fields map[sdk.FieldName]string) { + t.Helper() + output, err := query(provision(t, fields)) + if err != nil || !strings.Contains(output, "\nshell_plugin,plugin_test\n") { + t.Fatalf("Query with imported and provisioned credentials: %v\n%s", err, output) + } + } + t.Run("imported_credentials_connect", func(t *testing.T) { + checkQuery(t, candidates[0].Fields) + }) + if secure { + t.Run("TLS_is_the_default", func(t *testing.T) { + fields := maps.Clone(candidates[0].Fields) + delete(fields, "Insecure") + checkQuery(t, fields) + }) + checkRejected := func(t *testing.T, environment map[string]string) { + t.Helper() + output, err := query(environment) + if err == nil || !strings.Contains(output, "password authentication failed") { + t.Fatalf("Expected password authentication failure: %v\n%s", err, output) + } + } + t.Run("missing_password_is_rejected", func(t *testing.T) { + fields := maps.Clone(candidates[0].Fields) + delete(fields, fieldname.Password) + checkRejected(t, provision(t, fields)) + }) + t.Run("incorrect_password_is_rejected", func(t *testing.T) { + fields := maps.Clone(candidates[0].Fields) + fields[fieldname.Password] = "wrong-password" + checkRejected(t, provision(t, fields)) + }) + t.Run("COCKROACH_PASSWORD_is_ignored", func(t *testing.T) { + environment := provision(t, candidates[0].Fields) + delete(environment, "PGPASSWORD") + environment["COCKROACH_PASSWORD"] = "integration-password" + checkRejected(t, environment) + }) + } + }) + } +} + +func cockroachDocker(args ...string) (string, error) { + timeout := 30 * time.Second + if args[0] == "run" { + timeout = 2 * time.Minute + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + output, err := exec.CommandContext(ctx, "docker", args...).CombinedOutput() + return string(output), err +} diff --git a/plugins/cockroachdb/database_credentials_test.go b/plugins/cockroachdb/database_credentials_test.go new file mode 100644 index 00000000..62919f50 --- /dev/null +++ b/plugins/cockroachdb/database_credentials_test.go @@ -0,0 +1,196 @@ +package cockroachdb + +import ( + "testing" + + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/plugintest" + "github.com/1Password/shell-plugins/sdk/schema" + "github.com/1Password/shell-plugins/sdk/schema/credname" + "github.com/1Password/shell-plugins/sdk/schema/fieldname" +) + +func TestDatabaseCredentialsImporter(t *testing.T) { + for name := range defaultEnvVarMapping { + t.Setenv(name, "") + } + plugintest.TestImporter(t, DatabaseCredentials().Importer, map[string]plugintest.ImportCase{ + "no environment variables": {}, + "environment variables - complete": { + Environment: map[string]string{ + "COCKROACH_HOST": "localhost", + "COCKROACH_PORT": "26257", + "COCKROACH_USER": "root", + "PGPASSWORD": "password123", + "COCKROACH_DATABASE": "defaultdb", + "COCKROACH_INSECURE": "1", + }, + ExpectedCandidates: []sdk.ImportCandidate{ + { + Fields: map[sdk.FieldName]string{ + fieldname.Host: "localhost", + fieldname.Port: "26257", + fieldname.User: "root", + fieldname.Password: "password123", + fieldname.Database: "defaultdb", + "Insecure": "1", + }, + }, + }, + }, + "environment variables - minimal": { + Environment: map[string]string{ + "COCKROACH_HOST": "cockroach.example.com", + "COCKROACH_USER": "admin", + }, + ExpectedCandidates: []sdk.ImportCandidate{ + { + Fields: map[sdk.FieldName]string{ + fieldname.Host: "cockroach.example.com", + fieldname.User: "admin", + }, + }, + }, + }, + "environment variables - production secure": { + Environment: map[string]string{ + "COCKROACH_HOST": "prod-cluster.cockroachlabs.cloud", + "COCKROACH_PORT": "26257", + "COCKROACH_USER": "produser", + "PGPASSWORD": "securepass123", + "COCKROACH_DATABASE": "proddb", + "COCKROACH_INSECURE": "0", + }, + ExpectedCandidates: []sdk.ImportCandidate{ + { + Fields: map[sdk.FieldName]string{ + fieldname.Host: "prod-cluster.cockroachlabs.cloud", + fieldname.Port: "26257", + fieldname.User: "produser", + fieldname.Password: "securepass123", + fieldname.Database: "proddb", + "Insecure": "0", + }, + }, + }, + }, + }) +} + +func TestDatabaseCredentialsProvisioner(t *testing.T) { + plugintest.TestProvisioner(t, DatabaseCredentials().DefaultProvisioner, map[string]plugintest.ProvisionCase{ + "local development - insecure": { + ItemFields: map[sdk.FieldName]string{ + fieldname.Host: "localhost", + fieldname.Port: "26257", + fieldname.User: "root", + fieldname.Password: "password123", + fieldname.Database: "defaultdb", + "Insecure": "1", + }, + ExpectedOutput: sdk.ProvisionOutput{ + Environment: map[string]string{ + "COCKROACH_HOST": "localhost", + "COCKROACH_PORT": "26257", + "COCKROACH_USER": "root", + "PGPASSWORD": "password123", + "COCKROACH_DATABASE": "defaultdb", + "COCKROACH_INSECURE": "1", + }, + }, + }, + "production - secure": { + ItemFields: map[sdk.FieldName]string{ + fieldname.Host: "prod-cluster.cockroachlabs.cloud", + fieldname.Port: "26257", + fieldname.User: "produser", + fieldname.Password: "securepass123", + fieldname.Database: "proddb", + }, + ExpectedOutput: sdk.ProvisionOutput{ + Environment: map[string]string{ + "COCKROACH_HOST": "prod-cluster.cockroachlabs.cloud", + "COCKROACH_PORT": "26257", + "COCKROACH_USER": "produser", + "PGPASSWORD": "securepass123", + "COCKROACH_DATABASE": "proddb", + }, + }, + }, + "minimal configuration": { + ItemFields: map[sdk.FieldName]string{ + fieldname.Host: "cockroach.example.com", + fieldname.User: "admin", + }, + ExpectedOutput: sdk.ProvisionOutput{ + Environment: map[string]string{ + "COCKROACH_HOST": "cockroach.example.com", + "COCKROACH_USER": "admin", + }, + }, + }, + }) +} + +func TestCockroachSQLExecutable(t *testing.T) { + plugin := New() + + var cockroachSQL *schema.Executable + for _, exec := range plugin.Executables { + if exec.Name == "cockroach" { + cockroachSQL = &exec + break + } + } + + if cockroachSQL == nil { + t.Fatal("cockroach sql executable not found in plugin") + } + + if len(cockroachSQL.Uses) != 1 { + t.Fatalf("Expected 1 credential usage, got %d", len(cockroachSQL.Uses)) + } + + if cockroachSQL.Uses[0].Name != credname.DatabaseCredentials { + t.Errorf("Expected DatabaseCredentials, got %s", cockroachSQL.Uses[0].Name) + } +} + +func TestPluginValidation(t *testing.T) { + plugin := New() + + for _, report := range plugin.DeepValidate() { + if report.HasErrors() { + t.Errorf("Plugin validation failed: %+v", report) + } + } + + // Database items cannot store URL fields in the 1Password CLI. + if DatabaseCredentials().ManagementURL != nil { + t.Error("Database credentials must not set a management URL") + } + + if plugin.Name != "cockroachdb" { + t.Errorf("Expected plugin name 'cockroachdb', got '%s'", plugin.Name) + } + + if len(plugin.Credentials) != 1 { + t.Errorf("Expected 1 credential type, got %d", len(plugin.Credentials)) + } + + if len(plugin.Executables) != 1 { + t.Errorf("Expected 1 executable, got %d", len(plugin.Executables)) + } +} + +func TestCockroachNeedsAuth(t *testing.T) { + plugintest.TestNeedsAuth(t, Cockroach().NeedsAuth, map[string]plugintest.NeedsAuthCase{ + "interactive SQL": {Args: []string{"sql"}, ExpectedNeedsAuth: true}, + "SQL query": {Args: []string{"sql", "--execute", "SELECT 1"}, ExpectedNeedsAuth: true}, + "SQL help": {Args: []string{"sql", "--help"}}, + "help": {Args: []string{"help"}}, + "version": {Args: []string{"version"}}, + "start server": {Args: []string{"start-single-node", "--insecure"}}, + "no arguments": {}, + }) +} diff --git a/plugins/cockroachdb/plugin.go b/plugins/cockroachdb/plugin.go new file mode 100644 index 00000000..07132c6f --- /dev/null +++ b/plugins/cockroachdb/plugin.go @@ -0,0 +1,22 @@ +package cockroachdb + +import ( + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/schema" +) + +func New() schema.Plugin { + return schema.Plugin{ + Name: "cockroachdb", + Platform: schema.PlatformInfo{ + Name: "CockroachDB", + Homepage: sdk.URL("https://www.cockroachlabs.com"), + }, + Credentials: []schema.CredentialType{ + DatabaseCredentials(), + }, + Executables: []schema.Executable{ + Cockroach(), + }, + } +}