diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000000..5bf5d4639d --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,47 @@ +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@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: "1.26.0" + + - name: Install goose + run: go install github.com/pressly/goose/v3/cmd/goose@latest + + - name: Build production binary + run: ./scripts/buildprod.sh + + - name: Authenticate to Google Cloud + uses: google-github-actions/auth@v2 + with: + credentials_json: ${{ secrets.GCP_CREDENTIALS }} + + - name: Set up Cloud SDK + uses: google-github-actions/setup-gcloud@v2 + with: + project_id: notely-501812 + + - name: Build and push Docker image + run: gcloud builds submit --tag us-central1-docker.pkg.dev/notely-501812/notely-ar-repo/notely:latest . + + - name: Run database migrations + run: ./scripts/migrateup.sh + + - name: Deploy to Cloud Run + run: gcloud run deploy notely --image us-central1-docker.pkg.dev/notely-501812/notely-ar-repo/notely:latest --region us-central1 --allow-unauthenticated --project notely-501812 --max-instances=4 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000000..6ca6f50a36 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,50 @@ +name: ci + +on: + pull_request: + branches: [main] + +jobs: + tests: + name: Tests + runs-on: ubuntu-latest + + steps: + - name: Check out code + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: "1.26.0" + + - name: Run tests + run: go test ./... -cover + + - name: Install gosec + run: go install github.com/securego/gosec/v2/cmd/gosec@latest + + - name: Run gosec + run: gosec ./... + + style: + name: Style + runs-on: ubuntu-latest + + steps: + - name: Check out code + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: "1.25.1" + + - name: Check formatting + run: test -z $(go fmt ./...) + + - name: Install staticcheck + run: go install honnef.co/go/tools/cmd/staticcheck@latest + + - name: Run staticcheck + run: staticcheck ./... diff --git a/.gitignore b/.gitignore index 2092f54e78..e2c10e488c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ out .env learn-cicd-starter notely +CLOUD_BUILD_SETUP.md diff --git a/LEARNINGS.md b/LEARNINGS.md new file mode 100644 index 0000000000..706e397969 --- /dev/null +++ b/LEARNINGS.md @@ -0,0 +1,56 @@ +# Learnings — Learn CI/CD (Notely) + +Notes from Boot.dev’s Learn CI/CD course, applied to this Notely Go app. + +## Continuous Integration (CI) + +- **Trigger on PRs, not only on merge.** The `ci` workflow runs on `pull_request` to `main`, so broken tests or style issues are caught before merge. +- **Separate concerns into parallel jobs.** `tests` and `style` run as independent jobs on `ubuntu-latest`. Failures are clearer, and the pipeline finishes faster when they run in parallel. +- **Pin the toolchain.** `actions/setup-go` with an explicit `go-version` keeps local and CI builds aligned. +- **Tests belong in CI.** `go test ./... -cover` runs on every PR. A failing test (intentionally or not) blocks the pipeline — that feedback loop is the point. +- **Style is automated, not optional.** + - `go fmt`: `test -z $(go fmt ./...)` fails if any file would be reformatted. + - `staticcheck`: catches bugs and smell that the compiler misses. +- **Security scanning in CI.** `gosec ./...` looks for common Go security issues. Treat findings as build failures, then fix them (don’t just silence the tool). +- **Status badges.** A badge in the README (e.g. for `ci.yml`) makes pipeline health visible without opening the Actions tab. + +## Continuous Deployment (CD) + +- **Deploy on push to `main`.** The `cd` workflow assumes `main` is the release branch: merge → build → ship. +- **Build for the target platform.** `scripts/buildprod.sh` cross-compiles with `CGO_ENABLED=0 GOOS=linux GOARCH=amd64` so the binary runs in a Linux container even if you develop on macOS. +- **Keep the image thin.** The Dockerfile is a slim Debian image that only adds the prebuilt `notely` binary and CA certs — no Go toolchain in the runtime image. +- **Migrations before (or with) deploy.** Run Goose (`./scripts/migrateup.sh`) in CD so the schema is ready before the new Cloud Run revision serves traffic. +- **Cap scale early.** `--max-instances=4` on Cloud Run limits surprise cost while learning. + +## Secrets & config + +- **Never commit secrets.** `.env` is gitignored. CI/CD reads `DATABASE_URL` and `GCP_CREDENTIALS` from GitHub Actions secrets. +- **Same app, different envs.** Locally, missing `DATABASE_URL` means “no DB mode.” In CD, the secret must be set or migrations and CRUD fail. +- **Service account JSON in Actions.** `google-github-actions/auth` with `credentials_json` from a secret authenticates `gcloud` without interactive login. + +## Google Cloud pieces + +- **Artifact Registry** stores the Docker image (`…/notely-ar-repo/notely:latest`). +- **Cloud Build** builds and pushes from the repo (`gcloud builds submit --tag …`). +- **Cloud Run** runs the container (`gcloud run deploy … --allow-unauthenticated` for a public demo). +- **IAM matters.** The default Compute Engine service account needs roles like Cloud Build builder and Storage object viewer on the Cloud Build bucket — otherwise you get errors such as `Permission 'storage.objects.get' denied`. Fixing IAM is part of making CD work, not optional ops trivia. See `CLOUD_BUILD_SETUP.md` for the exact bindings used here. + +## Workflow habits that stuck + +1. Open a PR → watch CI (tests, fmt, staticcheck, gosec). +2. Merge to `main` → CD builds Linux binary → Cloud Build image → migrate DB → deploy Cloud Run. +3. Prefer small, reversible steps: add a CI step, confirm it fails correctly, then make it pass. +4. Document one-off GCP/IAM fixes so the next deploy doesn’t start from “why is permission denied?” + +## Stack at a glance + +| Layer | Choice | +| --- | --- | +| App | Go + Chi, Turso/libSQL, Goose migrations | +| CI | GitHub Actions (`ci.yml`) | +| CD | GitHub Actions (`cd.yml`) → GCP | +| Runtime | Cloud Run + Artifact Registry | + +## Takeaway + +CI is the automated gate on every change; CD is the automated path from a green `main` to a running service. Most of the friction was not YAML syntax — it was making tests honest, keeping secrets out of git, and giving GCP service accounts the right permissions end to end. diff --git a/README.md b/README.md index c2bec0368b..b54724f401 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +![CI status](https://github.com/gcoria/learn-cicd-starter/actions/workflows/ci.yml/badge.svg) + # learn-cicd-starter (Notely) This repo contains the starter code for the "Notely" application for the "Learn CICD" course on [Boot.dev](https://boot.dev). @@ -21,3 +23,5 @@ 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! + +Gasta's version of Boot.dev's Notely app \ No newline at end of file diff --git a/internal/auth/auth.go b/internal/auth/auth.go index f969aacf63..51c6208114 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -8,7 +8,8 @@ import ( var ErrNoAuthHeaderIncluded = errors.New("no authorization header included") -// GetAPIKey - +// GetAPIKey + func GetAPIKey(headers http.Header) (string, error) { authHeader := headers.Get("Authorization") if authHeader == "" { diff --git a/internal/auth/get_api_key_test.go b/internal/auth/get_api_key_test.go new file mode 100644 index 0000000000..0cb05da217 --- /dev/null +++ b/internal/auth/get_api_key_test.go @@ -0,0 +1,20 @@ +package auth + +import ( + "net/http" + "testing" +) + +func TestGetAPIKey(t *testing.T) { + header := http.Header{} + header.Set("Authorization", "ApiKey test") + + apiKey, err := GetAPIKey(header) + if err != nil { + t.Fatalf("Failed to get API key: %v", err) + } + if apiKey != "test" { + t.Fatalf("Expected API key to be 'test', got '%s'", apiKey) + } + +} diff --git a/json.go b/json.go index 1e6e7985e1..5bcb7998ba 100644 --- a/json.go +++ b/json.go @@ -30,5 +30,8 @@ func respondWithJSON(w http.ResponseWriter, code int, payload interface{}) { return } w.WriteHeader(code) - w.Write(dat) + _, err = w.Write(dat) + if err != nil { + log.Printf("Error writing JSON: %s", err) + } } diff --git a/main.go b/main.go index 19d7366c5f..cfe060fbb0 100644 --- a/main.go +++ b/main.go @@ -3,10 +3,13 @@ package main import ( "database/sql" "embed" + "errors" "io" "log" "net/http" "os" + "strconv" + "time" "github.com/go-chi/chi" "github.com/go-chi/cors" @@ -27,12 +30,23 @@ var staticFiles embed.FS func main() { err := godotenv.Load(".env") if err != nil { - log.Printf("warning: assuming default configuration. .env unreadable: %v", err) + if errors.Is(err, os.ErrNotExist) { + log.Println("warning: .env file not found, using environment defaults") + } else { + log.Printf("warning: .env unreadable, using environment defaults: %v", err) + } } port := os.Getenv("PORT") if port == "" { - log.Fatal("PORT environment variable is not set") + port = "8080" + } + portNum, err := strconv.Atoi(port) + if err != nil { + log.Fatalf("invalid PORT: %v", err) + } + if portNum < 1 || portNum > 65535 { + log.Fatalf("invalid PORT: port must be between 1 and 65535, got %d", portNum) } apiCfg := apiConfig{} @@ -89,10 +103,14 @@ func main() { router.Mount("/v1", v1Router) srv := &http.Server{ - Addr: ":" + port, - Handler: router, + Addr: ":" + strconv.Itoa(portNum), + Handler: router, + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, } - log.Printf("Serving on port: %s\n", port) + log.Printf("Serving on port: %d", portNum) log.Fatal(srv.ListenAndServe()) } 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