You've already forked golang-saas-starter-kit
mirror of
https://github.com/raseels-repos/golang-saas-starter-kit.git
synced 2025-06-15 00:15:15 +02:00
Finish inital schema migration script
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@ -1 +1,2 @@
|
||||
aws.lee
|
||||
aws.*
|
2
example-project/.gitignore
vendored
2
example-project/.gitignore
vendored
@ -1 +1 @@
|
||||
private.pem
|
||||
.env_docker_compose
|
||||
|
@ -143,6 +143,24 @@ To make authenticated requests put the token in the `Authorization` header with
|
||||
$ curl -H "Authorization: Bearer ${TOKEN}" http://localhost:3000/v1/users
|
||||
```
|
||||
|
||||
|
||||
## Making db calls
|
||||
Currently postgres is only supported for sqlxmigrate. MySQL should be easy to add after determing
|
||||
better method for abstracting the create table and other SQL statements from the main
|
||||
testing logic.
|
||||
|
||||
### bindvars
|
||||
When making new packages that use sqlx, bind vars for mysql are `?` where as postgres is `$1`.
|
||||
To database agnostic, sqlx supports using `?` for all queries and exposes the method `Rebind` to
|
||||
remap the placeholders to the correct database.
|
||||
|
||||
```go
|
||||
sqlQueryStr = db.Rebind(sqlQueryStr)
|
||||
```
|
||||
|
||||
For additional details refer to https://jmoiron.github.io/sqlx/#bindvars
|
||||
|
||||
|
||||
## What's Next
|
||||
|
||||
We are in the process of writing more documentation about this code. Classes are being finalized as part of the Ultimate series.
|
||||
@ -150,6 +168,7 @@ We are in the process of writing more documentation about this code. Classes are
|
||||
|
||||
|
||||
|
||||
|
||||
## AWS Permissions
|
||||
|
||||
Base required permissions
|
||||
@ -171,3 +190,24 @@ Additional permissions required for unittests
|
||||
secretsmanager:DeleteSecret
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
||||
### TODO:
|
||||
additianal info required here in readme
|
||||
|
||||
need to copy sample.env_docker_compose to .env_docker_compose and defined your aws configs for docker-compose
|
||||
|
||||
|
||||
/*
|
||||
ZipKin: http://localhost:9411
|
||||
AddLoad: hey -m GET -c 10 -n 10000 "http://localhost:3000/v1/users"
|
||||
expvarmon -ports=":3001" -endpoint="/metrics" -vars="requests,goroutines,errors,mem:memstats.Alloc"
|
||||
*/
|
||||
|
||||
/*
|
||||
Need to figure out timeouts for http service.
|
||||
You might want to reset your DB_HOST env var during test tear down.
|
||||
Service should start even without a DB running yet.
|
||||
symbols in profiles: https://github.com/golang/go/issues/23376 / https://github.com/google/pprof/pull/366
|
||||
*/
|
||||
|
@ -7,14 +7,17 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
_ "net/http/pprof"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/go-redis/redis"
|
||||
"github.com/lib/pq"
|
||||
"geeks-accelerator/oss/saas-starter-kit/example-project/cmd/web-api/handlers"
|
||||
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/auth"
|
||||
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/db"
|
||||
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/flag"
|
||||
itrace "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/trace"
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
@ -23,21 +26,11 @@ import (
|
||||
"github.com/kelseyhightower/envconfig"
|
||||
"go.opencensus.io/trace"
|
||||
awstrace "gopkg.in/DataDog/dd-trace-go.v1/contrib/aws/aws-sdk-go/aws"
|
||||
sqltrace "gopkg.in/DataDog/dd-trace-go.v1/contrib/database/sql"
|
||||
redistrace "gopkg.in/DataDog/dd-trace-go.v1/contrib/go-redis/redis"
|
||||
sqlxtrace "gopkg.in/DataDog/dd-trace-go.v1/contrib/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
/*
|
||||
ZipKin: http://localhost:9411
|
||||
AddLoad: hey -m GET -c 10 -n 10000 "http://localhost:3000/v1/users"
|
||||
expvarmon -ports=":3001" -endpoint="/metrics" -vars="requests,goroutines,errors,mem:memstats.Alloc"
|
||||
*/
|
||||
|
||||
/*
|
||||
Need to figure out timeouts for http service.
|
||||
You might want to reset your DB_HOST env var during test tear down.
|
||||
Service should start even without a DB running yet.
|
||||
symbols in profiles: https://github.com/golang/go/issues/23376 / https://github.com/google/pprof/pull/366
|
||||
*/
|
||||
|
||||
// build is the git version of this program. It is set using build flags in the makefile.
|
||||
var build = "develop"
|
||||
|
||||
@ -53,14 +46,56 @@ func main() {
|
||||
var cfg struct {
|
||||
Env string `default:"dev" envconfig:"ENV"`
|
||||
HTTP struct {
|
||||
Host string `default:"0.0.0.0:3001" envconfig:"HTTP_HOST"`
|
||||
DebugHost string `default:"0.0.0.0:4000" envconfig:"HTTP_DEBUG_HOST"`
|
||||
ReadTimeout time.Duration `default:"5s" envconfig:"HTTP_READ_TIMEOUT"`
|
||||
WriteTimeout time.Duration `default:"5s" envconfig:"HTTP_WRITE_TIMEOUT"`
|
||||
ShutdownTimeout time.Duration `default:"5s" envconfig:"HTTP_SHUTDOWN_TIMEOUT"`
|
||||
Host string `default:"0.0.0.0:3000" envconfig:"HTTP_HOST"`
|
||||
ReadTimeout time.Duration `default:"10s" envconfig:"HTTP_READ_TIMEOUT"`
|
||||
WriteTimeout time.Duration `default:"10s" envconfig:"HTTP_WRITE_TIMEOUT"`
|
||||
}
|
||||
HTTPS struct {
|
||||
Host string `default:"" envconfig:"HTTPS_HOST"`
|
||||
ReadTimeout time.Duration `default:"5s" envconfig:"HTTPS_READ_TIMEOUT"`
|
||||
WriteTimeout time.Duration `default:"5s" envconfig:"HTTPS_WRITE_TIMEOUT"`
|
||||
}
|
||||
App struct {
|
||||
Name string `default:"web-app" envconfig:"APP_NAME"`
|
||||
Name string `default:"web-api" envconfig:"APP_NAME"`
|
||||
BaseUrl string `default:"" envconfig:"APP_BASE_URL"`
|
||||
TemplateDir string `default:"./templates" envconfig:"APP_TEMPLATE_DIR"`
|
||||
DebugHost string `default:"0.0.0.0:4000" envconfig:"APP_DEBUG_HOST"`
|
||||
ShutdownTimeout time.Duration `default:"5s" envconfig:"APP_SHUTDOWN_TIMEOUT"`
|
||||
}
|
||||
Redis struct {
|
||||
DialTimeout time.Duration `default:"5s" envconfig:"REDIS_DIAL_TIMEOUT"`
|
||||
Host string `default:":6379" envconfig:"REDIS_HOST"`
|
||||
DB int `default:"1" envconfig:"REDIS_DB"`
|
||||
MaxmemoryPolicy string `envconfig:"REDIS_MAXMEMORY_POLICY"`
|
||||
}
|
||||
DB struct {
|
||||
Host string `default:"127.0.0.1:5433" envconfig:"DB_HOST"`
|
||||
User string `default:"postgres" envconfig:"DB_USER"`
|
||||
Pass string `default:"postgres" envconfig:"DB_PASS" json:"-"` // don't print
|
||||
Database string `default:"shared" envconfig:"DB_DATABASE"`
|
||||
Driver string `default:"postgres" envconfig:"DB_DRIVER"`
|
||||
Timezone string `default:"utc" envconfig:"DB_TIMEZONE"`
|
||||
DisableTLS bool `default:"false" envconfig:"DB_DISABLE_TLS"`
|
||||
}
|
||||
Trace struct {
|
||||
Host string `default:"http://tracer:3002/v1/publish" envconfig:"TRACE_HOST"`
|
||||
BatchSize int `default:"1000" envconfig:"TRACE_BATCH_SIZE"`
|
||||
SendInterval time.Duration `default:"15s" envconfig:"TRACE_SEND_INTERVAL"`
|
||||
SendTimeout time.Duration `default:"500ms" envconfig:"TRACE_SEND_TIMEOUT"`
|
||||
}
|
||||
AwsAccount struct {
|
||||
AccessKeyID string `envconfig:"AWS_ACCESS_KEY_ID"`
|
||||
SecretAccessKey string `envconfig:"AWS_SECRET_ACCESS_KEY" json:"-"` // don't print
|
||||
Region string `default:"us-east-1" envconfig:"AWS_REGION"`
|
||||
|
||||
// Get an AWS session from an implicit source if no explicit
|
||||
// configuration is provided. This is useful for taking advantage of
|
||||
// EC2/ECS instance roles.
|
||||
UseRole bool `envconfig:"AWS_USE_ROLE"`
|
||||
}
|
||||
Auth struct {
|
||||
AwsSecretID string `default:"auth-secret-key" envconfig:"AUTH_AWS_SECRET_ID"`
|
||||
KeyExpiration time.Duration `default:"3600s" envconfig:"AUTH_KEY_EXPIRATION"`
|
||||
}
|
||||
BuildInfo struct {
|
||||
CiCommitRefName string `envconfig:"CI_COMMIT_REF_NAME"`
|
||||
@ -74,35 +109,14 @@ func main() {
|
||||
CiPipelineId string `envconfig:"CI_COMMIT_PIPELINE_ID"`
|
||||
CiPipelineUrl string `envconfig:"CI_COMMIT_PIPELINE_URL"`
|
||||
}
|
||||
DB struct {
|
||||
DialTimeout time.Duration `default:"5s" envconfig:"DB_DIAL_TIMEOUT"`
|
||||
Host string `default:"mongo:27017/gotraining" envconfig:"DB_HOST"`
|
||||
}
|
||||
Trace struct {
|
||||
Host string `default:"http://tracer:3002/v1/publish" envconfig:"TRACE_HOST"`
|
||||
BatchSize int `default:"1000" envconfig:"TRACE_BATCH_SIZE"`
|
||||
SendInterval time.Duration `default:"15s" envconfig:"TRACE_SEND_INTERVAL"`
|
||||
SendTimeout time.Duration `default:"500ms" envconfig:"TRACE_SEND_TIMEOUT"`
|
||||
}
|
||||
AwsAccount struct {
|
||||
AccessKeyID string `envconfig:"AWS_ACCESS_KEY_ID"`
|
||||
SecretAccessKey string `envconfig:"AWS_SECRET_ACCESS_KEY"`
|
||||
Region string `default:"us-east-1" envconfig:"AWS_REGION"`
|
||||
|
||||
// Get an AWS session from an implicit source if no explicit
|
||||
// configuration is provided. This is useful for taking advantage of
|
||||
// EC2/ECS instance roles.
|
||||
UseRole bool `envconfig:"AWS_USE_ROLE"`
|
||||
}
|
||||
Auth struct {
|
||||
AwsSecretID string `default:"auth-secret-key" envconfig:"AUTH_AWS_SECRET_ID"`
|
||||
KeyExpiration time.Duration `default:"3600s" envconfig:"AUTH_KEY_EXPIRATION"`
|
||||
}
|
||||
}
|
||||
|
||||
// !!! This prefix seems buried, if you copy and paste this main.go
|
||||
// file, its likely you will forget to update this.
|
||||
if err := envconfig.Process("WEB_API", &cfg); err != nil {
|
||||
// The prefix used for loading env variables.
|
||||
// ie: export WEB_API_ENV=dev
|
||||
envKeyPrefix := "WEB_API"
|
||||
|
||||
// For additional details refer to https://github.com/kelseyhightower/envconfig
|
||||
if err := envconfig.Process(envKeyPrefix, &cfg); err != nil {
|
||||
log.Fatalf("main : Parsing Config : %v", err)
|
||||
}
|
||||
|
||||
@ -114,21 +128,42 @@ func main() {
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// App Starting
|
||||
// Config Validation & Defaults
|
||||
|
||||
// If base URL is empty, set the default value from the HTTP Host
|
||||
if cfg.App.BaseUrl == "" {
|
||||
baseUrl := cfg.HTTP.Host
|
||||
if !strings.HasPrefix(baseUrl, "http") {
|
||||
if strings.HasPrefix(baseUrl, "0.0.0.0:") {
|
||||
pts := strings.Split(baseUrl, ":")
|
||||
pts[0] = "127.0.0.1"
|
||||
baseUrl = strings.Join(pts, ":")
|
||||
} else if strings.HasPrefix(baseUrl, ":") {
|
||||
baseUrl = "127.0.0.1" + baseUrl
|
||||
}
|
||||
baseUrl = "http://" + baseUrl
|
||||
}
|
||||
cfg.App.BaseUrl = baseUrl
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Log App Info
|
||||
|
||||
// Print the build version for our logs. Also expose it under /debug/vars.
|
||||
expvar.NewString("build").Set(build)
|
||||
log.Printf("main : Started : Application Initializing version %q", build)
|
||||
defer log.Println("main : Completed")
|
||||
|
||||
// Print the config for our logs. It's important to any credentials in the config
|
||||
// that could expose a security risk are excluded from being json encoded by
|
||||
// applying the tag `json:"-"` to the struct var.
|
||||
{
|
||||
cfgJSON, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
log.Fatalf("main : Marshalling Config to JSON : %v", err)
|
||||
}
|
||||
|
||||
// TODO: Validate what is being written to the logs. We don't
|
||||
// want to leak credentials or anything that can be a security risk.
|
||||
log.Printf("main : Config : %v\n", string(cfgJSON))
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Init AWS Session
|
||||
@ -144,6 +179,77 @@ func main() {
|
||||
}
|
||||
awsSession = awstrace.WrapSession(awsSession)
|
||||
|
||||
// =========================================================================
|
||||
// Start Redis
|
||||
// Ensure the eviction policy on the redis cluster is set correctly.
|
||||
// AWS Elastic cache redis clusters by default have the volatile-lru.
|
||||
// volatile-lru: evict keys by trying to remove the less recently used (LRU) keys first, but only among keys that have an expire set, in order to make space for the new data added.
|
||||
// allkeys-lru: evict keys by trying to remove the less recently used (LRU) keys first, in order to make space for the new data added.
|
||||
// Recommended to have eviction policy set to allkeys-lru
|
||||
log.Println("main : Started : Initialize Redis")
|
||||
redisClient := redistrace.NewClient(&redis.Options{
|
||||
Addr: cfg.Redis.Host,
|
||||
DB: cfg.Redis.DB,
|
||||
DialTimeout: cfg.Redis.DialTimeout,
|
||||
})
|
||||
defer redisClient.Close()
|
||||
|
||||
evictPolicyConfigKey := "maxmemory-policy"
|
||||
|
||||
// if the maxmemory policy is set for redis, make sure its set on the cluster
|
||||
// default not set and will based on the redis config values defined on the server
|
||||
if cfg.Redis.MaxmemoryPolicy != "" {
|
||||
err := redisClient.ConfigSet(evictPolicyConfigKey, cfg.Redis.MaxmemoryPolicy).Err()
|
||||
if err != nil {
|
||||
log.Fatalf("main : redis : ConfigSet maxmemory-policy : %v", err)
|
||||
}
|
||||
} else {
|
||||
evictPolicy, err := redisClient.ConfigGet(evictPolicyConfigKey).Result()
|
||||
if err != nil {
|
||||
log.Fatalf("main : redis : ConfigGet maxmemory-policy : %v", err)
|
||||
}
|
||||
|
||||
if evictPolicy[1] != "allkeys-lru" {
|
||||
log.Printf("main : redis : ConfigGet maxmemory-policy : recommended to be set to allkeys-lru to avoid OOM")
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Start Database
|
||||
var dbUrl url.URL
|
||||
{
|
||||
// Query parameters.
|
||||
var q url.Values
|
||||
|
||||
// Handle SSL Mode
|
||||
if cfg.DB.DisableTLS {
|
||||
q.Set("sslmode", "disable")
|
||||
} else {
|
||||
q.Set("sslmode", "require")
|
||||
}
|
||||
|
||||
q.Set("timezone", cfg.DB.Timezone)
|
||||
|
||||
// Construct url.
|
||||
dbUrl = url.URL{
|
||||
Scheme: cfg.DB.Driver,
|
||||
User: url.UserPassword(cfg.DB.User, cfg.DB.Pass),
|
||||
Host: cfg.DB.Host,
|
||||
Path: cfg.DB.Database,
|
||||
RawQuery: q.Encode(),
|
||||
}
|
||||
}
|
||||
|
||||
// Register informs the sqlxtrace package of the driver that we will be using in our program.
|
||||
// It uses a default service name, in the below case "postgres.db". To use a custom service
|
||||
// name use RegisterWithServiceName.
|
||||
sqltrace.Register(cfg.DB.Driver, &pq.Driver{}, sqltrace.WithServiceName("my-service"))
|
||||
masterDb, err := sqlxtrace.Open(cfg.DB.Driver, dbUrl.String())
|
||||
if err != nil {
|
||||
log.Fatalf("main : Register DB : %s : %v", cfg.DB.Driver, err)
|
||||
}
|
||||
defer masterDb.Close()
|
||||
|
||||
// =========================================================================
|
||||
// Load auth keys from AWS and init new Authenticator
|
||||
authenticator, err := auth.NewAuthenticator(awsSession, cfg.Auth.AwsSecretID, time.Now().UTC(), cfg.Auth.KeyExpiration)
|
||||
@ -151,16 +257,6 @@ func main() {
|
||||
log.Fatalf("main : Constructing authenticator : %v", err)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Start Mongo
|
||||
|
||||
log.Println("main : Started : Initialize Mongo")
|
||||
masterDB, err := db.New(cfg.DB.Host, cfg.DB.DialTimeout)
|
||||
if err != nil {
|
||||
log.Fatalf("main : Register DB : %v", err)
|
||||
}
|
||||
defer masterDB.Close()
|
||||
|
||||
// =========================================================================
|
||||
// Start Tracing Support
|
||||
|
||||
@ -192,10 +288,10 @@ func main() {
|
||||
//
|
||||
// /debug/vars - Added to the default mux by the expvars package.
|
||||
// /debug/pprof - Added to the default mux by the net/http/pprof package.
|
||||
if cfg.HTTP.DebugHost != "" {
|
||||
if cfg.App.DebugHost != "" {
|
||||
go func() {
|
||||
log.Printf("main : Debug Listening %s", cfg.HTTP.DebugHost)
|
||||
log.Printf("main : Debug Listener closed : %v", http.ListenAndServe(cfg.HTTP.DebugHost, http.DefaultServeMux))
|
||||
log.Printf("main : Debug Listening %s", cfg.App.DebugHost)
|
||||
log.Printf("main : Debug Listener closed : %v", http.ListenAndServe(cfg.App.DebugHost, http.DefaultServeMux))
|
||||
}()
|
||||
}
|
||||
|
||||
|
0
example-project/cmd/web-api/templates/.gitkeep
Normal file
0
example-project/cmd/web-api/templates/.gitkeep
Normal file
@ -30,9 +30,12 @@ import (
|
||||
"github.com/aws/aws-sdk-go/aws/session"
|
||||
"github.com/go-redis/redis"
|
||||
"github.com/kelseyhightower/envconfig"
|
||||
"github.com/lib/pq"
|
||||
"go.opencensus.io/trace"
|
||||
awstrace "gopkg.in/DataDog/dd-trace-go.v1/contrib/aws/aws-sdk-go/aws"
|
||||
sqltrace "gopkg.in/DataDog/dd-trace-go.v1/contrib/database/sql"
|
||||
redistrace "gopkg.in/DataDog/dd-trace-go.v1/contrib/go-redis/redis"
|
||||
sqlxtrace "gopkg.in/DataDog/dd-trace-go.v1/contrib/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// build is the git version of this program. It is set using build flags in the makefile.
|
||||
@ -74,6 +77,41 @@ func main() {
|
||||
DebugHost string `default:"0.0.0.0:4000" envconfig:"APP_DEBUG_HOST"`
|
||||
ShutdownTimeout time.Duration `default:"5s" envconfig:"APP_SHUTDOWN_TIMEOUT"`
|
||||
}
|
||||
Redis struct {
|
||||
DialTimeout time.Duration `default:"5s" envconfig:"REDIS_DIAL_TIMEOUT"`
|
||||
Host string `default:":6379" envconfig:"REDIS_HOST"`
|
||||
DB int `default:"1" envconfig:"REDIS_DB"`
|
||||
MaxmemoryPolicy string `envconfig:"REDIS_MAXMEMORY_POLICY"`
|
||||
}
|
||||
DB struct {
|
||||
Host string `default:"127.0.0.1:5433" envconfig:"DB_HOST"`
|
||||
User string `default:"postgres" envconfig:"DB_USER"`
|
||||
Pass string `default:"postgres" envconfig:"DB_PASS" json:"-"` // don't print
|
||||
Database string `default:"shared" envconfig:"DB_DATABASE"`
|
||||
Driver string `default:"postgres" envconfig:"DB_DRIVER"`
|
||||
Timezone string `default:"utc" envconfig:"DB_TIMEZONE"`
|
||||
DisableTLS bool `default:"false" envconfig:"DB_DISABLE_TLS"`
|
||||
}
|
||||
Trace struct {
|
||||
Host string `default:"http://tracer:3002/v1/publish" envconfig:"TRACE_HOST"`
|
||||
BatchSize int `default:"1000" envconfig:"TRACE_BATCH_SIZE"`
|
||||
SendInterval time.Duration `default:"15s" envconfig:"TRACE_SEND_INTERVAL"`
|
||||
SendTimeout time.Duration `default:"500ms" envconfig:"TRACE_SEND_TIMEOUT"`
|
||||
}
|
||||
AwsAccount struct {
|
||||
AccessKeyID string `envconfig:"AWS_ACCESS_KEY_ID"`
|
||||
SecretAccessKey string `envconfig:"AWS_SECRET_ACCESS_KEY" json:"-"` // don't print
|
||||
Region string `default:"us-east-1" envconfig:"AWS_REGION"`
|
||||
|
||||
// Get an AWS session from an implicit source if no explicit
|
||||
// configuration is provided. This is useful for taking advantage of
|
||||
// EC2/ECS instance roles.
|
||||
UseRole bool `envconfig:"AWS_USE_ROLE"`
|
||||
}
|
||||
Auth struct {
|
||||
AwsSecretID string `default:"auth-secret-key" envconfig:"AUTH_AWS_SECRET_ID"`
|
||||
KeyExpiration time.Duration `default:"3600s" envconfig:"AUTH_KEY_EXPIRATION"`
|
||||
}
|
||||
BuildInfo struct {
|
||||
CiCommitRefName string `envconfig:"CI_COMMIT_REF_NAME"`
|
||||
CiCommitRefSlug string `envconfig:"CI_COMMIT_REF_SLUG"`
|
||||
@ -86,42 +124,15 @@ func main() {
|
||||
CiPipelineId string `envconfig:"CI_COMMIT_PIPELINE_ID"`
|
||||
CiPipelineUrl string `envconfig:"CI_COMMIT_PIPELINE_URL"`
|
||||
}
|
||||
Redis struct {
|
||||
DialTimeout time.Duration `default:"5s" envconfig:"REDIS_DIAL_TIMEOUT"`
|
||||
Host string `default:":6379" envconfig:"REDIS_HOST"`
|
||||
DB int `default:"1" envconfig:"REDIS_DB"`
|
||||
MaxmemoryPolicy string `envconfig:"REDIS_MAXMEMORY_POLICY"`
|
||||
}
|
||||
DB struct {
|
||||
DialTimeout time.Duration `default:"5s" envconfig:"DB_DIAL_TIMEOUT"`
|
||||
Host string `default:"mongo:27017/gotraining" envconfig:"DB_HOST"`
|
||||
}
|
||||
Trace struct {
|
||||
Host string `default:"http://tracer:3002/v1/publish" envconfig:"TRACE_HOST"`
|
||||
BatchSize int `default:"1000" envconfig:"TRACE_BATCH_SIZE"`
|
||||
SendInterval time.Duration `default:"15s" envconfig:"TRACE_SEND_INTERVAL"`
|
||||
SendTimeout time.Duration `default:"500ms" envconfig:"TRACE_SEND_TIMEOUT"`
|
||||
}
|
||||
AwsAccount struct {
|
||||
AccessKeyID string `envconfig:"AWS_ACCESS_KEY_ID"`
|
||||
SecretAccessKey string `envconfig:"AWS_SECRET_ACCESS_KEY"`
|
||||
Region string `default:"us-east-1" envconfig:"AWS_REGION"`
|
||||
|
||||
// Get an AWS session from an implicit source if no explicit
|
||||
// configuration is provided. This is useful for taking advantage of
|
||||
// EC2/ECS instance roles.
|
||||
UseRole bool `envconfig:"AWS_USE_ROLE"`
|
||||
}
|
||||
Auth struct {
|
||||
AwsSecretID string `default:"auth-secret-key" envconfig:"AUTH_AWS_SECRET_ID"`
|
||||
KeyExpiration time.Duration `default:"3600s" envconfig:"AUTH_KEY_EXPIRATION"`
|
||||
}
|
||||
CMD string `envconfig:"CMD"`
|
||||
}
|
||||
|
||||
// !!! This prefix seems buried, if you copy and paste this main.go
|
||||
// file, its likely you will forget to update this.
|
||||
if err := envconfig.Process("WEB_APP", &cfg); err != nil {
|
||||
// The prefix used for loading env variables.
|
||||
// ie: export WEB_APP_ENV=dev
|
||||
envKeyPrefix := "WEB_APP"
|
||||
|
||||
// For additional details refer to https://github.com/kelseyhightower/envconfig
|
||||
if err := envconfig.Process(envKeyPrefix, &cfg); err != nil {
|
||||
log.Fatalf("main : Parsing Config : %v", err)
|
||||
}
|
||||
|
||||
@ -132,6 +143,9 @@ func main() {
|
||||
return // We displayed help.
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Config Validation & Defaults
|
||||
|
||||
// If base URL is empty, set the default value from the HTTP Host
|
||||
if cfg.App.BaseUrl == "" {
|
||||
baseUrl := cfg.HTTP.Host
|
||||
@ -149,17 +163,23 @@ func main() {
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// App Starting
|
||||
// Log App Info
|
||||
|
||||
// Print the build version for our logs. Also expose it under /debug/vars.
|
||||
expvar.NewString("build").Set(build)
|
||||
log.Printf("main : Started : Application Initializing version %q", build)
|
||||
defer log.Println("main : Completed")
|
||||
|
||||
// Print the config for our logs. It's important to any credentials in the config
|
||||
// that could expose a security risk are excluded from being json encoded by
|
||||
// applying the tag `json:"-"` to the struct var.
|
||||
{
|
||||
cfgJSON, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
log.Fatalf("main : Marshalling Config to JSON : %v", err)
|
||||
}
|
||||
log.Printf("main : Config : %v\n", string(cfgJSON))
|
||||
}
|
||||
|
||||
// TODO: Validate what is being written to the logs. We don't
|
||||
// want to leak credentials or anything that can be a security risk.
|
||||
@ -199,7 +219,7 @@ func main() {
|
||||
// if the maxmemory policy is set for redis, make sure its set on the cluster
|
||||
// default not set and will based on the redis config values defined on the server
|
||||
if cfg.Redis.MaxmemoryPolicy != "" {
|
||||
err = redisClient.ConfigSet(evictPolicyConfigKey, cfg.Redis.MaxmemoryPolicy).Err()
|
||||
err := redisClient.ConfigSet(evictPolicyConfigKey, cfg.Redis.MaxmemoryPolicy).Err()
|
||||
if err != nil {
|
||||
log.Fatalf("main : redis : ConfigSet maxmemory-policy : %v", err)
|
||||
}
|
||||
@ -214,6 +234,42 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Start Database
|
||||
var dbUrl url.URL
|
||||
{
|
||||
// Query parameters.
|
||||
var q url.Values
|
||||
|
||||
// Handle SSL Mode
|
||||
if cfg.DB.DisableTLS {
|
||||
q.Set("sslmode", "disable")
|
||||
} else {
|
||||
q.Set("sslmode", "require")
|
||||
}
|
||||
|
||||
q.Set("timezone", cfg.DB.Timezone)
|
||||
|
||||
// Construct url.
|
||||
dbUrl = url.URL{
|
||||
Scheme: cfg.DB.Driver,
|
||||
User: url.UserPassword(cfg.DB.User, cfg.DB.Pass),
|
||||
Host: cfg.DB.Host,
|
||||
Path: cfg.DB.Database,
|
||||
RawQuery: q.Encode(),
|
||||
}
|
||||
}
|
||||
|
||||
// Register informs the sqlxtrace package of the driver that we will be using in our program.
|
||||
// It uses a default service name, in the below case "postgres.db". To use a custom service
|
||||
// name use RegisterWithServiceName.
|
||||
sqltrace.Register(cfg.DB.Driver, &pq.Driver{}, sqltrace.WithServiceName("my-service"))
|
||||
masterDb, err := sqlxtrace.Open(cfg.DB.Driver, dbUrl.String())
|
||||
if err != nil {
|
||||
log.Fatalf("main : Register DB : %s : %v", cfg.DB.Driver, err)
|
||||
}
|
||||
defer masterDb.Close()
|
||||
|
||||
// =========================================================================
|
||||
// Deploy
|
||||
switch cfg.CMD {
|
||||
@ -494,7 +550,7 @@ func main() {
|
||||
|
||||
api := http.Server{
|
||||
Addr: cfg.HTTP.Host,
|
||||
Handler: handlers.APP(shutdown, log, cfg.App.StaticDir, cfg.App.TemplateDir, nil, nil, renderer),
|
||||
Handler: handlers.APP(shutdown, log, cfg.App.StaticDir, cfg.App.TemplateDir, masterDb, nil, renderer),
|
||||
ReadTimeout: cfg.HTTP.ReadTimeout,
|
||||
WriteTimeout: cfg.HTTP.WriteTimeout,
|
||||
MaxHeaderBytes: 1 << 20,
|
||||
|
@ -4,63 +4,117 @@
|
||||
# docker-compose down
|
||||
version: '3'
|
||||
|
||||
networks:
|
||||
shared-network:
|
||||
driver: bridge
|
||||
|
||||
services:
|
||||
|
||||
# This starts a local mongo DB.
|
||||
mongo:
|
||||
container_name: mongo
|
||||
networks:
|
||||
- shared-network
|
||||
image: mongo:3-jessie
|
||||
postgres:
|
||||
container_name: postgres
|
||||
image: postgres:11-alpine
|
||||
expose:
|
||||
- "5433"
|
||||
ports:
|
||||
- 27017:27017
|
||||
command: --bind_ip 0.0.0.0
|
||||
|
||||
# This is the core CRUD based service.
|
||||
web-api:
|
||||
container_name: web-api
|
||||
networks:
|
||||
- shared-network
|
||||
image: gcr.io/web-api/web-api-amd64:1.0
|
||||
ports:
|
||||
- 3000:3000 # CRUD API
|
||||
- 4000:4000 # DEBUG API
|
||||
- "5433:5432"
|
||||
environment:
|
||||
- WEB_APP_AUTH_KEY_ID=1
|
||||
# - WEB_APP_DB_HOST=got:got2015@ds039441.mongolab.com:39441/gotraining
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASS=postgres
|
||||
- POSTGRES_DB=shared
|
||||
|
||||
redis:
|
||||
image: redis:latest
|
||||
expose:
|
||||
- "6379"
|
||||
ports:
|
||||
- "6379:6379"
|
||||
entrypoint: redis-server --appendonly yes
|
||||
|
||||
datadog:
|
||||
container_name: datadog
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/datadog-agent/Dockerfile
|
||||
image: example-project/datadog:latest
|
||||
ports:
|
||||
- 8125:8125
|
||||
- 8126:8126
|
||||
env_file:
|
||||
- .env_docker_compose
|
||||
environment:
|
||||
- DD_API_KEY=${DD_API_KEY}
|
||||
- DD_LOGS_ENABLED=true
|
||||
- DD_APM_ENABLED=true
|
||||
- DD_RECEIVER_PORT=8126
|
||||
- DD_APM_NON_LOCAL_TRAFFIC=true
|
||||
- DD_LOGS_CONFIG_CONTAINER_COLLECT_ALL=true
|
||||
- DD_TAGS=source:docker env:dev
|
||||
- DD_DOGSTATSD_ORIGIN_DETECTION=true
|
||||
- DD_DOGSTATSD_NON_LOCAL_TRAFFIC=true
|
||||
- ECS_FARGATE=false
|
||||
|
||||
web-app:
|
||||
container_name: web-app
|
||||
build:
|
||||
context: .
|
||||
dockerfile: cmd/web-app/Dockerfile
|
||||
image: example-project/web-api:latest
|
||||
ports:
|
||||
- 3000:3000 # WEB APP
|
||||
- 4000:4000 # DEBUG API
|
||||
links:
|
||||
- postgres
|
||||
- redis
|
||||
- datadog
|
||||
env_file:
|
||||
- .env_docker_compose
|
||||
environment:
|
||||
- WEB_APP_HTTP_HOST=0.0.0.0:3000
|
||||
- WEB_APP_APP_BASE_URL=http://127.0.0.1:3000
|
||||
- WEB_APP_REDIS_HOST=redis
|
||||
- WEB_APP_DB_HOST=postgres
|
||||
- WEB_APP_DB_USER=postgres
|
||||
- WEB_APP_DB_PASS=postgres
|
||||
- WEB_APP_DB_DATABASE=shared
|
||||
- WEB_APP_AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
|
||||
- WEB_APP_AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
|
||||
- WEB_APP_AWS_REGION=${AWS_REGION}
|
||||
- WEB_APP_AWS_USE_ROLE=${AWS_USE_ROLE}
|
||||
- DD_TRACE_AGENT_HOSTNAME=datadog
|
||||
- DD_TRACE_AGENT_PORT=8126
|
||||
- DD_SERVICE_NAME=web-app
|
||||
- DD_ENV=dev
|
||||
# - GODEBUG=gctrace=1
|
||||
|
||||
# This sidecar publishes metrics to the console by default.
|
||||
metrics:
|
||||
container_name: metrics
|
||||
networks:
|
||||
- shared-network
|
||||
image: gcr.io/web-api/metrics-amd64:1.0
|
||||
web-api:
|
||||
container_name: web-api
|
||||
build:
|
||||
context: .
|
||||
dockerfile: cmd/web-api/Dockerfile
|
||||
image: example-project/web-api:latest
|
||||
ports:
|
||||
- 3001:3001 # EXPVAR API
|
||||
- 3001:3001 # WEB API
|
||||
- 4001:4001 # DEBUG API
|
||||
links:
|
||||
- postgres
|
||||
- redis
|
||||
- datadog
|
||||
env_file:
|
||||
- .env_docker_compose
|
||||
environment:
|
||||
- WEB_API_HTTP_HOST=0.0.0.0:3001
|
||||
- WEB_API_APP_BASE_URL=http://127.0.0.1:3001
|
||||
- WEB_API_REDIS_HOST=redis
|
||||
- WEB_API_DB_HOST=postgres
|
||||
- WEB_API_DB_USER=postgres
|
||||
- WEB_API_DB_PASS=postgres
|
||||
- WEB_API_DB_DATABASE=shared
|
||||
- WEB_API_AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
|
||||
- WEB_API_AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
|
||||
- WEB_API_AWS_REGION=${AWS_REGION}
|
||||
- WEB_API_AWS_USE_ROLE=${AWS_USE_ROLE}
|
||||
- DD_TRACE_AGENT_HOSTNAME=datadog
|
||||
- DD_TRACE_AGENT_PORT=8126
|
||||
- DD_SERVICE_NAME=web-app
|
||||
- DD_ENV=dev
|
||||
# - GODEBUG=gctrace=1
|
||||
|
||||
|
||||
|
||||
|
||||
# This sidecar publishes tracing to the console by default.
|
||||
tracer:
|
||||
container_name: tracer
|
||||
networks:
|
||||
- shared-network
|
||||
image: gcr.io/web-api/tracer-amd64:1.0
|
||||
ports:
|
||||
- 3002:3002 # TRACER API
|
||||
- 4002:4002 # DEBUG API
|
||||
# environment:
|
||||
# - WEB_APP_ZIPKIN_HOST=http://zipkin:9411/api/v2/spans
|
||||
|
||||
# This sidecar allows for the viewing of traces.
|
||||
zipkin:
|
||||
container_name: zipkin
|
||||
networks:
|
||||
- shared-network
|
||||
image: openzipkin/zipkin:2.11
|
||||
ports:
|
||||
- 9411:9411
|
||||
|
16
example-project/docker/datadog-agent/Dockerfile
Normal file
16
example-project/docker/datadog-agent/Dockerfile
Normal file
@ -0,0 +1,16 @@
|
||||
FROM datadog/agent:latest
|
||||
|
||||
LABEL maintainer="lee@geeksinthewoods.com"
|
||||
|
||||
#COPY target/go_expvar.conf.yaml /etc/datadog-agent/conf.d/go_expvar.d/conf.yaml
|
||||
COPY target/custom-init.sh /custom-init.sh
|
||||
|
||||
ARG service
|
||||
ENV SERVICE_NAME $service
|
||||
|
||||
ARG env="dev"
|
||||
ENV ENV $env
|
||||
|
||||
ENV DD_TAGS="source:docker service:${service} service_name:${service} cluster:NA env:${ENV}"
|
||||
|
||||
CMD ["/custom-init.sh"]
|
16
example-project/docker/datadog-agent/custom-init.sh
Executable file
16
example-project/docker/datadog-agent/custom-init.sh
Executable file
@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
configFile="/etc/datadog-agent/conf.d/go_expvar.d/conf.yaml"
|
||||
|
||||
echo -e "init_config:\n\ninstances:\n - expvar_url: http://localhost:80/debug/vars" > $configFile
|
||||
|
||||
if [[ "${DD_TAGS}" != "" ]]; then
|
||||
echo " tags:" >> $configFile
|
||||
for t in ${DD_TAGS}; do
|
||||
echo " - \"${t}\"" >> $configFile
|
||||
done
|
||||
fi
|
||||
|
||||
cat $configFile
|
||||
|
||||
/init
|
@ -1,33 +1,35 @@
|
||||
module geeks-accelerator/oss/saas-starter-kit/example-project
|
||||
|
||||
require (
|
||||
github.com/GuiaBolso/darwin v0.0.0-20170210191649-86919dfcf808 // indirect
|
||||
github.com/aws/aws-sdk-go v1.19.33
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible
|
||||
github.com/dimfeld/httptreemux v5.0.1+incompatible
|
||||
github.com/gitwak/gondolier v0.0.0-20190521205431-504d297a6c42 // indirect
|
||||
github.com/gitwak/sqlxmigrate v0.0.0-20190522211042-9625063dea5d
|
||||
github.com/go-playground/locales v0.12.1
|
||||
github.com/go-playground/universal-translator v0.16.0
|
||||
github.com/go-redis/redis v6.15.2+incompatible
|
||||
github.com/google/go-cmp v0.2.0
|
||||
github.com/hashicorp/golang-lru v0.5.1
|
||||
github.com/jmoiron/sqlx v1.2.0
|
||||
github.com/kelseyhightower/envconfig v1.3.0
|
||||
github.com/kr/pretty v0.1.0 // indirect
|
||||
github.com/leodido/go-urn v1.1.0 // indirect
|
||||
github.com/lib/pq v1.1.1
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
|
||||
github.com/opentracing/opentracing-go v1.1.0 // indirect
|
||||
github.com/openzipkin/zipkin-go v0.1.1
|
||||
github.com/pborman/uuid v0.0.0-20180122190007-c65b2f87fee3
|
||||
github.com/philhofer/fwd v1.0.0 // indirect
|
||||
github.com/philippgille/gokv v0.5.0 // indirect
|
||||
github.com/pkg/errors v0.8.0
|
||||
github.com/pkg/errors v0.8.1
|
||||
github.com/sethgrid/pester v0.0.0-20190127155807-68a33a018ad0
|
||||
github.com/stretchr/testify v1.3.0 // indirect
|
||||
github.com/tinylib/msgp v1.1.0 // indirect
|
||||
go.opencensus.io v0.14.0
|
||||
golang.org/x/crypto v0.0.0-20180910181607-0e37d006457b
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225 // indirect
|
||||
golang.org/x/sys v0.0.0-20190516110030-61b9204099cb // indirect
|
||||
golang.org/x/text v0.3.0 // indirect
|
||||
gopkg.in/DataDog/dd-trace-go.v1 v1.13.1
|
||||
gopkg.in/DataDog/dd-trace-go.v1 v1.14.0
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
|
||||
gopkg.in/go-playground/assert.v1 v1.2.1 // indirect
|
||||
gopkg.in/go-playground/validator.v9 v9.28.0
|
||||
|
@ -1,3 +1,5 @@
|
||||
github.com/GuiaBolso/darwin v0.0.0-20170210191649-86919dfcf808 h1:rxDa2t7Ep7E26WMVHjl+mdLr9Un7yRSzz1CwRW6fWNY=
|
||||
github.com/GuiaBolso/darwin v0.0.0-20170210191649-86919dfcf808/go.mod h1:3sqgkckuISJ5rs1EpOp6vCvwOUKe/z9vPmyuIlq8Q/A=
|
||||
github.com/aws/aws-sdk-go v1.19.32 h1:/usjSR6qsKfOKzk4tDNvZq7LqmP5+J0Cq/Uwsr2XVG8=
|
||||
github.com/aws/aws-sdk-go v1.19.32/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
|
||||
github.com/aws/aws-sdk-go v1.19.33 h1:qz9ZQtxCUuwBKdc5QiY6hKuISYGeRQyLVA2RryDEDaQ=
|
||||
@ -8,18 +10,28 @@ github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumC
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
|
||||
github.com/dimfeld/httptreemux v5.0.1+incompatible h1:Qj3gVcDNoOthBAqftuD596rm4wg/adLLz5xh5CmpiCA=
|
||||
github.com/dimfeld/httptreemux v5.0.1+incompatible/go.mod h1:rbUlSV+CCpv/SuqUTP/8Bk2O3LyUV436/yaRGkhP6Z0=
|
||||
github.com/gitwak/gondolier v0.0.0-20190521205431-504d297a6c42 h1:+lo4HFeG6LlcgwvsvQC8H5FG8yr/kDn89E51BTw3loE=
|
||||
github.com/gitwak/gondolier v0.0.0-20190521205431-504d297a6c42/go.mod h1:ecEQ8e4eHeWKPf+g6ByatPM7l4QZgR3G5ZIZKvEAdCE=
|
||||
github.com/gitwak/sqlxmigrate v0.0.0-20190522211042-9625063dea5d h1:oaUPMY0F+lNUkyB5tzsQS3EC0m9Cxdglesp63i3UPso=
|
||||
github.com/gitwak/sqlxmigrate v0.0.0-20190522211042-9625063dea5d/go.mod h1:e7vYkZWKUHC2Vl0/dIiQRKR3z2HMuswoLf2IiQmnMoQ=
|
||||
github.com/go-playground/locales v0.12.1 h1:2FITxuFt/xuCNP1Acdhv62OzaCiviiE4kotfhkmOqEc=
|
||||
github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM=
|
||||
github.com/go-playground/universal-translator v0.16.0 h1:X++omBR/4cE2MNg91AoC3rmGrCjJ8eAeUP/K/EKx4DM=
|
||||
github.com/go-playground/universal-translator v0.16.0/go.mod h1:1AnU7NaIRDWWzGEKwgtJRd2xk99HeFyHw3yid4rvQIY=
|
||||
github.com/go-redis/redis v6.15.2+incompatible h1:9SpNVG76gr6InJGxoZ6IuuxaCOQwDAhzyXg+Bs+0Sb4=
|
||||
github.com/go-redis/redis v6.15.2+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA=
|
||||
github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/google/go-cmp v0.2.0 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU=
|
||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af h1:pmfjZENx5imkbgOkpRUYLnmbU7UEFbjtDA2hxJ1ichM=
|
||||
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
|
||||
github.com/jmoiron/sqlx v1.2.0 h1:41Ip0zITnmWNR/vHV+S4m+VoUivnWY5E4OJfLZjCJMA=
|
||||
github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks=
|
||||
github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
|
||||
github.com/kelseyhightower/envconfig v1.3.0 h1:IvRS4f2VcIQy6j4ORGIf9145T/AsUB+oY8LyvN8BXNM=
|
||||
github.com/kelseyhightower/envconfig v1.3.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
|
||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
@ -29,6 +41,12 @@ github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/leodido/go-urn v1.1.0 h1:Sm1gr51B1kKyfD2BlRcLSiEkffoG96g6TPv6eRoEiB8=
|
||||
github.com/leodido/go-urn v1.1.0/go.mod h1:+cyI34gQWZcE1eQU7NVgKkkzdXDQHr1dBMtdAPozLkw=
|
||||
github.com/lib/pq v1.0.0 h1:X5PMW56eZitiTeO7tKzZxFCSpbFZJtkMMooicw2us9A=
|
||||
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.1.1 h1:sJZmqHoEaY7f+NPP8pgLB/WxulyR3fewgCM2qaSlBb4=
|
||||
github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||
github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
|
||||
github.com/opentracing/opentracing-go v1.1.0 h1:pWlfV3Bxv7k65HYwkikxat0+s3pV4bsqf19k25Ur8rU=
|
||||
@ -43,6 +61,8 @@ github.com/philippgille/gokv v0.5.0 h1:6bgvKt+RR1BDxhD/oLXDTA9a7ws8xbgV3767ytBNr
|
||||
github.com/philippgille/gokv v0.5.0/go.mod h1:3qSKa2SgG4qXwLfF4htVEWRoRNLi86+fNdn+jQH5Clw=
|
||||
github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw=
|
||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/sethgrid/pester v0.0.0-20190127155807-68a33a018ad0 h1:X9XMOYjxEfAYSy3xK1DzO5dMkkWhs9E9UCcS1IERx2k=
|
||||
@ -62,8 +82,11 @@ golang.org/x/sys v0.0.0-20190516110030-61b9204099cb h1:k07iPOt0d6nEnwXF+kHB+iEg+
|
||||
golang.org/x/sys v0.0.0-20190516110030-61b9204099cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
gopkg.in/DataDog/dd-trace-go.v1 v1.13.1 h1:oTzOClfuudNhW9Skkp2jxjqYO92uDKXqKLbiuPA13Rk=
|
||||
gopkg.in/DataDog/dd-trace-go.v1 v1.13.1/go.mod h1:DVp8HmDh8PuTu2Z0fVVlBsyWaC++fzwVCaGWylTe3tg=
|
||||
gopkg.in/DataDog/dd-trace-go.v1 v1.14.0 h1:p/8j8WV6HC+6c99FMWIPrPPs+PiXU/ShrBxHbO8S8V0=
|
||||
gopkg.in/DataDog/dd-trace-go.v1 v1.14.0/go.mod h1:DVp8HmDh8PuTu2Z0fVVlBsyWaC++fzwVCaGWylTe3tg=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
@ -1,124 +0,0 @@
|
||||
// All material is licensed under the Apache License Version 2.0, January 2004
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"go.opencensus.io/trace"
|
||||
mgo "gopkg.in/mgo.v2"
|
||||
)
|
||||
|
||||
// ErrInvalidDBProvided is returned in the event that an uninitialized db is
|
||||
// used to perform actions against.
|
||||
var ErrInvalidDBProvided = errors.New("invalid DB provided")
|
||||
|
||||
// DB is a collection of support for different DB technologies. Currently
|
||||
// only MongoDB has been implemented. We want to be able to access the raw
|
||||
// database support for the given DB so an interface does not work. Each
|
||||
// database is too different.
|
||||
type DB struct {
|
||||
|
||||
// MongoDB Support.
|
||||
database *mgo.Database
|
||||
session *mgo.Session
|
||||
}
|
||||
|
||||
// New returns a new DB value for use with MongoDB based on a registered
|
||||
// master session.
|
||||
func New(url string, timeout time.Duration) (*DB, error) {
|
||||
|
||||
// Set the default timeout for the session.
|
||||
if timeout == 0 {
|
||||
timeout = 60 * time.Second
|
||||
}
|
||||
|
||||
// Create a session which maintains a pool of socket connections
|
||||
// to our MongoDB.
|
||||
ses, err := mgo.DialWithTimeout(url, timeout)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "mgo.DialWithTimeout: %s,%v", url, timeout)
|
||||
}
|
||||
|
||||
// Reads may not be entirely up-to-date, but they will always see the
|
||||
// history of changes moving forward, the data read will be consistent
|
||||
// across sequential queries in the same session, and modifications made
|
||||
// within the session will be observed in following queries (read-your-writes).
|
||||
// http://godoc.org/labix.org/v2/mgo#Session.SetMode
|
||||
ses.SetMode(mgo.Monotonic, true)
|
||||
|
||||
db := DB{
|
||||
database: ses.DB(""),
|
||||
session: ses,
|
||||
}
|
||||
|
||||
return &db, nil
|
||||
}
|
||||
|
||||
// Close closes a DB value being used with MongoDB.
|
||||
func (db *DB) Close() {
|
||||
db.session.Close()
|
||||
}
|
||||
|
||||
// Copy returns a new DB value for use with MongoDB based on master session.
|
||||
func (db *DB) Copy() *DB {
|
||||
ses := db.session.Copy()
|
||||
|
||||
// As per the mgo documentation, https://godoc.org/gopkg.in/mgo.v2#Session.DB
|
||||
// if no database name is specified, then use the default one, or the one that
|
||||
// the connection was dialed with.
|
||||
newDB := DB{
|
||||
database: ses.DB(""),
|
||||
session: ses,
|
||||
}
|
||||
|
||||
return &newDB
|
||||
}
|
||||
|
||||
// Execute is used to execute MongoDB commands.
|
||||
func (db *DB) Execute(ctx context.Context, collName string, f func(*mgo.Collection) error) error {
|
||||
ctx, span := trace.StartSpan(ctx, "platform.DB.Execute")
|
||||
defer span.End()
|
||||
|
||||
if db == nil || db.session == nil {
|
||||
return errors.Wrap(ErrInvalidDBProvided, "db == nil || db.session == nil")
|
||||
}
|
||||
|
||||
return f(db.database.C(collName))
|
||||
}
|
||||
|
||||
// ExecuteTimeout is used to execute MongoDB commands with a timeout.
|
||||
func (db *DB) ExecuteTimeout(ctx context.Context, timeout time.Duration, collName string, f func(*mgo.Collection) error) error {
|
||||
ctx, span := trace.StartSpan(ctx, "platform.DB.ExecuteTimeout")
|
||||
defer span.End()
|
||||
|
||||
if db == nil || db.session == nil {
|
||||
return errors.Wrap(ErrInvalidDBProvided, "db == nil || db.session == nil")
|
||||
}
|
||||
|
||||
db.session.SetSocketTimeout(timeout)
|
||||
|
||||
return f(db.database.C(collName))
|
||||
}
|
||||
|
||||
// StatusCheck validates the DB status good.
|
||||
func (db *DB) StatusCheck(ctx context.Context) error {
|
||||
ctx, span := trace.StartSpan(ctx, "platform.DB.StatusCheck")
|
||||
defer span.End()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Query provides a string version of the value
|
||||
func Query(value interface{}) string {
|
||||
json, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return string(json)
|
||||
}
|
5
example-project/sample.env_docker_compose
Normal file
5
example-project/sample.env_docker_compose
Normal file
@ -0,0 +1,5 @@
|
||||
AWS_ACCESS_KEY_ID=XXXX
|
||||
AWS_SECRET_ACCESS_KEY=XXXX
|
||||
AWS_REGION=us-east-1
|
||||
AWS_USE_ROLE=false
|
||||
DD_API_KEY=XXXX
|
17
example-project/schema/init_schema.go
Normal file
17
example-project/schema/init_schema.go
Normal file
@ -0,0 +1,17 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/jmoiron/sqlx"
|
||||
"log"
|
||||
)
|
||||
|
||||
// initSchema runs before any migrations are executed. This happens when no other migrations
|
||||
// have previously been executed.
|
||||
func initSchema(db *sqlx.DB, log *log.Logger) func(*sqlx.DB) error {
|
||||
f := func(*sqlx.DB) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return f
|
||||
}
|
130
example-project/schema/main.go
Normal file
130
example-project/schema/main.go
Normal file
@ -0,0 +1,130 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"expvar"
|
||||
"github.com/lib/pq"
|
||||
"log"
|
||||
"net/url"
|
||||
"os"
|
||||
|
||||
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/flag"
|
||||
"github.com/kelseyhightower/envconfig"
|
||||
sqltrace "gopkg.in/DataDog/dd-trace-go.v1/contrib/database/sql"
|
||||
sqlxtrace "gopkg.in/DataDog/dd-trace-go.v1/contrib/jmoiron/sqlx"
|
||||
"github.com/gitwak/sqlxmigrate"
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
// build is the git version of this program. It is set using build flags in the makefile.
|
||||
var build = "develop"
|
||||
|
||||
func main() {
|
||||
// =========================================================================
|
||||
// Logging
|
||||
|
||||
log := log.New(os.Stdout, "Schema : ", log.LstdFlags|log.Lmicroseconds|log.Lshortfile)
|
||||
|
||||
// =========================================================================
|
||||
// Configuration
|
||||
var cfg struct {
|
||||
Env string `default:"dev" envconfig:"ENV"`
|
||||
DB struct {
|
||||
Host string `default:"127.0.0.1:5433" envconfig:"DB_HOST"`
|
||||
User string `default:"postgres" envconfig:"DB_USER"`
|
||||
Pass string `default:"postgres" envconfig:"DB_PASS" json:"-"` // don't print
|
||||
Database string `default:"shared" envconfig:"DB_DATABASE"`
|
||||
Driver string `default:"postgres" envconfig:"DB_DRIVER"`
|
||||
Timezone string `default:"utc" envconfig:"DB_TIMEZONE"`
|
||||
DisableTLS bool `default:"false" envconfig:"DB_DISABLE_TLS"`
|
||||
}
|
||||
}
|
||||
|
||||
// The prefix used for loading env variables.
|
||||
// ie: export SCHEMA_ENV=dev
|
||||
envKeyPrefix := "SCHEMA"
|
||||
|
||||
// For additional details refer to https://github.com/kelseyhightower/envconfig
|
||||
if err := envconfig.Process(envKeyPrefix, &cfg); err != nil {
|
||||
log.Fatalf("main : Parsing Config : %v", err)
|
||||
}
|
||||
|
||||
if err := flag.Process(&cfg); err != nil {
|
||||
if err != flag.ErrHelp {
|
||||
log.Fatalf("main : Parsing Command Line : %v", err)
|
||||
}
|
||||
return // We displayed help.
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Log App Info
|
||||
|
||||
// Print the build version for our logs. Also expose it under /debug/vars.
|
||||
expvar.NewString("build").Set(build)
|
||||
log.Printf("main : Started : Application Initializing version %q", build)
|
||||
defer log.Println("main : Completed")
|
||||
|
||||
// Print the config for our logs. It's important to any credentials in the config
|
||||
// that could expose a security risk are excluded from being json encoded by
|
||||
// applying the tag `json:"-"` to the struct var.
|
||||
{
|
||||
cfgJSON, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
log.Fatalf("main : Marshalling Config to JSON : %v", err)
|
||||
}
|
||||
log.Printf("main : Config : %v\n", string(cfgJSON))
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Start Database
|
||||
var dbUrl url.URL
|
||||
{
|
||||
// Query parameters.
|
||||
var q url.Values
|
||||
|
||||
// Handle SSL Mode
|
||||
if cfg.DB.DisableTLS {
|
||||
q.Set("sslmode", "disable")
|
||||
} else {
|
||||
q.Set("sslmode", "require")
|
||||
}
|
||||
|
||||
q.Set("timezone", cfg.DB.Timezone)
|
||||
|
||||
// Construct url.
|
||||
dbUrl = url.URL{
|
||||
Scheme: cfg.DB.Driver,
|
||||
User: url.UserPassword(cfg.DB.User, cfg.DB.Pass),
|
||||
Host: cfg.DB.Host,
|
||||
Path: cfg.DB.Database,
|
||||
RawQuery: q.Encode(),
|
||||
}
|
||||
}
|
||||
|
||||
// Register informs the sqlxtrace package of the driver that we will be using in our program.
|
||||
// It uses a default service name, in the below case "postgres.db". To use a custom service
|
||||
// name use RegisterWithServiceName.
|
||||
sqltrace.Register(cfg.DB.Driver, &pq.Driver{}, sqltrace.WithServiceName("my-service"))
|
||||
masterDb, err := sqlxtrace.Open(cfg.DB.Driver, dbUrl.String())
|
||||
if err != nil {
|
||||
log.Fatalf("main : Register DB : %s : %v", cfg.DB.Driver, err)
|
||||
}
|
||||
defer masterDb.Close()
|
||||
|
||||
// =========================================================================
|
||||
// Start Migrations
|
||||
|
||||
// Load list of Schema migrations and init new sqlxmigrate client
|
||||
migrations := migrationList(masterDb, log)
|
||||
m := sqlxmigrate.New(masterDb, sqlxmigrate.DefaultOptions, migrations)
|
||||
|
||||
// Append any schema that need to be applied if this is a fresh migration
|
||||
// ie. the migrations database table does not exist.
|
||||
m.InitSchema(initSchema(masterDb, log))
|
||||
|
||||
// Execute the migrations
|
||||
if err = m.Migrate(); err != nil {
|
||||
log.Fatalf("main : Migrate : %v", err)
|
||||
}
|
||||
log.Printf("main : Migrate : Completed")
|
||||
}
|
129
example-project/schema/migrations.go
Normal file
129
example-project/schema/migrations.go
Normal file
@ -0,0 +1,129 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/gitwak/sqlxmigrate"
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
// migrationList returns a list of migrations to be executed. If the id of the
|
||||
// migration already exists in the migrations table it will be skipped.
|
||||
func migrationList(db *sqlx.DB, log *log.Logger) []*sqlxmigrate.Migration {
|
||||
return []*sqlxmigrate.Migration{
|
||||
// create table users
|
||||
{
|
||||
ID: "20190522-01a",
|
||||
Migrate: func(tx *sql.Tx) error {
|
||||
q := `CREATE TABLE IF NOT EXISTS users (
|
||||
id char(36) NOT NULL,
|
||||
email varchar(200) NOT NULL,
|
||||
title varchar(100) NOT NULL DEFAULT '',
|
||||
first_name varchar(200) NOT NULL DEFAULT '',
|
||||
last_name varchar(200) NOT NULL DEFAULT '',
|
||||
password_hash varchar(200) NOT NULL,
|
||||
password_reset varchar(200) DEFAULT NULL,
|
||||
password_salt varchar(200) NOT NULL,
|
||||
phone varchar(20) NOT NULL DEFAULT '',
|
||||
status enum('active','disabled') NOT NULL DEFAULT 'active',
|
||||
timezone varchar(128) NOT NULL DEFAULT 'America/Anchorage',
|
||||
created_at timestamp(0) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at timestamp(0) DEFAULT NULL,
|
||||
deleted_at timestamp(0) DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT email UNIQUE (email)
|
||||
) ;`
|
||||
if _, err := tx.Exec(q); err != nil {
|
||||
return errors.WithMessagef(err, "Query failed %s", q)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Rollback: func(tx *sql.Tx) error {
|
||||
q := `DROP TABLE IF EXISTS users`
|
||||
if _, err := tx.Exec(q); err != nil {
|
||||
return errors.WithMessagef(err, "Query failed %s", q)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
// create new table accounts
|
||||
{
|
||||
ID: "20190522-01b",
|
||||
Migrate: func(tx *sql.Tx) error {
|
||||
q := `CREATE TABLE IF NOT EXISTS accounts (
|
||||
id char(36) NOT NULL,
|
||||
name varchar(255) NOT NULL,
|
||||
address1 varchar(255) NOT NULL DEFAULT '',
|
||||
address2 varchar(255) NOT NULL DEFAULT '',
|
||||
city varchar(100) NOT NULL DEFAULT '',
|
||||
region varchar(255) NOT NULL DEFAULT '',
|
||||
country varchar(255) NOT NULL DEFAULT '',
|
||||
zipcode varchar(20) NOT NULL DEFAULT '',
|
||||
status enum('active','pending','disabled') NOT NULL DEFAULT 'active',
|
||||
timezone varchar(128) NOT NULL DEFAULT 'America/Anchorage',
|
||||
signup_user_id char(36) DEFAULT NULL,
|
||||
billing_user_id char(36) DEFAULT NULL,
|
||||
created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at datetime DEFAULT NULL,
|
||||
deleted_at datetime DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY name (name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`
|
||||
if _, err := tx.Exec(q); err != nil {
|
||||
return errors.WithMessagef(err, "Query failed %s", q)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Rollback: func(tx *sql.Tx) error {
|
||||
q := `DROP TABLE IF EXISTS accounts`
|
||||
if _, err := tx.Exec(q); err != nil {
|
||||
return errors.WithMessagef(err, "Query failed %s", q)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
// create new table user_accounts
|
||||
{
|
||||
ID: "20190522-01c",
|
||||
Migrate: func(tx *sql.Tx) error {
|
||||
q1 := `CREATE TYPE IF NOT EXISTS role_t as enum('admin', 'user');`
|
||||
if _, err := tx.Exec(q1); err != nil {
|
||||
return errors.WithMessagef(err, "Query failed %s", q1)
|
||||
}
|
||||
|
||||
q2 := `CREATE TABLE IF NOT EXISTS users_accounts (
|
||||
id char(36) NOT NULL,
|
||||
account_id char(36) NOT NULL,
|
||||
user_id ichar(36) NOT NULL,
|
||||
roles role_t[] NOT NULL,
|
||||
created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at datetime DEFAULT NULL,
|
||||
deleted_at datetime DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY user_account (user_id,account_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`
|
||||
if _, err := tx.Exec(q1); err != nil {
|
||||
return errors.WithMessagef(err, "Query failed %s", q2)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
Rollback: func(tx *sql.Tx) error {
|
||||
q1 := `DROP TYPE IF EXISTS role_t`
|
||||
if _, err := tx.Exec(q1); err != nil {
|
||||
return errors.WithMessagef(err, "Query failed %s", q1)
|
||||
}
|
||||
|
||||
q2 := `DROP TABLE IF EXISTS users_accounts`
|
||||
if _, err := tx.Exec(q2); err != nil {
|
||||
return errors.WithMessagef(err, "Query failed %s", q2)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user