Skip to content

Commit 2a8bb19

Browse files
authored
Merge pull request #325 from smocker-dev/chore/improvements
chore: fixes, self-contained scratch image, and dev-flow improvements
2 parents 2801d06 + d41dc2a commit 2a8bb19

13 files changed

Lines changed: 185 additions & 43 deletions

File tree

.github/dependabot.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,23 @@ updates:
1010
github-actions:
1111
patterns:
1212
- "*"
13+
14+
# Go module dependencies.
15+
- package-ecosystem: gomod
16+
directory: /
17+
schedule:
18+
interval: weekly
19+
groups:
20+
gomod:
21+
patterns:
22+
- "*"
23+
24+
# Frontend (npm) dependencies.
25+
- package-ecosystem: npm
26+
directory: /
27+
schedule:
28+
interval: weekly
29+
groups:
30+
npm:
31+
patterns:
32+
- "*"

.github/workflows/main.yml

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@ on:
33
push:
44
branches:
55
- main
6-
# Release flavor is derived from the tag suffix in the deploy job:
6+
# Release flavor is derived from the tag suffix in the deploy job. Convention: prefix tags
7+
# with "v" (bare X.Y.Z is still accepted for backward compatibility).
78
tags:
8-
- '*.*.*' # stable semver (e.g. 1.2.3): published and marked "Latest"
9-
- '*-preview' # pre-release (e.g. 1.2.3-preview): published, never "Latest"
10-
- '*-draft' # draft (e.g. 1.2.3-draft): unpublished for review, never "Latest"
9+
- '*.*.*' # stable semver (e.g. v1.2.3): published and marked "Latest"
10+
- '*-preview' # pre-release (e.g. v1.2.3-preview): published, never "Latest"
11+
- '*-draft' # draft (e.g. v1.2.3-draft): unpublished for review, never "Latest"
1112
pull_request:
1213
branches:
1314
- main
@@ -108,6 +109,9 @@ jobs:
108109
make VERSION=${{ steps.extract_ref.outputs.GIT_REF }} build-docker
109110
make VERSION=${{ steps.extract_ref.outputs.GIT_REF }} start-docker
110111
112+
- name: Smoke-test the docker image
113+
run: make VERSION=${{ steps.extract_ref.outputs.GIT_REF }} smoke-docker
114+
111115
- if: startsWith(github.ref, 'refs/tags/')
112116
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
113117
with:

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,6 @@
33
node_modules/
44
# All build, test and runtime output goes here (see Makefile / vite.config.ts / playwright.config.ts).
55
build/
6+
# The embedded client is copied here by the release build; keep only the placeholder in git.
7+
server/frontend/dist/*
8+
!server/frontend/dist/.gitkeep

Dockerfile

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,26 @@
11
ARG GO_VERSION=1.26
22
FROM golang:${GO_VERSION}-alpine AS build-backend
3-
RUN apk add --no-cache make
3+
RUN apk add --no-cache make ca-certificates
44
ARG VERSION=snapshot
55
ARG COMMIT
66
WORKDIR /go/src/smocker
77
COPY go.mod go.sum ./
88
RUN go mod download
99
COPY Makefile main.go ./
1010
COPY server/ ./server/
11-
RUN make VERSION=$VERSION COMMIT=$COMMIT RELEASE=1 build
11+
# The client is built on the host (make release); bake it into the binary via go:embed.
12+
COPY build/client ./server/frontend/dist/
13+
# CGO_ENABLED=0 produces a fully static binary (pure-Go net/tls), required for a scratch image.
14+
RUN CGO_ENABLED=0 make VERSION=$VERSION COMMIT=$COMMIT RELEASE=1 build
1215

13-
FROM alpine
16+
FROM scratch
1417
LABEL org.opencontainers.image.source="https://github.com/smocker-dev/smocker"
15-
WORKDIR /opt
1618
EXPOSE 8080 8081
17-
COPY build/client client/
18-
COPY --from=build-backend /go/src/smocker/build/* /opt/
19-
CMD ["/opt/smocker"]
19+
# CA roots so the proxy mock type can reach HTTPS backends (scratch ships none).
20+
COPY --from=build-backend /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
21+
# The UI is embedded in the binary, so nothing else is needed — just the static binary.
22+
COPY --from=build-backend /go/src/smocker/build/smocker /smocker
23+
# Run unprivileged (nobody). The listen ports are >1024 so no privilege is needed; a mounted
24+
# persistence directory must be writable by this uid.
25+
USER 65534:65534
26+
ENTRYPOINT ["/smocker"]

Makefile

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ DOCKER_IMAGE=ghcr.io/smocker-dev/smocker
2121
# A tag name must be valid ASCII and may contain lowercase and uppercase letters, digits, underscores, periods and dashes.
2222
# A tag name may not start with a period or a dash and may contain a maximum of 128 characters.
2323
DOCKER_TAG:=$(shell echo $(VERSION) | tr -cd '[:alnum:]_.-')
24-
IS_SEMVER:=$(shell echo $(DOCKER_TAG) | grep -E "^[[:digit:]]+\.[[:digit:]]+\.[[:digit:]]+$$")
24+
IS_SEMVER:=$(shell echo $(DOCKER_TAG) | grep -E "^v?[[:digit:]]+\.[[:digit:]]+\.[[:digit:]]+$$")
2525

2626
LEVEL=debug
2727

@@ -155,10 +155,26 @@ build-docker:
155155
start-docker: check-default-ports
156156
docker run -d -p 8080:8080 -p 8081:8081 --name $(APPNAME) $(DOCKER_IMAGE):$(DOCKER_TAG)
157157

158+
# Smoke-test the running container (started by start-docker): the config API answers, the UI is
159+
# served (proves the embedded client works — the image ships no client directory), and a mock
160+
# registered on the config port is served on the mock port.
161+
.PHONY: smoke-docker
162+
smoke-docker:
163+
@for i in $$(seq 1 30); do curl -sf http://localhost:8081/version >/dev/null 2>&1 && break; sleep 1; done
164+
@curl -sf http://localhost:8081/version >/dev/null || { echo "FAIL: /version"; exit 1; }
165+
@curl -sf http://localhost:8081/ | grep -q Smocker || { echo "FAIL: embedded UI not served"; exit 1; }
166+
@curl -sf -XPOST http://localhost:8081/mocks -H "Content-Type: application/json" \
167+
--data '[{"request":{"method":"GET","path":"/smoke"},"response":{"status":200,"body":"ok"}}]' >/dev/null \
168+
|| { echo "FAIL: register mock"; exit 1; }
169+
@test "$$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/smoke)" = "200" \
170+
|| { echo "FAIL: mock round-trip"; exit 1; }
171+
@echo "docker smoke tests passed"
172+
158173
.PHONY: check-default-ports
159174
check-default-ports:
160175
@lsof -i:8080 > /dev/null && (echo "Port 8080 already in use"; exit 1) || true
161176
@lsof -i:8081 > /dev/null && (echo "Port 8081 already in use"; exit 1) || true
177+
@lsof -i:$(PROXY_TARGET_PORT) > /dev/null && (echo "Port $(PROXY_TARGET_PORT) (go-httpbin) already in use"; exit 1) || true
162178

163179
.PHONY: optimize
164180
optimize:
@@ -168,11 +184,15 @@ optimize:
168184
# The following targets are only available for CI usage
169185

170186
build/smocker.tar.gz:
171-
$(MAKE) build
187+
# Build the client first, copy it into the embed source, then build the Go binary so the UI
188+
# is baked in (go:embed). The result is a self-contained binary — no client dir to ship.
172189
npm ci --ignore-scripts
173190
npm run build
174-
# Package only the release artifacts, not test/runtime output that may also live in build/.
175-
cd build/; tar -czvf smocker.tar.gz smocker client
191+
rm -rf server/frontend/dist && mkdir -p server/frontend/dist
192+
cp -r build/client/. server/frontend/dist/
193+
touch server/frontend/dist/.gitkeep
194+
$(MAKE) build
195+
cd build/; tar -czvf smocker.tar.gz smocker
176196

177197
.PHONY: release
178198
release: build/smocker.tar.gz

README.md

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -167,15 +167,15 @@ The backend is written in Go. You can use the following commands to manage the d
167167

168168
### Frontend
169169

170-
The frontend is written with TypeScript and React. You can use the following commands to manage the development lifecycle:
171-
172-
- `yarn install`: install the dependencies
173-
- `yarn start`: start the frontend in development mode, with live reload
174-
- `yarn build`: generate the transpiled and minified files and assets
175-
- `yarn lint`: run static analysis on the code
176-
- `yarn format`: automatically format the frontend code
177-
- `yarn test`: execute unit tests
178-
- `yarn test:watch`: execute unit tests, with live reload
170+
The frontend is written with TypeScript and React, bundled with Vite. You can use the following commands to manage the development lifecycle:
171+
172+
- `npm install`: install the dependencies
173+
- `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)
174+
- `npm run build`: generate the transpiled and minified files and assets
175+
- `npm run lint`: run static analysis on the code
176+
- `npm run format`: automatically format the frontend code
177+
- `npm test`: execute unit tests
178+
- `npm run test:watch`: execute unit tests, with live reload
179179

180180
### Docker
181181

client/modules/utils.test.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,8 +115,7 @@ describe("Generate curl command from:", () => {
115115
},
116116
};
117117
expect(entryToCurl(entry)).toBe(
118-
// FIXME: we shouldn't have the " if the client sent raw text
119-
`curl -XPOST '/test' --data '"value containing \\'single quotes\\'"'`,
118+
`curl -XPOST '/test' --data 'value containing \\'single quotes\\''`,
120119
);
121120
});
122121
});

client/modules/utils.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,13 @@ export const entryToCurl = (historyEntry: Entry): string => {
6060
);
6161

6262
if (request.body) {
63-
command.push(`--data '${escapeQuote(JSON.stringify(request.body))}'`);
63+
// A raw/urlencoded body is already a string; only JSON bodies need serializing. Stringifying
64+
// a string would wrap it in extra quotes (--data '"a=b"').
65+
const data =
66+
typeof request.body === "string"
67+
? request.body
68+
: JSON.stringify(request.body);
69+
command.push(`--data '${escapeQuote(data)}'`);
6470
}
6571

6672
return command.join(" ");

server/admin_server.go

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"github.com/labstack/echo/v4"
1919
"github.com/labstack/echo/v4/middleware"
2020
"github.com/smocker-dev/smocker/server/config"
21+
"github.com/smocker-dev/smocker/server/frontend"
2122
"github.com/smocker-dev/smocker/server/handlers"
2223
"github.com/smocker-dev/smocker/server/services"
2324
"golang.org/x/sync/errgroup"
@@ -79,8 +80,14 @@ func Serve(config config.Config) {
7980
return c.JSON(http.StatusOK, config.Build)
8081
})
8182

82-
// UI Routes
83-
adminServerEngine.Static("/assets", config.StaticFiles)
83+
// UI Routes: serve from --static-files on disk when it holds a built index.html (development
84+
// or an explicit override); otherwise fall back to the client embedded in the binary, so a
85+
// released binary is self-contained.
86+
if fileExists(config.StaticFiles + "/index.html") {
87+
adminServerEngine.Static("/assets", config.StaticFiles)
88+
} else if embedded, ok := frontend.FS(); ok {
89+
adminServerEngine.StaticFS("/assets", embedded)
90+
}
8491
adminServerEngine.GET("/*", renderIndex(adminServerEngine, config))
8592

8693
slog.Info("Starting admin server", "port", config.ConfigListenPort)
@@ -153,15 +160,32 @@ func serve(tlsEnable bool, servers ...*http.Server) error {
153160
return g.Wait()
154161
}
155162

163+
// fileExists reports whether path exists (used to prefer an on-disk UI over the embedded one).
164+
func fileExists(path string) bool {
165+
_, err := os.Stat(path)
166+
return err == nil
167+
}
168+
156169
func renderIndex(e *echo.Echo, cfg config.Config) echo.HandlerFunc {
157170
return func(c echo.Context) error {
158-
// In development mode, index.html might not be available yet
171+
// Parse index.html once: from --static-files on disk if present (dev/override), otherwise
172+
// from the client embedded in the binary. In development the client may not be built yet.
159173
if templateRenderer == nil {
160-
template, err := template.ParseFiles(cfg.StaticFiles + "/index.html")
174+
var (
175+
tmpl *template.Template
176+
err error
177+
)
178+
if diskIndex := cfg.StaticFiles + "/index.html"; fileExists(diskIndex) {
179+
tmpl, err = template.ParseFiles(diskIndex)
180+
} else if embedded, ok := frontend.FS(); ok {
181+
tmpl, err = template.ParseFS(embedded, "index.html")
182+
} else {
183+
return c.String(http.StatusNotFound, "index is building...")
184+
}
161185
if err != nil {
162186
return c.String(http.StatusNotFound, "index is building...")
163187
}
164-
templateRenderer := &TemplateRenderer{template}
188+
templateRenderer = &TemplateRenderer{tmpl}
165189
e.Renderer = templateRenderer
166190
}
167191

server/frontend/dist/.gitkeep

Whitespace-only changes.

0 commit comments

Comments
 (0)