diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a16ddc3..7875507 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -10,3 +10,23 @@ updates: github-actions: patterns: - "*" + + # Go module dependencies. + - package-ecosystem: gomod + directory: / + schedule: + interval: weekly + groups: + gomod: + patterns: + - "*" + + # Frontend (npm) dependencies. + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + groups: + npm: + patterns: + - "*" diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5989832..fdfb661 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -3,11 +3,12 @@ on: push: branches: - main - # Release flavor is derived from the tag suffix in the deploy job: + # Release flavor is derived from the tag suffix in the deploy job. Convention: prefix tags + # with "v" (bare X.Y.Z is still accepted for backward compatibility). tags: - - '*.*.*' # stable semver (e.g. 1.2.3): published and marked "Latest" - - '*-preview' # pre-release (e.g. 1.2.3-preview): published, never "Latest" - - '*-draft' # draft (e.g. 1.2.3-draft): unpublished for review, never "Latest" + - '*.*.*' # stable semver (e.g. v1.2.3): published and marked "Latest" + - '*-preview' # pre-release (e.g. v1.2.3-preview): published, never "Latest" + - '*-draft' # draft (e.g. v1.2.3-draft): unpublished for review, never "Latest" pull_request: branches: - main @@ -108,6 +109,9 @@ jobs: make VERSION=${{ steps.extract_ref.outputs.GIT_REF }} build-docker make VERSION=${{ steps.extract_ref.outputs.GIT_REF }} start-docker + - name: Smoke-test the docker image + run: make VERSION=${{ steps.extract_ref.outputs.GIT_REF }} smoke-docker + - if: startsWith(github.ref, 'refs/tags/') uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: diff --git a/.gitignore b/.gitignore index 893be12..219d6f9 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ node_modules/ # All build, test and runtime output goes here (see Makefile / vite.config.ts / playwright.config.ts). build/ +# The embedded client is copied here by the release build; keep only the placeholder in git. +server/frontend/dist/* +!server/frontend/dist/.gitkeep diff --git a/Dockerfile b/Dockerfile index 5084dd0..e65b20b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ ARG GO_VERSION=1.26 FROM golang:${GO_VERSION}-alpine AS build-backend -RUN apk add --no-cache make +RUN apk add --no-cache make ca-certificates ARG VERSION=snapshot ARG COMMIT WORKDIR /go/src/smocker @@ -8,12 +8,19 @@ COPY go.mod go.sum ./ RUN go mod download COPY Makefile main.go ./ COPY server/ ./server/ -RUN make VERSION=$VERSION COMMIT=$COMMIT RELEASE=1 build +# The client is built on the host (make release); bake it into the binary via go:embed. +COPY build/client ./server/frontend/dist/ +# CGO_ENABLED=0 produces a fully static binary (pure-Go net/tls), required for a scratch image. +RUN CGO_ENABLED=0 make VERSION=$VERSION COMMIT=$COMMIT RELEASE=1 build -FROM alpine +FROM scratch LABEL org.opencontainers.image.source="https://github.com/smocker-dev/smocker" -WORKDIR /opt EXPOSE 8080 8081 -COPY build/client client/ -COPY --from=build-backend /go/src/smocker/build/* /opt/ -CMD ["/opt/smocker"] +# CA roots so the proxy mock type can reach HTTPS backends (scratch ships none). +COPY --from=build-backend /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt +# The UI is embedded in the binary, so nothing else is needed — just the static binary. +COPY --from=build-backend /go/src/smocker/build/smocker /smocker +# Run unprivileged (nobody). The listen ports are >1024 so no privilege is needed; a mounted +# persistence directory must be writable by this uid. +USER 65534:65534 +ENTRYPOINT ["/smocker"] diff --git a/Makefile b/Makefile index 8bf7dd0..5670b2f 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,7 @@ DOCKER_IMAGE=ghcr.io/smocker-dev/smocker # A tag name must be valid ASCII and may contain lowercase and uppercase letters, digits, underscores, periods and dashes. # A tag name may not start with a period or a dash and may contain a maximum of 128 characters. DOCKER_TAG:=$(shell echo $(VERSION) | tr -cd '[:alnum:]_.-') -IS_SEMVER:=$(shell echo $(DOCKER_TAG) | grep -E "^[[:digit:]]+\.[[:digit:]]+\.[[:digit:]]+$$") +IS_SEMVER:=$(shell echo $(DOCKER_TAG) | grep -E "^v?[[:digit:]]+\.[[:digit:]]+\.[[:digit:]]+$$") LEVEL=debug @@ -155,10 +155,26 @@ build-docker: start-docker: check-default-ports docker run -d -p 8080:8080 -p 8081:8081 --name $(APPNAME) $(DOCKER_IMAGE):$(DOCKER_TAG) +# Smoke-test the running container (started by start-docker): the config API answers, the UI is +# served (proves the embedded client works — the image ships no client directory), and a mock +# registered on the config port is served on the mock port. +.PHONY: smoke-docker +smoke-docker: + @for i in $$(seq 1 30); do curl -sf http://localhost:8081/version >/dev/null 2>&1 && break; sleep 1; done + @curl -sf http://localhost:8081/version >/dev/null || { echo "FAIL: /version"; exit 1; } + @curl -sf http://localhost:8081/ | grep -q Smocker || { echo "FAIL: embedded UI not served"; exit 1; } + @curl -sf -XPOST http://localhost:8081/mocks -H "Content-Type: application/json" \ + --data '[{"request":{"method":"GET","path":"/smoke"},"response":{"status":200,"body":"ok"}}]' >/dev/null \ + || { echo "FAIL: register mock"; exit 1; } + @test "$$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/smoke)" = "200" \ + || { echo "FAIL: mock round-trip"; exit 1; } + @echo "docker smoke tests passed" + .PHONY: check-default-ports check-default-ports: @lsof -i:8080 > /dev/null && (echo "Port 8080 already in use"; exit 1) || true @lsof -i:8081 > /dev/null && (echo "Port 8081 already in use"; exit 1) || true + @lsof -i:$(PROXY_TARGET_PORT) > /dev/null && (echo "Port $(PROXY_TARGET_PORT) (go-httpbin) already in use"; exit 1) || true .PHONY: optimize optimize: @@ -168,11 +184,15 @@ optimize: # The following targets are only available for CI usage build/smocker.tar.gz: - $(MAKE) build + # Build the client first, copy it into the embed source, then build the Go binary so the UI + # is baked in (go:embed). The result is a self-contained binary — no client dir to ship. npm ci --ignore-scripts npm run build - # Package only the release artifacts, not test/runtime output that may also live in build/. - cd build/; tar -czvf smocker.tar.gz smocker client + rm -rf server/frontend/dist && mkdir -p server/frontend/dist + cp -r build/client/. server/frontend/dist/ + touch server/frontend/dist/.gitkeep + $(MAKE) build + cd build/; tar -czvf smocker.tar.gz smocker .PHONY: release release: build/smocker.tar.gz diff --git a/README.md b/README.md index 861353b..0beee1d 100644 --- a/README.md +++ b/README.md @@ -167,15 +167,15 @@ The backend is written in Go. You can use the following commands to manage the d ### Frontend -The frontend is written with TypeScript and React. You can use the following commands to manage the development lifecycle: - -- `yarn install`: install the dependencies -- `yarn start`: start the frontend in development mode, with live reload -- `yarn build`: generate the transpiled and minified files and assets -- `yarn lint`: run static analysis on the code -- `yarn format`: automatically format the frontend code -- `yarn test`: execute unit tests -- `yarn test:watch`: execute unit tests, with live reload +The frontend is written with TypeScript and React, bundled with Vite. You can use the following commands to manage the development lifecycle: + +- `npm install`: install the dependencies +- `npm run dev`: start the Vite dev server with hot reload. It proxies the admin API to the backend, so run `make start` alongside it (override the target with `SMOCKER_DEV_PROXY` if the backend is elsewhere) +- `npm run build`: generate the transpiled and minified files and assets +- `npm run lint`: run static analysis on the code +- `npm run format`: automatically format the frontend code +- `npm test`: execute unit tests +- `npm run test:watch`: execute unit tests, with live reload ### Docker diff --git a/client/modules/utils.test.ts b/client/modules/utils.test.ts index f66f5f7..93d2a36 100644 --- a/client/modules/utils.test.ts +++ b/client/modules/utils.test.ts @@ -115,8 +115,7 @@ describe("Generate curl command from:", () => { }, }; expect(entryToCurl(entry)).toBe( - // FIXME: we shouldn't have the " if the client sent raw text - `curl -XPOST '/test' --data '"value containing \\'single quotes\\'"'`, + `curl -XPOST '/test' --data 'value containing \\'single quotes\\''`, ); }); }); diff --git a/client/modules/utils.ts b/client/modules/utils.ts index 739f6ed..c7d8c87 100644 --- a/client/modules/utils.ts +++ b/client/modules/utils.ts @@ -60,7 +60,13 @@ export const entryToCurl = (historyEntry: Entry): string => { ); if (request.body) { - command.push(`--data '${escapeQuote(JSON.stringify(request.body))}'`); + // A raw/urlencoded body is already a string; only JSON bodies need serializing. Stringifying + // a string would wrap it in extra quotes (--data '"a=b"'). + const data = + typeof request.body === "string" + ? request.body + : JSON.stringify(request.body); + command.push(`--data '${escapeQuote(data)}'`); } return command.join(" "); diff --git a/server/admin_server.go b/server/admin_server.go index 8417516..1c3851c 100644 --- a/server/admin_server.go +++ b/server/admin_server.go @@ -18,6 +18,7 @@ import ( "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" "github.com/smocker-dev/smocker/server/config" + "github.com/smocker-dev/smocker/server/frontend" "github.com/smocker-dev/smocker/server/handlers" "github.com/smocker-dev/smocker/server/services" "golang.org/x/sync/errgroup" @@ -79,8 +80,14 @@ func Serve(config config.Config) { return c.JSON(http.StatusOK, config.Build) }) - // UI Routes - adminServerEngine.Static("/assets", config.StaticFiles) + // UI Routes: serve from --static-files on disk when it holds a built index.html (development + // or an explicit override); otherwise fall back to the client embedded in the binary, so a + // released binary is self-contained. + if fileExists(config.StaticFiles + "/index.html") { + adminServerEngine.Static("/assets", config.StaticFiles) + } else if embedded, ok := frontend.FS(); ok { + adminServerEngine.StaticFS("/assets", embedded) + } adminServerEngine.GET("/*", renderIndex(adminServerEngine, config)) slog.Info("Starting admin server", "port", config.ConfigListenPort) @@ -153,15 +160,32 @@ func serve(tlsEnable bool, servers ...*http.Server) error { return g.Wait() } +// fileExists reports whether path exists (used to prefer an on-disk UI over the embedded one). +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + func renderIndex(e *echo.Echo, cfg config.Config) echo.HandlerFunc { return func(c echo.Context) error { - // In development mode, index.html might not be available yet + // Parse index.html once: from --static-files on disk if present (dev/override), otherwise + // from the client embedded in the binary. In development the client may not be built yet. if templateRenderer == nil { - template, err := template.ParseFiles(cfg.StaticFiles + "/index.html") + var ( + tmpl *template.Template + err error + ) + if diskIndex := cfg.StaticFiles + "/index.html"; fileExists(diskIndex) { + tmpl, err = template.ParseFiles(diskIndex) + } else if embedded, ok := frontend.FS(); ok { + tmpl, err = template.ParseFS(embedded, "index.html") + } else { + return c.String(http.StatusNotFound, "index is building...") + } if err != nil { return c.String(http.StatusNotFound, "index is building...") } - templateRenderer := &TemplateRenderer{template} + templateRenderer = &TemplateRenderer{tmpl} e.Renderer = templateRenderer } diff --git a/server/frontend/dist/.gitkeep b/server/frontend/dist/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/server/frontend/frontend.go b/server/frontend/frontend.go new file mode 100644 index 0000000..5e56c5c --- /dev/null +++ b/server/frontend/frontend.go @@ -0,0 +1,27 @@ +// Package frontend embeds the built web UI so a released binary is self-contained (no need to +// ship the client directory alongside it). The dist directory is populated by the client build +// (see the Makefile release target); a committed placeholder keeps `go build` working before the +// client has ever been built. +package frontend + +import ( + "embed" + "io/fs" +) + +//go:embed all:dist +var dist embed.FS + +// FS returns the embedded client filesystem (rooted at dist) together with whether it actually +// holds a built UI (an index.html). When only the placeholder is present it returns ok=false, so +// callers fall back to serving from the --static-files directory on disk. +func FS() (fs.FS, bool) { + sub, err := fs.Sub(dist, "dist") + if err != nil { + return nil, false + } + if _, err := fs.Stat(sub, "index.html"); err != nil { + return nil, false + } + return sub, true +} diff --git a/server/handlers/mocks.go b/server/handlers/mocks.go index e7a4c55..63fa48c 100644 --- a/server/handlers/mocks.go +++ b/server/handlers/mocks.go @@ -121,11 +121,12 @@ func (m *Mocks) GenericHandler(c echo.Context) error { /* Response writing */ - // Headers + // Headers: assign to the header map directly instead of Add()/Set(), which canonicalize the + // key (e.g. "BrokerProperties" -> "Brokerproperties"). Smocker preserves the exact casing the + // mock declares, matching servers that treat header names case-sensitively. + header := c.Response().Header() for key, values := range response.Headers { - for _, value := range values { - c.Response().Header().Add(key, value) - } + header[key] = []string(values) } // Delay diff --git a/vite.config.ts b/vite.config.ts index 7d0f042..008be28 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -17,6 +17,7 @@ function assetsPrefix(): Plugin { let indexPath = ""; return { name: "smocker-assets-prefix", + apply: "build", configResolved(cfg) { indexPath = resolve(cfg.root, cfg.build.outDir, "index.html"); }, @@ -30,13 +31,43 @@ function assetsPrefix(): Plugin { }; } -export default defineConfig({ +// In the dev server, Vite serves index.html without the Go template step, so the runtime globals +// stay as literal "{{.basePath}}" / "{{.version}}" and break the API base + . Substitute +// them for dev only; the production build keeps the placeholders for the Go server to render. +function devIndexHtml(): Plugin { + return { + name: "smocker-dev-index-html", + apply: "serve", + transformIndexHtml(html) { + return html + .replace(/\{\{\.basePath\}\}/g, "/") + .replace(/\{\{\.version\}\}/g, "dev"); + }, + }; +} + +// Where `npm run dev` proxies the API to (the running `make start` admin server). +const devProxyTarget = process.env.SMOCKER_DEV_PROXY ?? "http://localhost:8081"; +const apiProxy = Object.fromEntries( + ["/mocks", "/sessions", "/history", "/version", "/reset"].map((path) => [ + path, + devProxyTarget, + ]), +); + +export default defineConfig(({ command }) => ({ root: "client", - base: "./", - plugins: [react(), assetsPrefix()], - // Allow importing the canonical mock schema (docs/mock.schema.json) from client code, which - // lives above the Vite root. - server: { fs: { allow: [import.meta.dirname] } }, + // Dev serves from "/" (clean absolute URLs against the dev server); the build keeps "./" so the + // Go server's can relocate assets under any deployment base path. + base: command === "build" ? "./" : "/", + plugins: [react(), assetsPrefix(), devIndexHtml()], + server: { + // Allow importing the canonical mock schema (docs/mock.schema.json) from client code, which + // lives above the Vite root. + fs: { allow: [import.meta.dirname] }, + // Proxy the admin API to the backend so `npm run dev` works against `make start`. + proxy: apiProxy, + }, build: { outDir: "../build/client", emptyOutDir: true, @@ -48,4 +79,4 @@ export default defineConfig({ // Relative to the Vite root ("client"), so this matches client/**/*.test.{ts,tsx}. include: ["**/*.test.{ts,tsx}"], }, -}); +}));