Skip to content

Commit

Permalink
feat: Add cassandra driver, ci setup
Browse files Browse the repository at this point in the history
  • Loading branch information
rafaelespinoza committed May 12, 2021
1 parent 9196510 commit 2affc38
Show file tree
Hide file tree
Showing 18 changed files with 727 additions and 2 deletions.
15 changes: 15 additions & 0 deletions .ci/cassandra/client.Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
FROM godfish_test/client_base:latest
LABEL driver=cassandra role=client

WORKDIR /src
RUN go build -v ./drivers/cassandra/godfish && \
go test -c . && go test -c ./drivers/cassandra

# Alpine linux doesn't have a cassandra client. Build a golang binary to check
# if server is ready and setup the test DB. Use it in the entrypoint.
WORKDIR /src/.ci/cassandra
RUN go build -v -o /client_setup_keyspace .
COPY .ci/cassandra/client.sh /

WORKDIR /src
ENTRYPOINT /client.sh
28 changes: 28 additions & 0 deletions .ci/cassandra/client.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
#!/usr/bin/env sh

set -eu

dbhost="${1:?missing dbhost}"

echo "building binary"
make cassandra
echo "testing godfish"
make test ARGS='-v -count=1'

# Wait for db server to be ready, with some limits.
num_attempts=0

until /client_setup_keyspace "${dbhost}" godfish_test ; do
num_attempts=$((num_attempts+1))
if [ $num_attempts -gt 12 ]; then
>&2 echo "ERROR: max attempts exceeded"
exit 1
fi

>&2 echo "db is unavailable now, sleeping"
sleep 5
done
>&2 echo "db is up"

echo "testing godfish against live db"
make cassandra-test ARGS='-v -count=1'
41 changes: 41 additions & 0 deletions .ci/cassandra/client_setup_keyspace.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Alpine linux doesn't have a cassandra client. This command can be used by the
// test environment to check if the server is ready, and when it is, prepare a
// keyspace for the tests.
package main

import (
"log"
"os"

"github.com/gocql/gocql"
)

func init() {
log.SetOutput(os.Stderr)
}

func main() {
if len(os.Args) < 3 {
log.Printf("requires 2 positional args; got %d; %#v\n", len(os.Args), os.Args)
log.Fatalf("Usage: %s dbhost keyspace", os.Args[0])
}
host, keyspace := os.Args[1], os.Args[2]

err := setupKeyspace(host, keyspace)
if err != nil {
log.Fatal(err)
}
log.Println("ok")
}

func setupKeyspace(dbhost, keyspace string) error {
cluster := gocql.NewCluster(dbhost)
session, err := cluster.CreateSession()
if err != nil {
return err
}
defer session.Close()

statement := `CREATE KEYSPACE IF NOT EXISTS ` + keyspace + ` WITH replication = {'class':'SimpleStrategy', 'replication_factor': 1}`
return session.Query(statement).Exec()
}
25 changes: 25 additions & 0 deletions .ci/cassandra/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
version: "3.9"

services:
client:
image: godfish_test/cassandra/client:latest
container_name: godfish_ci_cassandra_client
depends_on:
- server
entrypoint: /client.sh server
environment:
CGO_ENABLED: 0
DB_DSN: "cassandra://server:9042/godfish_test?timeout_ms=2000&connect_timeout_ms=2000"
tty: true
server:
image: godfish_test/cassandra/server:latest
container_name: godfish_ci_cassandra_server
expose:
- "9042"
volumes:
-
type: volume
source: server
target: /var/lib/cassandra
volumes:
server:
5 changes: 5 additions & 0 deletions .ci/cassandra/server.Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
FROM cassandra:latest
LABEL driver=cassandra role=server

# Tests run on a a single node, only need to expose the CQL listener port.
EXPOSE 9042
12 changes: 12 additions & 0 deletions .github/workflows/cassandra.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
name: cassandra
on: [push, pull_request]
jobs:
all:
runs-on: ubuntu-20.04
steps:
- name: Checkout repo
uses: actions/checkout@v1
- name: Build environment and run tests
run: make -f Makefile.docker ci-cassandra-up
- name: Teardown
run: make -f Makefile.docker ci-cassandra-down
8 changes: 8 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ test:
clean:
rm $(BIN)

cassandra:
$(GO) build -o $(BIN) -v \
-ldflags "$(LDFLAGS) \
-X $(PKG_IMPORT_PATH)/internal/version.Driver=cassandra" \
./drivers/cassandra/godfish
cassandra-test:
$(GO) test $(ARGS) ./drivers/cassandra

postgres:
$(GO) build -o $(BIN) -v \
-ldflags "$(LDFLAGS) \
Expand Down
14 changes: 14 additions & 0 deletions Makefile.docker
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,16 @@ CI_DIR=./.ci
# Build CI environment, run test suite against a live DB.
# NOTE: The client entrypoints require the other Makefile.
#
ci-cassandra-up: build-cassandra
docker-compose -f $(CI_DIR)/cassandra/docker-compose.yml -- up --exit-code-from client
ci-mysql-up: build-mysql
docker-compose -f $(CI_DIR)/mysql/docker-compose.yml -- up --exit-code-from client
ci-postgres-up: build-postgres
docker-compose -f $(CI_DIR)/postgres/docker-compose.yml -- up --exit-code-from client
ci-sqlite3-up: build-sqlite3
docker container run --name=$(BASENAME)_sqlite3 -- $(BASENAME)/sqlite3
ci-cassandra-down:
docker-compose -f $(CI_DIR)/cassandra/docker-compose.yml -- down --rmi all --volumes
ci-mysql-down:
docker-compose -f $(CI_DIR)/mysql/docker-compose.yml -- down --rmi all --volumes
ci-postgres-down:
Expand All @@ -25,6 +29,10 @@ build-base: $(eval BUILD_DIR=$(shell mktemp -d -p /tmp $(BASENAME)_XXXXXX))
build-base:
git clone --depth=1 file://$(PWD) $(BUILD_DIR)
docker image build -f $(CI_DIR)/client_base.Dockerfile -t $(BASENAME)/client_base $(BUILD_DIR)
build-cassandra: build-base
docker image build -f $(CI_DIR)/cassandra/client.Dockerfile -t $(BASENAME)/cassandra/client $(BUILD_DIR)
docker image build -f $(CI_DIR)/cassandra/server.Dockerfile -t $(BASENAME)/cassandra/server $(BUILD_DIR)
rm -rf $(BUILD_DIR)
build-mysql: build-base
docker image build -f $(CI_DIR)/mysql/client.Dockerfile -t $(BASENAME)/mysql/client $(BUILD_DIR)
docker image build -f $(CI_DIR)/mysql/server.Dockerfile -t $(BASENAME)/mysql/server $(BUILD_DIR)
Expand All @@ -40,6 +48,9 @@ build-sqlite3: build-base
#
# More cleanup stuff.
#
rm-cassandra:
docker container rm $(shell docker container ls -aq --filter ancestor=$(BASENAME)/cassandra/client)
docker container rm $(shell docker container ls -aq --filter ancestor=$(BASENAME)/cassandra/server)
rm-mysql:
docker container rm $(shell docker container ls -aq --filter ancestor=$(BASENAME)/mysql/client)
docker container rm $(shell docker container ls -aq --filter ancestor=$(BASENAME)/mysql/server)
Expand All @@ -49,6 +60,9 @@ rm-postgres:
rm-sqlite3:
docker container rm $(shell docker container ls -aq --filter ancestor=$(BASENAME)/sqlite3)

rmi-cassandra:
docker image rmi $(shell docker image ls -aq $(BASENAME)/cassandra/client)
docker image rmi $(shell docker image ls -aq $(BASENAME)/cassandra/server)
rmi-mysql:
docker image rmi $(shell docker image ls -aq $(BASENAME)/mysql/client)
docker image rmi $(shell docker image ls -aq $(BASENAME)/mysql/server)
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

[![Go Reference](https://pkg.go.dev/badge/github.com/rafaelespinoza/godfish.svg)](https://pkg.go.dev/github.com/rafaelespinoza/godfish)

[![cassandra](https://github.com/rafaelespinoza/godfish/actions/workflows/cassandra.yml/badge.svg)](https://github.com/rafaelespinoza/godfish/actions/workflows/cassandra.yml)
[![mysql](https://github.com/rafaelespinoza/godfish/actions/workflows/mysql.yml/badge.svg)](https://github.com/rafaelespinoza/godfish/actions/workflows/mysql.yml)
[![postgres](https://github.com/rafaelespinoza/godfish/actions/workflows/postgres.yml/badge.svg)](https://github.com/rafaelespinoza/godfish/actions/workflows/postgres.yml)
[![sqlite3](https://github.com/rafaelespinoza/godfish/actions/workflows/sqlite3.yml/badge.svg)](https://github.com/rafaelespinoza/godfish/actions/workflows/sqlite3.yml)
Expand All @@ -11,8 +12,8 @@ good [`dogfish`](https://github.com/dwb/dogfish), but written in golang.

## goals

- use SQL in the migration files, no other high-level DSLs
- interface with many RDBMSs
- use the native query language in the migration files, no other high-level DSLs
- interface with many DBs
- as little dependencies outside of the standard library as possible
- not terrible error messages

Expand All @@ -22,6 +23,7 @@ Make a CLI binary for the DB you want to use. This tool comes with some driver
implementations. Build one like so:

```
make cassandra
make postgres
make mysql
make sqlite3
Expand Down Expand Up @@ -147,11 +149,13 @@ lives in `Makefile.docker` and the `.ci/` directory.

```sh
# Build environments and run tests
make -f Makefile.docker ci-cassandra-up
make -f Makefile.docker ci-mysql-up
make -f Makefile.docker ci-postgres-up
make -f Makefile.docker ci-sqlite3-up

# Teardown
make -f Makefile.docker ci-cassandra-down
make -f Makefile.docker ci-mysql-down
make -f Makefile.docker ci-postgres-down
make -f Makefile.docker ci-sqlite3-down
Expand Down
37 changes: 37 additions & 0 deletions drivers/cassandra/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# cassandra

This `godfish.Driver` implementation has been tested against:

- cassandra version 3.11.10

## Connecting

Like other `godfish.Driver` implementations, you must specify an environment
variable, `DB_DSN`, to connect to the DB. There does not seem to be a standard
connection URI schema for cassandra, but nonetheless this library expects a
`DB_DSN` value. The form is roughly:

```
scheme://[userinfo@]host[,more,hosts]/keyspace[?query]
```

It's parsed by `net/url.Parse` from the standard library and ends up making a
`*gocql.ClusterConfig`. See the tests for working and non-working examples.

### DSN Components

- `scheme`: Required, the value can be something like `cassandra`, or really
anything, followed by a `://`. If this is empty or malformed, then the parsing
function gets confused, and may mix up the host and the keyspace. So, just to
make all of this easier, it's best to put something here.
- `userinfo`: Optionally specify username and password.
- `host`: Required, IP address of DB server.
- may also include a port, in the form: `ip_address:port`
- optionally add `comma,delimited,hosts`
- `keyspace`: Required, should be the first "path" in the DSN string.
- `query`: Various options, represented as query string key value pairs. If any
key is unspecified or the value is zero, then the corresponding field for
`gocql.ClusterConfig` is set to its default.
- `connect_timeout_ms`: Integer, milliseconds. Sets `gocql.ClusterConfig.ConnectTimeout`.
- `protocol_version`: Integer. Sets `gocql.ClusterConfig.ProtoVersion`.
- `timeout_ms`: Integer, milliseconds. Sets `gocql.ClusterConfig.Timeout`.
113 changes: 113 additions & 0 deletions drivers/cassandra/cassandra.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package cassandra

import (
"regexp"
"strings"

"github.com/gocql/gocql"
"github.com/rafaelespinoza/godfish"
)

// NewDriver creates a new cassandra driver.
func NewDriver() godfish.Driver { return &driver{} }

// driver implements the Driver interface for cassandra databases.
type driver struct {
connection *gocql.Session
}

func (d *driver) Name() string { return "cassandra" }
func (d *driver) Connect(in string) (err error) {
if d.connection != nil {
return
}

cluster, err := newClusterConfig(in)
if err != nil {
return
}
conn, err := cluster.CreateSession()
if err != nil {
return
}
d.connection = conn
return
}

func (d *driver) Close() (err error) {
conn := d.connection
if conn == nil {
return
}
d.connection = nil
conn.Close()
return
}

var statementDelimiter = regexp.MustCompile(`;\s*\n`)

func (d *driver) Execute(query string, args ...interface{}) (err error) {
statements := statementDelimiter.Split(query, -1)
if len(statements) < 1 {
return
}
for _, q := range statements {
if len(strings.TrimSpace(q)) < 1 {
continue
}
err = d.connection.Query(q).Exec()
if err != nil {
return
}
}
return nil
}

func (d *driver) CreateSchemaMigrationsTable() (err error) {
err = d.connection.Query(
`CREATE TABLE IF NOT EXISTS schema_migrations (migration_id TEXT PRIMARY KEY)`,
).Exec()
return
}

func (d *driver) AppliedVersions() (out godfish.AppliedVersions, err error) {
query := d.connection.Query(
`SELECT migration_id FROM schema_migrations`,
)

av := execAllAscending(query)

ierr := av.err
if ierr == nil {
out = av
return
}

// A cleaner approach may be to look for a specific error code. The most
// specific error code from the gocql library I've encountered is 8704 (or
// 0x2200 if using cqlsh). As far as I know, it just means "invalid".
if strings.Contains(ierr.Error(), "unconfigured table") {
err = godfish.ErrSchemaMigrationsDoesNotExist
return
}
err = ierr
return
}

func (d *driver) UpdateSchemaMigrations(dir godfish.Direction, version string) (err error) {
conn := d.connection
if dir == godfish.DirForward {
err = conn.Query(`
INSERT INTO schema_migrations (migration_id)
VALUES (?)`,
version,
).Exec()
} else {
err = conn.Query(`
DELETE FROM schema_migrations
WHERE migration_id = ?`,
version,
).Exec()
}
return
}
Loading

0 comments on commit 2affc38

Please sign in to comment.