diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 03babbc..aa5b98b 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/devcontainers/go:1-1-bullseye +FROM mcr.microsoft.com/devcontainers/go:1.18 # Python environment RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ diff --git a/.github/workflows/testing.yaml b/.github/workflows/testing.yaml index 7a30f94..0c5e262 100644 --- a/.github/workflows/testing.yaml +++ b/.github/workflows/testing.yaml @@ -19,7 +19,7 @@ jobs: go-version: '1.18' - name: Quality Assurance run: | - go install golang.org/x/tools/cmd/goimports@latest + go install golang.org/x/tools/cmd/goimports@v0.24.0 gofmt -l ./*.go goimports -e -d ./*.go - name: golangci-lint diff --git a/.gitignore b/.gitignore index 4c41f13..783be10 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ cli/.vscode pkg/ bin .env +.cache diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..d848fef --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,9 @@ +linters: + exclusions: + # Disable ST1006 (receiver name style) to allow existing receiver names in server.go + rules: + - path: server.go + linters: + - staticcheck + - stylecheck + text: ST1006 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 796deed..cf9e76d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -28,7 +28,7 @@ repos: - -w require_serial: true - repo: https://github.com/golangci/golangci-lint - rev: v1.59.1 + rev: v1.50.1 hooks: - id: golangci-lint exclude: ^pkg/ diff --git a/Makefile b/Makefile index 6c36b7a..3b3ae20 100644 --- a/Makefile +++ b/Makefile @@ -9,10 +9,10 @@ setup-ide: # Test SDK test: - cd test; go test -v . + cd test; go mod tidy && go test -v . test-codecov: - cd test; go test -v -race -coverprofile=coverage.out -covermode=atomic . + cd test; go mod tidy && go test -v -race -coverprofile=coverage.out -covermode=atomic . # GO SDK sdk: *.go diff --git a/cli/go.mod b/cli/go.mod index e47889a..fe83033 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -9,7 +9,6 @@ require ( ) require ( - github.com/google/go-cmp v0.5.9 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/mattn/go-runewidth v0.0.3 // indirect github.com/pierrec/lz4 v2.6.1+incompatible // indirect diff --git a/cli/go.sum b/cli/go.sum index a2a0e86..e52f226 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -2,8 +2,7 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 h1:bWDMxwH3px2JBh6AyO7hdCn/PkvCZXii8TGj7sbtEbQ= github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= diff --git a/cli/sqlc.go b/cli/sqlc.go index d1748ea..84855b0 100644 --- a/cli/sqlc.go +++ b/cli/sqlc.go @@ -74,7 +74,7 @@ General Options: Output Format Options: -o, --output FILE Switch to BATCH mode, execute SQL Commands and send output to FILE, then exit. In BATCH mode, the default output format is switched to QUOTE. - + --echo Disables --quiet, print command(s) before execution --quiet Disables --echo, run command(s) quietly (no messages, only query output) --noheader Turn headers off @@ -96,6 +96,7 @@ Connection Options: -e, --create Create database if it does not exist -i, --nonlinearizable Use non-linearizable mode for queries -a, --apikey KEY Use API key for authentication + -k, --token TOKEN Use TOKEN for authentication -n, --noblob Disable BLOB support -x, --maxdata SIZE Set maximum data size for queries -y, --maxrows ROWS Set maximum number of rows for queries @@ -150,6 +151,7 @@ type Parameter struct { Create bool `docopt:"--create"` NonLinearizable bool `docopt:"--nonlinearizable"` ApiKey string `docopt:"--apikey"` + Token string `docopt:"--token"` NoBlob bool `docopt:"--noblob"` MaxData int `docopt:"--maxdata"` MaxRows int `docopt:"--maxrows"` @@ -294,6 +296,7 @@ func parseParameters() (Parameter, error) { p["--tls"] = "SKIP" } p["--apikey"] = getFirstNoneEmptyString([]string{dropError(p.String("--apikey")), conf.ApiKey}) + p["--token"] = getFirstNoneEmptyString([]string{dropError(p.String("--token")), conf.Token}) if conf.NoBlob { if b, err := p.Bool("--noblob"); err == nil { p["--noblob"] = b || conf.NoBlob @@ -418,6 +421,7 @@ func main() { Port: parameter.Port, Username: parameter.User, Password: parameter.Password, + Token: parameter.Token, Database: parameter.Database, Timeout: time.Duration(parameter.Timeout) * time.Second, Compression: parameter.Compress == sqlitecloud.CompressModeLZ4, diff --git a/connection.go b/connection.go index b4d137b..8bc1070 100644 --- a/connection.go +++ b/connection.go @@ -34,6 +34,7 @@ import ( type SQCloudConfig struct { Host string Port int + ProjectID string // ProjectID to identify the user's node Username string Password string Database string @@ -49,10 +50,11 @@ type SQCloudConfig struct { TlsInsecureSkipVerify bool // Accept invalid TLS certificates (no_verify_certificate) Pem string ApiKey string - NoBlob bool // flag to tell the server to not send BLOB columns - MaxData int // value to tell the server to not send columns with more than max_data bytes - MaxRows int // value to control rowset chunks based on the number of rows - MaxRowset int // value to control the maximum allowed size for a rowset + Token string // Access Token for authentication + NoBlob bool // flag to tell the server to not send BLOB columns + MaxData int // value to tell the server to not send columns with more than max_data bytes + MaxRows int // value to control rowset chunks based on the number of rows + MaxRowset int // value to control the maximum allowed size for a rowset } type SQCloud struct { @@ -131,6 +133,7 @@ func ParseConnectionString(ConnectionString string) (config *SQCloudConfig, err config.MaxRows = 0 config.MaxRowset = 0 config.ApiKey = "" + config.Token = "" sPort := strings.TrimSpace(u.Port()) if len(sPort) > 0 { @@ -139,6 +142,12 @@ func ParseConnectionString(ConnectionString string) (config *SQCloudConfig, err } } + // eg: project ID "abvqqetyhq" in "abvqqetyhq.global3.ryujaz.sqlite.cloud" + config.ProjectID = strings.Split(config.Host, ".")[0] + if config.ProjectID == "" { + return nil, fmt.Errorf("invalid connection string: missing project ID in host") + } + for key, values := range u.Query() { lastLiteral := strings.TrimSpace(values[len(values)-1]) switch strings.ToLower(strings.TrimSpace(key)) { @@ -195,6 +204,8 @@ func ParseConnectionString(ConnectionString string) (config *SQCloudConfig, err config.Secure, config.TlsInsecureSkipVerify, config.Pem = ParseTlsString(lastLiteral) case "apikey": config.ApiKey = lastLiteral + case "token": + config.Token = lastLiteral case "noblob": if b, err := parseBool(lastLiteral, config.NoBlob); err == nil { config.NoBlob = b @@ -434,12 +445,14 @@ func connectionCommands(config SQCloudConfig) (string, []interface{}) { } if config.ApiKey != "" { - c, a := authWithKeyCommand(config.ApiKey) + c, a := authWithApiKeyCommand(config.ApiKey) buffer += c args = append(args, a...) - } - - if config.Username != "" && config.Password != "" { + } else if config.Token != "" { + c, a := authWithTokenCommand(config.Token) + buffer += c + args = append(args, a...) + } else if config.Username != "" && config.Password != "" { c, a := authCommand(config.Username, config.Password, config.PasswordHashed) buffer += c args = append(args, a...) diff --git a/connection_internal_test.go b/connection_internal_test.go new file mode 100644 index 0000000..625c4c2 --- /dev/null +++ b/connection_internal_test.go @@ -0,0 +1,37 @@ +package sqlitecloud + +import ( + "reflect" + "strings" + "testing" +) + +func TestConnectionCommandsAuthPreference(t *testing.T) { + tests := []struct { + name string + config SQCloudConfig + wantCmd string + wantArgs []interface{} + }{ + {"api key wins", SQCloudConfig{ApiKey: "k", Token: "t", Username: "u", Password: "p"}, "AUTH APIKEY ?;", []interface{}{"k"}}, + {"token next", SQCloudConfig{Token: "t", Username: "u", Password: "p"}, "AUTH TOKEN ?;", []interface{}{"t"}}, + {"user/pass fallback", SQCloudConfig{Username: "u", Password: "p"}, "AUTH USER ? PASSWORD ?;", []interface{}{"u", "p"}}, + {"hashed password", SQCloudConfig{Username: "u", Password: "hash", PasswordHashed: true}, "AUTH USER ? HASH ?;", []interface{}{"u", "hash"}}, + {"no credentials", SQCloudConfig{}, "", []interface{}{}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotBuf, gotArgs := connectionCommands(tt.config) + if tt.wantCmd == "" && gotBuf != "" { + t.Fatalf("expected no auth command, got %q", gotBuf) + } + if tt.wantCmd != "" && !strings.Contains(gotBuf, tt.wantCmd) { + t.Fatalf("expected %q in buffer, got %q", tt.wantCmd, gotBuf) + } + if !reflect.DeepEqual(gotArgs, tt.wantArgs) { + t.Fatalf("args mismatch: want %v got %v", tt.wantArgs, gotArgs) + } + }) + } +} diff --git a/go.mod b/go.mod index d8bd281..20aebad 100644 --- a/go.mod +++ b/go.mod @@ -10,5 +10,6 @@ require ( require ( github.com/frankban/quicktest v1.14.3 // indirect - golang.org/x/sys v0.7.0 // indirect + github.com/google/go-cmp v0.6.0 // indirect + golang.org/x/sys v0.6.0 // indirect ) diff --git a/go.sum b/go.sum index 15aa01b..8d17d51 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,9 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE= github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps= -github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o= github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= @@ -16,11 +17,10 @@ github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBO github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/xo/dburl v0.13.1 h1:EV+BCdo539sc/mBrny0VxaEGLM0b1U0mJA9RpP80ux0= github.com/xo/dburl v0.13.1/go.mod h1:B7/G9FGungw6ighV8xJNwWYQPMfn3gsi2sn5SE8Bzco= -golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU= -golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= diff --git a/server.go b/server.go index 5b48491..2125299 100644 --- a/server.go +++ b/server.go @@ -54,6 +54,7 @@ const ( type SQCloudNode struct { NodeID int64 + PublicAddr string NodeInterface string ClusterInterface string Status SQCloudNodeStatus @@ -157,16 +158,17 @@ func (this *SQCloud) ListNodes() ([]SQCloudNode, error) { if err == nil { if result != nil { defer result.Free() - if result.GetNumberOfColumns() == 7 { + if result.GetNumberOfColumns() == 8 { for row, rows := uint64(0), result.GetNumberOfRows(); row < rows; row++ { node := SQCloudNode{} node.NodeID, _ = result.GetInt64Value(row, 0) - node.NodeInterface, _ = result.GetStringValue(row, 1) - node.ClusterInterface, _ = result.GetStringValue(row, 2) - node.Status, _ = stringToSQCloudNodeStatus(result.GetStringValue_(row, 3)) - node.Progress, _ = stringToSQCloudNodeProgress(result.GetStringValue_(row, 4)) - node.Match, _ = result.GetInt64Value(row, 5) - node.LastActivity, _ = result.GetSQLDateTime(row, 6) + node.PublicAddr = result.GetStringValue_(row, 1) + node.NodeInterface, _ = result.GetStringValue(row, 2) + node.ClusterInterface, _ = result.GetStringValue(row, 3) + node.Status, _ = stringToSQCloudNodeStatus(result.GetStringValue_(row, 4)) + node.Progress, _ = stringToSQCloudNodeProgress(result.GetStringValue_(row, 5)) + node.Match, _ = result.GetInt64Value(row, 6) + node.LastActivity, _ = result.GetSQLDateTime(row, 7) list = append(list, node) } return list, nil @@ -260,13 +262,21 @@ func (this *SQCloud) Auth(Username string, Password string) error { return this.ExecuteArray(authCommand(Username, Password, false)) } -func authWithKeyCommand(Key string) (string, []interface{}) { +func authWithApiKeyCommand(Key string) (string, []interface{}) { return "AUTH APIKEY ?;", []interface{}{Key} } +func authWithTokenCommand(Token string) (string, []interface{}) { + return "AUTH TOKEN ?;", []interface{}{Token} +} + // Auth - INTERNAL SERVER COMMAND: Authenticates User with the given API KEY. func (this *SQCloud) AuthWithKey(Key string) error { - return this.ExecuteArray(authWithKeyCommand(Key)) + return this.ExecuteArray(authWithApiKeyCommand(Key)) +} + +func (this *SQCloud) AuthWithToken(Token string) error { + return this.ExecuteArray(authWithTokenCommand(Token)) } // Database funcitons diff --git a/test/go.mod b/test/go.mod index 1375454..8ff8e6d 100644 --- a/test/go.mod +++ b/test/go.mod @@ -10,7 +10,6 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect - github.com/google/go-cmp v0.5.9 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/pierrec/lz4 v2.6.1+incompatible // indirect github.com/pmezard/go-difflib v1.0.0 // indirect diff --git a/test/go.sum b/test/go.sum index 12d63d6..8211dea 100644 --- a/test/go.sum +++ b/test/go.sum @@ -2,8 +2,7 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= diff --git a/test/unit/connection_test.go b/test/unit/connection_test.go index 2576880..4e6ccc9 100644 --- a/test/unit/connection_test.go +++ b/test/unit/connection_test.go @@ -11,11 +11,12 @@ import ( ) func TestParseConnectionString(t *testing.T) { - connectionString := "sqlitecloud://myhost.sqlite.cloud:8860/mydatabase?timeout=11&compress=yes" + connectionString := "sqlitecloud://myproject.sqlite.cloud:8860/mydatabase?timeout=11&compress=yes" expectedConfig := &sqlitecloud.SQCloudConfig{ - Host: "myhost.sqlite.cloud", + Host: "myproject.sqlite.cloud", Port: 8860, + ProjectID: "myproject", Username: "", Password: "", Database: "mydatabase", @@ -68,6 +69,32 @@ func TestParseConnectionStringWithAPIKey(t *testing.T) { assert.Truef(t, reflect.DeepEqual(expectedConfig, config), "Expected: %+v\nGot: %+v", expectedConfig, config) } +func TestParseConnectionStringWithToken(t *testing.T) { + connectionString := "sqlitecloud://host.com:8860/dbname?token=123|tok123&compress=true" + expectedConfig := &sqlitecloud.SQCloudConfig{ + Host: "host.com", + Port: 8860, + Username: "", + Password: "", + Database: "dbname", + Timeout: 0, + Compression: true, + CompressMode: sqlitecloud.CompressModeLZ4, + Secure: true, + TlsInsecureSkipVerify: false, + Pem: "", + Token: "123|tok123", + NoBlob: false, + MaxData: 0, + MaxRows: 0, + MaxRowset: 0, + } + + config, err := sqlitecloud.ParseConnectionString(connectionString) + assert.NoError(t, err) + assert.Truef(t, reflect.DeepEqual(expectedConfig, config), "Expected: %+v\nGot: %+v", expectedConfig, config) +} + func TestParseConnectionStringWithCredentials(t *testing.T) { connectionString := "sqlitecloud://user:pass@host.com:8860" config, err := sqlitecloud.ParseConnectionString(connectionString) @@ -187,6 +214,12 @@ func TestParseConnectionStringWithParameters(t *testing.T) { value: "abc123", expectedValue: "abc123", }, + { + param: "token", + configParam: "Token", + value: "123|tok123", + expectedValue: "123|tok123", + }, } for _, tt := range tests {