diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000000..36253ca3b8 --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,72 @@ +name: cd + +on: + push: + branches: [main] + +jobs: + deploy: + name: Deploy + runs-on: ubuntu-latest + + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Install goose + run: | + curl -fsSL https://raw.githubusercontent.com/pressly/goose/master/install.sh | sh + + - id: 'auth' + uses: 'google-github-actions/auth@v2' + with: + credentials_json: '${{ secrets.GCP_CREDENTIALS }}' + + - name: 'Set up Cloud SDK' + uses: 'google-github-actions/setup-gcloud@v3' + + - name: Configure gcloud defaults + run: | + gcloud config set project notely-473217 + gcloud config set run/region us-central1 + + - name: Set IMAGE_TAG + run: | + SHORT_SHA="$(echo ${{github.sha}} | head -c 8)" + echo "IMAGE_TAG=$SHORT_SHA" >> "$GITHUB_ENV" + + - name: Build and push image (Cloud Build) + run: | + gcloud builds submit \ + --tag us-central1-docker.pkg.dev/notely-473217/notely-ar-repo/notely:${IMAGE_TAG} . + + - name: Debug DB URL presence + run: | + [ -n "$DATABASE_URL" ] && echo "DATABASE_URL is set" || echo "DATABASE_URL is MISSING" + + - name: Run DB migrations + run: | + export PATH="$PWD/bin:$PATH" + chmod +x scripts/migrateup.sh + ./scripts/migrateup.sh + + - name: Deploy to Cloud Run + run: | + gcloud run deploy notely \ + --image us-central1-docker.pkg.dev/notely-473217/notely-ar-repo/notely:${IMAGE_TAG} \ + --allow-unauthenticated \ + --max-instances=4 + + # Optional: ensure unauthenticated access if first deploy + - name: Ensure public invoker (optional) + continue-on-error: true + run: | + gcloud run services add-iam-policy-binding notely \ + --member="allUsers" \ + --role="roles/run.invoker" + + + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000000..2eb0578a96 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,54 @@ +name: ci + +on: + pull_request: + branches: [main] + push: + branches: [addtests] + +jobs: + tests: + name: Tests + runs-on: ubuntu-latest + + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "stable" + + - name: Run tests + run: go test -cover ./... + + - name: Install gosec + run: go install github.com/securego/gosec/v2/cmd/gosec@latest + + - name: Check gosec + run: gosec ./... + + style: + name: Style + runs-on: ubuntu-latest + + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "stable" + + - name: Run formatting check + run: test -z "$(gofmt -l .)" + + - name: Install staticcheck + run: go install honnef.co/go/tools/cmd/staticcheck@latest + + - name: Run staticcheck + run: staticcheck ./... + + diff --git a/Dockerfile b/Dockerfile index 2be3d18b81..3b2dfa3c7f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,15 @@ -FROM --platform=linux/amd64 debian:stable-slim +# Build stage +FROM golang:1.22 AS build +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o notely . -RUN apt-get update && apt-get install -y ca-certificates -ADD notely /usr/bin/notely - -CMD ["notely"] +# Run stage +FROM debian:stable-slim +RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* +COPY --from=build /app/notely /usr/bin/notely +EXPOSE 8080 +CMD ["/usr/bin/notely"] diff --git a/README.md b/README.md index c2bec0368b..6a819b3713 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # learn-cicd-starter (Notely) +![Testing Badge](https://github.com/tjtreem/learn-cicd-starter/actions/workflows/ci.yml/badge.svg) + This repo contains the starter code for the "Notely" application for the "Learn CICD" course on [Boot.dev](https://boot.dev). ## Local Development @@ -21,3 +23,9 @@ go build -o notely && ./notely *This starts the server in non-database mode.* It will serve a simple webpage at `http://localhost:8080`. You do *not* need to set up a database or any interactivity on the webpage yet. Instructions for that will come later in the course! + + +Tim Treem's version of Boot.Dev's Notely app. + + + diff --git a/handler_user.go b/handler_user.go index d53d4316fb..8a2d8f2fc8 100644 --- a/handler_user.go +++ b/handler_user.go @@ -7,6 +7,7 @@ import ( "encoding/json" "net/http" "time" + "log" "github.com/bootdotdev/learn-cicd-starter/internal/database" "github.com/google/uuid" @@ -37,7 +38,9 @@ func (cfg *apiConfig) handlerUsersCreate(w http.ResponseWriter, r *http.Request) Name: params.Name, ApiKey: apiKey, }) + if err != nil { + log.Printf("create user failed: %v", err) respondWithError(w, http.StatusInternalServerError, "Couldn't create user", err) return } diff --git a/internal/auth/get_api_key_test.go b/internal/auth/get_api_key_test.go new file mode 100644 index 0000000000..2bd36e6227 --- /dev/null +++ b/internal/auth/get_api_key_test.go @@ -0,0 +1,55 @@ +package auth + +import ( + "errors" + "net/http" + "testing" +) + +type testCase struct { + name string + setHeader bool + headerVal string + wantKey string + wantErr error +} + +func TestGetAPIKey(t *testing.T) { + cases := []testCase{ + {name: "no header", setHeader: false, wantErr: ErrNoAuthHeaderIncluded}, + {name: "empty header", setHeader: true, headerVal: "", wantErr: ErrNoAuthHeaderIncluded}, + {name: "valid", setHeader: true, headerVal: "ApiKey my-secret", wantKey: "my-secret", wantErr: nil}, + {name: "wrong prefix", setHeader: true, headerVal: "Bearer token", wantErr: errors.New("any")}, + {name: "missing key", setHeader: true, headerVal: "ApiKey", wantErr: errors.New("any")}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + headers := http.Header{} + if tc.setHeader { + headers.Set("Authorization", tc.headerVal) + } + + got, err := GetAPIKey(headers) + + if tc.wantErr != nil { + // For the specific missing-header error: + if errors.Is(tc.wantErr, ErrNoAuthHeaderIncluded) && !errors.Is(err, ErrNoAuthHeaderIncluded) { + t.Fatalf("expected ErrNoAuthHeaderIncluded, got %v", err) + } + // For generic malformed cases just ensure err != nil + if errors.Is(tc.wantErr, ErrNoAuthHeaderIncluded) && err == nil { + t.Fatalf("expected error, got nil") + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.wantKey { + t.Fatalf("want key %q, got %q", tc.wantKey, got) + } + }) + } +} diff --git a/json.go b/json.go index 1e6e7985e1..d2de41b725 100644 --- a/json.go +++ b/json.go @@ -30,5 +30,10 @@ func respondWithJSON(w http.ResponseWriter, code int, payload interface{}) { return } w.WriteHeader(code) - w.Write(dat) + + n, err := w.Write(dat) + if err != nil { + log.Printf("respondWithJSON: write error after %d bytes: %v", n, err) + return + } } diff --git a/main.go b/main.go index 19d7366c5f..286412ffb6 100644 --- a/main.go +++ b/main.go @@ -7,6 +7,7 @@ import ( "log" "net/http" "os" + "time" "github.com/go-chi/chi" "github.com/go-chi/cors" @@ -89,10 +90,12 @@ func main() { router.Mount("/v1", v1Router) srv := &http.Server{ - Addr: ":" + port, - Handler: router, + Addr: ":" + port, + Handler: router, + ReadHeaderTimeout: 1 * time.Second, } log.Printf("Serving on port: %s\n", port) log.Fatal(srv.ListenAndServe()) + } diff --git a/scripts/migrateup.sh b/scripts/migrateup.sh index fe69c6fc7a..0f073a2c2f 100755 --- a/scripts/migrateup.sh +++ b/scripts/migrateup.sh @@ -1,8 +1,5 @@ #!/bin/bash - -if [ -f .env ]; then - source .env -fi - -cd sql/schema -goose turso $DATABASE_URL up +set -euo pipefail +cd "$(dirname "$0")/../sql/schema" +echo "Running: goose turso up" +goose turso "$DATABASE_URL" up diff --git a/static/index.html b/static/index.html index 72be101028..5d4ad73c09 100644 --- a/static/index.html +++ b/static/index.html @@ -7,7 +7,7 @@ -

Notely

+

Welcome to Notely

diff --git a/vendor/github.com/go-chi/chi/chi.go b/vendor/github.com/go-chi/chi/chi.go index b7063dc297..db1780b1c6 100644 --- a/vendor/github.com/go-chi/chi/chi.go +++ b/vendor/github.com/go-chi/chi/chi.go @@ -1,29 +1,29 @@ -// // Package chi is a small, idiomatic and composable router for building HTTP services. // // chi requires Go 1.10 or newer. // // Example: -// package main // -// import ( -// "net/http" +// package main +// +// import ( +// "net/http" // -// "github.com/go-chi/chi" -// "github.com/go-chi/chi/middleware" -// ) +// "github.com/go-chi/chi" +// "github.com/go-chi/chi/middleware" +// ) // -// func main() { -// r := chi.NewRouter() -// r.Use(middleware.Logger) -// r.Use(middleware.Recoverer) +// func main() { +// r := chi.NewRouter() +// r.Use(middleware.Logger) +// r.Use(middleware.Recoverer) // -// r.Get("/", func(w http.ResponseWriter, r *http.Request) { -// w.Write([]byte("root.")) -// }) +// r.Get("/", func(w http.ResponseWriter, r *http.Request) { +// w.Write([]byte("root.")) +// }) // -// http.ListenAndServe(":3333", r) -// } +// http.ListenAndServe(":3333", r) +// } // // See github.com/go-chi/chi/_examples/ for more in-depth examples. // @@ -47,12 +47,12 @@ // placeholder which will match / characters. // // Examples: -// "/user/{name}" matches "/user/jsmith" but not "/user/jsmith/info" or "/user/jsmith/" -// "/user/{name}/info" matches "/user/jsmith/info" -// "/page/*" matches "/page/intro/latest" -// "/page/*/index" also matches "/page/intro/latest" -// "/date/{yyyy:\\d\\d\\d\\d}/{mm:\\d\\d}/{dd:\\d\\d}" matches "/date/2017/04/01" // +// "/user/{name}" matches "/user/jsmith" but not "/user/jsmith/info" or "/user/jsmith/" +// "/user/{name}/info" matches "/user/jsmith/info" +// "/page/*" matches "/page/intro/latest" +// "/page/*/index" also matches "/page/intro/latest" +// "/date/{yyyy:\\d\\d\\d\\d}/{mm:\\d\\d}/{dd:\\d\\d}" matches "/date/2017/04/01" package chi import "net/http" diff --git a/vendor/github.com/go-chi/chi/context.go b/vendor/github.com/go-chi/chi/context.go index 8c97f214a9..bd30d52b4f 100644 --- a/vendor/github.com/go-chi/chi/context.go +++ b/vendor/github.com/go-chi/chi/context.go @@ -112,13 +112,13 @@ func (x *Context) URLParam(key string) string { // // For example, // -// func Instrument(next http.Handler) http.Handler { -// return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { -// next.ServeHTTP(w, r) -// routePattern := chi.RouteContext(r.Context()).RoutePattern() -// measure(w, r, routePattern) -// }) -// } +// func Instrument(next http.Handler) http.Handler { +// return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +// next.ServeHTTP(w, r) +// routePattern := chi.RouteContext(r.Context()).RoutePattern() +// measure(w, r, routePattern) +// }) +// } func (x *Context) RoutePattern() string { routePattern := strings.Join(x.RoutePatterns, "") return replaceWildcards(routePattern) diff --git a/vendor/github.com/go-chi/cors/cors.go b/vendor/github.com/go-chi/cors/cors.go index 8df81636e3..75196ab91c 100644 --- a/vendor/github.com/go-chi/cors/cors.go +++ b/vendor/github.com/go-chi/cors/cors.go @@ -3,15 +3,15 @@ // // You can configure it by passing an option struct to cors.New: // -// c := cors.New(cors.Options{ -// AllowedOrigins: []string{"foo.com"}, -// AllowedMethods: []string{"GET", "POST", "DELETE"}, -// AllowCredentials: true, -// }) +// c := cors.New(cors.Options{ +// AllowedOrigins: []string{"foo.com"}, +// AllowedMethods: []string{"GET", "POST", "DELETE"}, +// AllowCredentials: true, +// }) // // Then insert the handler in the chain: // -// handler = c.Handler(handler) +// handler = c.Handler(handler) // // See Options documentation for more options. // diff --git a/vendor/github.com/google/uuid/dce.go b/vendor/github.com/google/uuid/dce.go index fa820b9d30..9302a1c1bb 100644 --- a/vendor/github.com/google/uuid/dce.go +++ b/vendor/github.com/google/uuid/dce.go @@ -42,7 +42,7 @@ func NewDCESecurity(domain Domain, id uint32) (UUID, error) { // NewDCEPerson returns a DCE Security (Version 2) UUID in the person // domain with the id returned by os.Getuid. // -// NewDCESecurity(Person, uint32(os.Getuid())) +// NewDCESecurity(Person, uint32(os.Getuid())) func NewDCEPerson() (UUID, error) { return NewDCESecurity(Person, uint32(os.Getuid())) } @@ -50,7 +50,7 @@ func NewDCEPerson() (UUID, error) { // NewDCEGroup returns a DCE Security (Version 2) UUID in the group // domain with the id returned by os.Getgid. // -// NewDCESecurity(Group, uint32(os.Getgid())) +// NewDCESecurity(Group, uint32(os.Getgid())) func NewDCEGroup() (UUID, error) { return NewDCESecurity(Group, uint32(os.Getgid())) } diff --git a/vendor/github.com/google/uuid/hash.go b/vendor/github.com/google/uuid/hash.go index b404f4bec2..24ccde6464 100644 --- a/vendor/github.com/google/uuid/hash.go +++ b/vendor/github.com/google/uuid/hash.go @@ -39,7 +39,7 @@ func NewHash(h hash.Hash, space UUID, data []byte, version int) UUID { // NewMD5 returns a new MD5 (Version 3) UUID based on the // supplied name space and data. It is the same as calling: // -// NewHash(md5.New(), space, data, 3) +// NewHash(md5.New(), space, data, 3) func NewMD5(space UUID, data []byte) UUID { return NewHash(md5.New(), space, data, 3) } @@ -47,7 +47,7 @@ func NewMD5(space UUID, data []byte) UUID { // NewSHA1 returns a new SHA1 (Version 5) UUID based on the // supplied name space and data. It is the same as calling: // -// NewHash(sha1.New(), space, data, 5) +// NewHash(sha1.New(), space, data, 5) func NewSHA1(space UUID, data []byte) UUID { return NewHash(sha1.New(), space, data, 5) } diff --git a/vendor/github.com/google/uuid/node_js.go b/vendor/github.com/google/uuid/node_js.go index 24b78edc90..96090351a9 100644 --- a/vendor/github.com/google/uuid/node_js.go +++ b/vendor/github.com/google/uuid/node_js.go @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. +//go:build js // +build js package uuid diff --git a/vendor/github.com/google/uuid/node_net.go b/vendor/github.com/google/uuid/node_net.go index 0cbbcddbd6..e91358f7d9 100644 --- a/vendor/github.com/google/uuid/node_net.go +++ b/vendor/github.com/google/uuid/node_net.go @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. +//go:build !js // +build !js package uuid diff --git a/vendor/github.com/google/uuid/null.go b/vendor/github.com/google/uuid/null.go index d7fcbf2865..06ecf9de2a 100644 --- a/vendor/github.com/google/uuid/null.go +++ b/vendor/github.com/google/uuid/null.go @@ -17,15 +17,14 @@ var jsonNull = []byte("null") // NullUUID implements the SQL driver.Scanner interface so // it can be used as a scan destination: // -// var u uuid.NullUUID -// err := db.QueryRow("SELECT name FROM foo WHERE id=?", id).Scan(&u) -// ... -// if u.Valid { -// // use u.UUID -// } else { -// // NULL value -// } -// +// var u uuid.NullUUID +// err := db.QueryRow("SELECT name FROM foo WHERE id=?", id).Scan(&u) +// ... +// if u.Valid { +// // use u.UUID +// } else { +// // NULL value +// } type NullUUID struct { UUID UUID Valid bool // Valid is true if UUID is not NULL diff --git a/vendor/github.com/google/uuid/version4.go b/vendor/github.com/google/uuid/version4.go index 7697802e4d..62ac273815 100644 --- a/vendor/github.com/google/uuid/version4.go +++ b/vendor/github.com/google/uuid/version4.go @@ -9,7 +9,7 @@ import "io" // New creates a new random UUID or panics. New is equivalent to // the expression // -// uuid.Must(uuid.NewRandom()) +// uuid.Must(uuid.NewRandom()) func New() UUID { return Must(NewRandom()) } @@ -17,7 +17,7 @@ func New() UUID { // NewString creates a new random UUID and returns it as a string or panics. // NewString is equivalent to the expression // -// uuid.New().String() +// uuid.New().String() func NewString() string { return Must(NewRandom()).String() } @@ -31,11 +31,11 @@ func NewString() string { // // A note about uniqueness derived from the UUID Wikipedia entry: // -// Randomly generated UUIDs have 122 random bits. One's annual risk of being -// hit by a meteorite is estimated to be one chance in 17 billion, that -// means the probability is about 0.00000000006 (6 × 10−11), -// equivalent to the odds of creating a few tens of trillions of UUIDs in a -// year and having one duplicate. +// Randomly generated UUIDs have 122 random bits. One's annual risk of being +// hit by a meteorite is estimated to be one chance in 17 billion, that +// means the probability is about 0.00000000006 (6 × 10−11), +// equivalent to the odds of creating a few tens of trillions of UUIDs in a +// year and having one duplicate. func NewRandom() (UUID, error) { if !poolEnabled { return NewRandomFromReader(rander) diff --git a/vendor/nhooyr.io/websocket/accept.go b/vendor/nhooyr.io/websocket/accept.go index 18536bdb2c..b54471312e 100644 --- a/vendor/nhooyr.io/websocket/accept.go +++ b/vendor/nhooyr.io/websocket/accept.go @@ -1,3 +1,4 @@ +//go:build !js // +build !js package websocket diff --git a/vendor/nhooyr.io/websocket/close_notjs.go b/vendor/nhooyr.io/websocket/close_notjs.go index 4251311d2e..8cafaa9dcf 100644 --- a/vendor/nhooyr.io/websocket/close_notjs.go +++ b/vendor/nhooyr.io/websocket/close_notjs.go @@ -1,3 +1,4 @@ +//go:build !js // +build !js package websocket diff --git a/vendor/nhooyr.io/websocket/compress_notjs.go b/vendor/nhooyr.io/websocket/compress_notjs.go index 809a272c3d..e094513b35 100644 --- a/vendor/nhooyr.io/websocket/compress_notjs.go +++ b/vendor/nhooyr.io/websocket/compress_notjs.go @@ -1,3 +1,4 @@ +//go:build !js // +build !js package websocket diff --git a/vendor/nhooyr.io/websocket/conn_notjs.go b/vendor/nhooyr.io/websocket/conn_notjs.go index 0c85ab7711..3c1361c5b2 100644 --- a/vendor/nhooyr.io/websocket/conn_notjs.go +++ b/vendor/nhooyr.io/websocket/conn_notjs.go @@ -1,3 +1,4 @@ +//go:build !js // +build !js package websocket diff --git a/vendor/nhooyr.io/websocket/dial.go b/vendor/nhooyr.io/websocket/dial.go index 7a7787ff71..7b91713e9c 100644 --- a/vendor/nhooyr.io/websocket/dial.go +++ b/vendor/nhooyr.io/websocket/dial.go @@ -1,3 +1,4 @@ +//go:build !js // +build !js package websocket diff --git a/vendor/nhooyr.io/websocket/doc.go b/vendor/nhooyr.io/websocket/doc.go index efa920e3b6..a2b873c722 100644 --- a/vendor/nhooyr.io/websocket/doc.go +++ b/vendor/nhooyr.io/websocket/doc.go @@ -1,3 +1,4 @@ +//go:build !js // +build !js // Package websocket implements the RFC 6455 WebSocket protocol. @@ -16,7 +17,7 @@ // // More documentation at https://nhooyr.io/websocket. // -// Wasm +// # Wasm // // The client side supports compiling to Wasm. // It wraps the WebSocket browser API. @@ -25,8 +26,8 @@ // // Some important caveats to be aware of: // -// - Accept always errors out -// - Conn.Ping is no-op -// - HTTPClient, HTTPHeader and CompressionMode in DialOptions are no-op -// - *http.Response from Dial is &http.Response{} with a 101 status code on success +// - Accept always errors out +// - Conn.Ping is no-op +// - HTTPClient, HTTPHeader and CompressionMode in DialOptions are no-op +// - *http.Response from Dial is &http.Response{} with a 101 status code on success package websocket // import "nhooyr.io/websocket" diff --git a/vendor/nhooyr.io/websocket/internal/wsjs/wsjs_js.go b/vendor/nhooyr.io/websocket/internal/wsjs/wsjs_js.go index 26ffb45625..88e8f43f69 100644 --- a/vendor/nhooyr.io/websocket/internal/wsjs/wsjs_js.go +++ b/vendor/nhooyr.io/websocket/internal/wsjs/wsjs_js.go @@ -1,3 +1,4 @@ +//go:build js // +build js // Package wsjs implements typed access to the browser javascript WebSocket API. diff --git a/vendor/nhooyr.io/websocket/read.go b/vendor/nhooyr.io/websocket/read.go index ae05cf93ed..0f10afef62 100644 --- a/vendor/nhooyr.io/websocket/read.go +++ b/vendor/nhooyr.io/websocket/read.go @@ -1,3 +1,4 @@ +//go:build !js // +build !js package websocket diff --git a/vendor/nhooyr.io/websocket/write.go b/vendor/nhooyr.io/websocket/write.go index 2210cf817a..9528e5acd7 100644 --- a/vendor/nhooyr.io/websocket/write.go +++ b/vendor/nhooyr.io/websocket/write.go @@ -1,3 +1,4 @@ +//go:build !js // +build !js package websocket