diff --git a/nethttp-mysql/Dockerfile b/nethttp-mysql/Dockerfile new file mode 100644 index 00000000..08780cab --- /dev/null +++ b/nethttp-mysql/Dockerfile @@ -0,0 +1,13 @@ +FROM golang:1.22 AS build +WORKDIR /app +COPY go.mod go.sum* ./ +RUN go mod download || true +COPY . . +RUN go mod tidy && CGO_ENABLED=0 GOOS=linux go build -o app . + +FROM alpine:3.19 +WORKDIR /app +RUN apk add --no-cache ca-certificates +COPY --from=build /app/app /app/app +EXPOSE 8080 +ENTRYPOINT ["/app/app"] diff --git a/nethttp-mysql/README.md b/nethttp-mysql/README.md new file mode 100644 index 00000000..a1eaa8b9 --- /dev/null +++ b/nethttp-mysql/README.md @@ -0,0 +1,56 @@ +# nethttp-mysql + +Minimal Go sample that uses `net/http` + `database/sql` with the MySQL +driver. The app exposes three endpoints (`/health`, `/users`, +`/users/add`) and connects to a standard MySQL 8 instance. + +This sample is wired into Keploy's end-to-end pipeline as a regression +guard for the MySQL replay command-phase loop: in replay mode the app +must successfully complete `db.Ping()` (which sends commands to the +mocked MySQL connection) and bind `:8080`. If the proxy ever re- +introduces a zero/overflow read deadline in the MySQL command phase the +Go driver blocks inside `db.Ping()`, the HTTP listener never starts, +and every replayed request fails with `connection reset by peer` — +making the regression loud. + +## Run locally + +```sh +# Terminal 1 +docker network create keploy-network +docker run -d --name mysql --network keploy-network \ + -e MYSQL_ROOT_PASSWORD=password -e MYSQL_DATABASE=testdb mysql:8.0 + +# Terminal 2 +docker build -t nethttp-mysql . +docker run --rm -p 8080:8080 --network keploy-network \ + -e MYSQL_HOST=mysql -e MYSQL_USER=root -e MYSQL_PASSWORD=password \ + -e MYSQL_DATABASE=testdb nethttp-mysql +``` + +Then hit: + +```sh +curl http://localhost:8080/health +curl http://localhost:8080/users +curl "http://localhost:8080/users/add?name=charlie&email=charlie@test.com" +``` + +## Use with Keploy + +```sh +keploy record -c "docker run -p 8080:8080 --name nethttp-mysql --network keploy-network \ + -e MYSQL_HOST=mysql -e MYSQL_USER=root -e MYSQL_PASSWORD=password \ + -e MYSQL_DATABASE=testdb nethttp-mysql" \ + --container-name nethttp-mysql --network-name keploy-network --buildDelay 60 + +# ... exercise the endpoints ... + +# Stop MySQL, then replay against mocks: +docker rm -f mysql +keploy test -c "docker run -p 8080:8080 --name nethttp-mysql --network keploy-network \ + -e MYSQL_HOST=mysql -e MYSQL_USER=root -e MYSQL_PASSWORD=password \ + -e MYSQL_DATABASE=testdb nethttp-mysql" \ + --containerName nethttp-mysql --network-name keploy-network \ + --apiTimeout 60 --delay 20 +``` diff --git a/nethttp-mysql/go.mod b/nethttp-mysql/go.mod new file mode 100644 index 00000000..462fdacb --- /dev/null +++ b/nethttp-mysql/go.mod @@ -0,0 +1,7 @@ +module github.com/keploy/samples-go/nethttp-mysql + +go 1.22 + +require github.com/go-sql-driver/mysql v1.8.1 + +require filippo.io/edwards25519 v1.1.0 // indirect diff --git a/nethttp-mysql/go.sum b/nethttp-mysql/go.sum new file mode 100644 index 00000000..19dbcece --- /dev/null +++ b/nethttp-mysql/go.sum @@ -0,0 +1,4 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= diff --git a/nethttp-mysql/main.go b/nethttp-mysql/main.go new file mode 100644 index 00000000..abfe9f17 --- /dev/null +++ b/nethttp-mysql/main.go @@ -0,0 +1,127 @@ +// Minimal net/http + database/sql + MySQL sample used by Keploy's +// end-to-end pipeline. Exercises the full MySQL connection-and-command +// phase so regressions in the replay proxy (e.g. a zero read deadline +// in the command-phase loop) surface as the Go MySQL driver blocking +// inside db.Ping(), which prevents the HTTP server from binding. +package main + +import ( + "database/sql" + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "time" + + _ "github.com/go-sql-driver/mysql" +) + +type User struct { + ID int `json:"id"` + Name string `json:"name"` + Email string `json:"email"` +} + +var db *sql.DB + +func envDefault(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +func main() { + host := envDefault("MYSQL_HOST", "mysql") + port := envDefault("MYSQL_PORT", "3306") + user := envDefault("MYSQL_USER", "root") + pass := envDefault("MYSQL_PASSWORD", "password") + dbname := envDefault("MYSQL_DATABASE", "testdb") + + dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true", user, pass, host, port, dbname) + log.Printf("Connecting to MySQL at %s:%s/%s", host, port, dbname) + + var err error + for attempt := 1; attempt <= 30; attempt++ { + db, err = sql.Open("mysql", dsn) + if err == nil { + err = db.Ping() + if err == nil { + break + } + } + log.Printf("waiting for mysql (attempt %d): %v", attempt, err) + time.Sleep(2 * time.Second) + } + if err != nil { + log.Fatalf("could not connect to mysql after retries: %v", err) + } + + if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS users ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100), + email VARCHAR(100) + )`); err != nil { + log.Fatalf("failed to create table: %v", err) + } + + var count int + _ = db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count) + if count == 0 { + if _, err := db.Exec("INSERT INTO users (name,email) VALUES ('alice','alice@example.com'), ('bob','bob@example.com')"); err != nil { + log.Fatalf("failed to seed: %v", err) + } + } + + http.HandleFunc("/users", getUsers) + http.HandleFunc("/users/add", addUser) + http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + if err := db.Ping(); err != nil { + http.Error(w, err.Error(), http.StatusServiceUnavailable) + return + } + fmt.Fprintln(w, "ok") + }) + + log.Println("listening on :8080") + log.Fatal(http.ListenAndServe(":8080", nil)) +} + +func getUsers(w http.ResponseWriter, r *http.Request) { + rows, err := db.Query("SELECT id, name, email FROM users") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer rows.Close() + + users := make([]User, 0) + for rows.Next() { + var u User + if err := rows.Scan(&u.ID, &u.Name, &u.Email); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + users = append(users, u) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(users) +} + +func addUser(w http.ResponseWriter, r *http.Request) { + name := r.URL.Query().Get("name") + email := r.URL.Query().Get("email") + if name == "" || email == "" { + http.Error(w, "name and email required", http.StatusBadRequest) + return + } + res, err := db.Exec("INSERT INTO users (name,email) VALUES (?,?)", name, email) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + id, _ := res.LastInsertId() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(User{ID: int(id), Name: name, Email: email}) +}