diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..f336a9fbd --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,13 @@ +## Goal + + +## Changes +- + +## Testing + + +## Checklist +- [ ] Title is a clear sentence (≤ 70 chars) +- [ ] Commits are signed (`git log --show-signature`) +- [ ] `submissions/labN.md` updated diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..125863da6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,96 @@ +name: DevOps Intro Workflow + +on: + push: + branches: [ main ] + paths: + - 'app/**' + - '.github/workflows/**' + pull_request: + branches: [ main ] + paths: + - 'app/**' + - '.github/workflows/**' + +permissions: + contents: read + +env: + GOFLAGS: -buildvcs=false + +jobs: + vet: + runs-on: ubuntu-24.04 + steps: + - name: Checkout repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 1 + + - name: Setup Go compiler + uses: actions/setup-go@3041bf56c941b39c61721a86cd11f3bb1338122a # v5.2.0 + with: + go-version: '1.24' + cache: true + cache-dependency-path: app/go.sum + + - name: Run go vet + run: go vet ./... + working-directory: app + + test: + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + go: ['1.23', '1.24'] + steps: + - name: Checkout repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 1 + + - name: Setup Go compiler + uses: actions/setup-go@3041bf56c941b39c61721a86cd11f3bb1338122a # v5.2.0 + with: + go-version: ${{ matrix.go }} + cache: true + cache-dependency-path: app/go.sum + + - name: Run unit tests + run: go test -race -count=1 ./... + working-directory: app + + lint: + runs-on: ubuntu-24.04 + steps: + - name: Checkout repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 1 + + - name: Setup Go compiler + uses: actions/setup-go@3041bf56c941b39c61721a86cd11f3bb1338122a # v5.2.0 + with: + go-version: '1.24' + cache: true + cache-dependency-path: app/go.sum + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@1481404843c368bc19ca9406f87d6e0fc97bdcfd # v7.0.0 + with: + version: v2.5.0 + working-directory: app + + ci-ok: + if: always() + needs: [vet, test, lint] + runs-on: ubuntu-24.04 + steps: + - name: Check status of dependent jobs + run: | + if ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}; then + echo "One or more dependent jobs failed or were cancelled." + exit 1 + fi + echo "All dependent jobs completed successfully." diff --git a/.gitignore b/.gitignore index 1c0a1e94b..966b15200 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,5 @@ Thumbs.db # *.sbom.cdx.json, zap-*.html/json, trivy-*.txt (Lab 9 scan evidence) # flake.nix, flake.lock (Lab 11) # wasm/main.go, spin.toml, go.sum (Lab 12) +data/ +app/data/ diff --git a/app/Dockerfile b/app/Dockerfile new file mode 100644 index 000000000..7f43d41ae --- /dev/null +++ b/app/Dockerfile @@ -0,0 +1,24 @@ +# syntax=docker/dockerfile:1 + +FROM golang:1.24.6-bookworm AS builder +WORKDIR /src +COPY go.mod go.su[m] ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build \ + -trimpath \ + -ldflags='-s -w' \ + -o /out/quicknotes . +RUN mkdir -p /data-empty + +FROM busybox:1.37-uclibc AS busybox + +FROM gcr.io/distroless/static:nonroot +WORKDIR /app +COPY --from=builder /out/quicknotes /app/quicknotes +COPY --from=builder /src/seed.json /app/seed.json +COPY --from=busybox /bin/wget /bin/wget +COPY --from=builder --chown=65532:65532 /data-empty /data +USER nonroot:nonroot +EXPOSE 8080 +ENTRYPOINT ["/app/quicknotes"] diff --git a/app/handlers.go b/app/handlers.go index c534979c5..8fd457568 100644 --- a/app/handlers.go +++ b/app/handlers.go @@ -50,6 +50,9 @@ func (sw *statusWriter) WriteHeader(code int) { func (s *Server) wrap(h http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { sw := &statusWriter{ResponseWriter: w, code: 200} + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Cross-Origin-Resource-Policy", "same-origin") + w.Header().Set("Cache-Control", "no-store") h(sw, r) s.requestsTotal.Add(1) if c, ok := s.requestsByCode[sw.code]; ok { diff --git a/app/handlers_test.go b/app/handlers_test.go index 9dff2e3e5..461b348ba 100644 --- a/app/handlers_test.go +++ b/app/handlers_test.go @@ -130,4 +130,3 @@ func TestMetrics_ExposesPrometheusFormat(t *testing.T) { } } } - diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 000000000..384ee7640 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,29 @@ +services: + quicknotes: + build: ./app + image: quicknotes:lab6 + ports: + - "8080:8080" + environment: + ADDR: ":8080" + DATA_PATH: /data/notes.json + SEED_PATH: /app/seed.json + healthcheck: + test: ["CMD", "/bin/wget", "-q", "-O", "-", "http://127.0.0.1:8080/health"] + interval: 10s + timeout: 3s + retries: 3 + start_period: 5s + cap_drop: + - ALL + read_only: true + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true + volumes: + - quicknotes-data:/data + restart: unless-stopped + +volumes: + quicknotes-data: diff --git a/evidence/lab12/00-windows-attempt.txt b/evidence/lab12/00-windows-attempt.txt new file mode 100644 index 000000000..1264cc8a9 --- /dev/null +++ b/evidence/lab12/00-windows-attempt.txt @@ -0,0 +1,25 @@ +=== Windows attempt (abandoned) === +Host: Windows 11, Go 1.26.5, Spin 4.0.2, TinyGo 0.41.1 + +1. Spin 4 template no longer uses TinyGo at all. + Generated spin.toml build command: 'go tool componentize-go build' + Generated go.mod requires: github.com/spinframework/spin-go-sdk/v3 v3.0.0 + Tool: github.com/bytecodealliance/componentize-go v0.3.3 + The lab's requirements for -buildmode=c-shared and TinyGo describe a toolchain + Spin 3.x used; Spin 4 replaced it. + +2. componentize-go's own installer requests a file that does not exist: + GET .../v0.3.3/componentize-go-windows-amd64.tar.gz -> 404 + The release publishes componentize-go-windows-amd64.zip instead. + +3. Installing the binary manually got further, but the scaffold ships no wit/ + directory, so the build fails with 'failed to read path for WIT [wit]'. + +4. Supplying a wit/ directory from componentize-go's own wasip2 example got + further still, and then the Go linker panicked: + runtime.wasiOnIdle.wrapinfo: missing section for relocation target + panic: runtime error: invalid memory address or nil pointer dereference + cmd/link/internal/ld.(*pclntab).generateFuncdata + This is a linker bug in the host Go toolchain, not a configuration error. + +Conclusion: the lab was completed on macOS instead. diff --git a/evidence/lab12/01-task1.txt b/evidence/lab12/01-task1.txt new file mode 100644 index 000000000..8e0ee19a4 --- /dev/null +++ b/evidence/lab12/01-task1.txt @@ -0,0 +1,53 @@ +=== toolchain === +spin 4.0.2 (bfc7543 2026-06-23) +go version go1.25.12 darwin/arm64 (build used this; the 1.26.5 shown by a shell without GOTOOLCHAIN=local crashes the linker) +GOTOOLCHAIN= + +=== spin.toml === +#:schema https://schemas.spinframework.dev/spin/manifest-v2/latest.json + +spin_manifest_version = 2 + +[application] +name = "moscow-time" +version = "0.1.0" +authors = ["Elvira <239804565+HNS2112@users.noreply.github.com>"] +description = "" + +[[trigger.http]] +route = "/time" +component = "moscow-time" + +[component.moscow-time] +source = "main.wasm" +allowed_outbound_hosts = [] +[component.moscow-time.build] +command = "go tool componentize-go build" +watch = ["**/*.go", "go.mod"] + +=== go.mod === +module github.com/moscow_time + +go 1.25.5 + +require github.com/spinframework/spin-go-sdk/v3 v3.0.0 + +require ( + github.com/apparentlymart/go-userdirs v0.0.0-20200915174352-b0c018a67c13 // indirect + github.com/bytecodealliance/componentize-go v0.3.3 // indirect + github.com/gofrs/flock v0.13.0 // indirect + go.bytecodealliance.org/pkg v0.2.1 // indirect + golang.org/x/sys v0.37.0 // indirect +) + +tool github.com/bytecodealliance/componentize-go +=== artifact === +-rw-r--r-- 1 elvira staff 5242031 Aug 10 17:56 wasm/moscow-time/main.wasm + +=== GET /time === +{ + "unix": 1786374003, + "iso": "2026-08-10T18:00:03+03:00", + "hour_minute": "18:00", + "zone": "Europe/Moscow (UTC+3)" +} diff --git a/evidence/lab12/02-warm.md b/evidence/lab12/02-warm.md new file mode 100644 index 000000000..24caf9f91 --- /dev/null +++ b/evidence/lab12/02-warm.md @@ -0,0 +1,4 @@ +| Command | Mean [ms] | Min [ms] | Max [ms] | Relative | +|:---|---:|---:|---:|---:| +| `curl -s http://127.0.0.1:3000/time` | 10.5 ± 0.8 | 8.8 | 11.8 | 1.00 | +| `curl -s http://localhost:8080/health` | 11.3 ± 1.1 | 7.8 | 12.8 | 1.07 ± 0.13 | diff --git a/evidence/lab12/03-cold-docker.txt b/evidence/lab12/03-cold-docker.txt new file mode 100644 index 000000000..2eee92008 --- /dev/null +++ b/evidence/lab12/03-cold-docker.txt @@ -0,0 +1,6 @@ +=== Docker cold start (5 samples) === +docker cold-1 = 137.5 ms +docker cold-2 = 143.7 ms +docker cold-3 = 139.9 ms +docker cold-4 = 131.5 ms +docker cold-5 = 140.7 ms diff --git a/evidence/lab12/04-cold-spin.txt b/evidence/lab12/04-cold-spin.txt new file mode 100644 index 000000000..156ce3bd4 --- /dev/null +++ b/evidence/lab12/04-cold-spin.txt @@ -0,0 +1,6 @@ +=== Spin cold start (5 samples) === +spin cold-1 = 171.7 ms +spin cold-2 = 136.3 ms +spin cold-3 = 137.2 ms +spin cold-4 = 134.6 ms +spin cold-5 = 139.9 ms diff --git a/evidence/lab12/05-wasmtime.md b/evidence/lab12/05-wasmtime.md new file mode 100644 index 000000000..deea14583 --- /dev/null +++ b/evidence/lab12/05-wasmtime.md @@ -0,0 +1,3 @@ +| Command | Mean [ms] | Min [ms] | Max [ms] | Relative | +|:---|---:|---:|---:|---:| +| `wasmtime run --env REQUEST_METHOD=GET --env PATH_INFO=/time main.wasm` | 20.2 ± 1.2 | 17.7 | 23.5 | 1.00 | diff --git a/evidence/lab12/06-bonus.txt b/evidence/lab12/06-bonus.txt new file mode 100644 index 000000000..8596f192a --- /dev/null +++ b/evidence/lab12/06-bonus.txt @@ -0,0 +1,18 @@ +=== wasm-cli artifact === +-rwxr-xr-x 1 elvira staff 3184098 Aug 10 18:06 main.wasm + +=== build command === +GOOS=wasip1 GOARCH=wasm go build -o main.wasm . + +=== run command + output === +wasmtime run --env REQUEST_METHOD=GET --env PATH_INFO=/time main.wasm +Content-Type: application/json + +{"unix":1786374493,"iso":"2026-08-10T18:08:13+03:00","hour_minute":"18:08","zone":"Europe/Moscow (UTC+3)"} + +=== Spin component under bare wasmtime run === +Error: failed to run main module `../wasm/moscow-time/main.wasm` + +Caused by: + 0: component imports instance `wasi:http/types@0.3.0-rc-2026-03-15`, but a matching implementation was not found in the linker + 1: instance export `fields` has the wrong type diff --git a/submissions/lab12.md b/submissions/lab12.md new file mode 100644 index 000000000..38aa7da94 --- /dev/null +++ b/submissions/lab12.md @@ -0,0 +1,608 @@ +# Lab 12 — Bonus: WebAssembly Containers — A QuickNotes Endpoint on Spin + +**Author:** HNS ([@HNS2112](https://github.com/HNS2112)) +**Date:** 10 August 2026 +**Test rig:** MacBook Air M3 (Apple Silicon, arm64), macOS, 16 GB RAM +**Toolchain:** Spin 4.0.2, componentize-go 0.3.3, Go 1.25.12 (`GOTOOLCHAIN=local`), +wasmtime 47.0.3, hyperfine 1.20.0, Docker 29.6.2 + +Raw output is committed under `evidence/lab12/`; sources under `wasm/` and `wasm-cli/`. + +--- + +## Toolchain: what the lab describes no longer exists + +The lab pins **Spin 3.4 + TinyGo 0.41 + `spin-go-sdk/v2`** and was validated in +May 2026. Scaffolding with `spin new -t http-go` on Spin 4.0.2 produces something +different in three ways, and the lab's own instruction — *"Do not hand-write +`spin.toml` from an old tutorial… scaffold the canonical layout for your Spin +version"* — is exactly why this report follows the scaffold rather than the text. + +| Lab says | Spin 4.0.2 scaffold produces | +|---|---| +| `tinygo build -target=wasip1 -buildmode=c-shared` | `go tool componentize-go build` | +| `github.com/spinframework/spin-go-sdk/v2` | `github.com/spinframework/spin-go-sdk/v3 v3.0.0` | +| TinyGo as the compiler | upstream Go via `componentize-go` | + +**TinyGo is not used at all.** It was installed and never invoked. That +invalidates the premise of two design questions: (b) asks why the build needs +`-buildmode=c-shared`, and (d) asks which TinyGo stdlib gap was hit. Both are +answered below for what they were actually asking, with the substitution stated. + +### Four failures before the first successful build + +Recorded because they are the real content of "WASM tooling moves fast — pin +your versions", and because three of the four are bugs in released tooling +rather than mistakes in configuration. + +**1. Windows: the installer downloads a file that does not exist.** + +``` +Downloading `componentize-go` binary from +https://github.com/.../v0.3.3/componentize-go-windows-amd64.tar.gz +2026/08/10 11:35:39 unexpected status for URL `...tar.gz`: 404 +``` + +The release publishes `componentize-go-windows-amd64.zip`. The tool asks for +`.tar.gz` on every platform. Installing the binary manually to the exact cache +path it prints did not help — it re-attempts the download regardless. + +**2. The scaffold ships no `wit/` directory.** + +``` +Error: failed to read path for WIT [wit] +Caused by: No such file or directory (os error 2) +``` + +Reproduced identically on Windows and macOS, so it is the template, not the +platform. Worked around by copying the `wit/` tree from `componentize-go`'s own +`examples/wasip2`. + +**3. The Go linker segfaults under Go 1.26.5.** + +``` +runtime.wasiOnIdle.wrapinfo: missing section for relocation target +[signal SIGSEGV: segmentation violation] +cmd/link/internal/ld.(*pclntab).generateFuncdata.func2 +cmd/link/internal/wasm.asmb +``` + +Same crash, same stack frame, on Windows/amd64 and macOS/arm64. Not a +configuration error — a linker bug hit by `componentize-go 0.3.3` on that Go +version. + +**4. Downgrading Go required disabling the toolchain switcher.** + +Installing Go 1.24 and putting it first in `PATH` changed nothing: + +```console +$ export PATH="/opt/homebrew/opt/go@1.24/bin:$PATH" && go version +go: downloading go1.25.5 (darwin/arm64) +go version go1.25.5 darwin/arm64 +``` + +`GOTOOLCHAIN=auto` silently fetched a newer Go because `go.mod` asked for it — +the same mechanism documented in Lab 3 §2.2, where it made a CI version matrix +test one compiler under two names. `GOTOOLCHAIN=local` pinned it. Go 1.24 was +then rejected (`componentize-go requires go >= 1.25`), so **Go 1.25.12** is the +one narrow version that works: new enough for the tool, old enough for the linker. + +**And then the tool solved the problem it had been crashing on:** + +``` +Note: /opt/homebrew/Cellar/go@1.25/1.25.12/libexec/bin/go does not support +async operation; will use downloaded version. +See https://github.com/golang/go/pull/76775 for details. +Downloading patched Go from +https://github.com/dicej/go/releases/download/go1.25.5-wasi-on-idle-v2/... +Finished building all Spin components +``` + +`componentize-go` detects that the host Go lacks the `wasi-on-idle` patch and +downloads a patched fork. Under Go 1.26.5 that detection never fired and the +stock linker crashed instead — which is why the failure looked like a +configuration problem for four attempts. + +--- + +## Task 1 — WASM Endpoint with the Spin SDK + +### 1.1 `spin.toml` + +```toml +spin_manifest_version = 2 + +[application] +name = "moscow-time" +version = "0.1.0" + +[[trigger.http]] +route = "/time" +component = "moscow-time" + +[component.moscow-time] +source = "main.wasm" +allowed_outbound_hosts = [] +[component.moscow-time.build] +command = "go tool componentize-go build" +watch = ["**/*.go", "go.mod"] +``` + +Only `route` was edited (from the scaffold's `/...`). The build command is the +scaffold's own and was left alone, per requirement 1.3. + +### 1.2 `main.go` + +```go +package main + +import ( + "encoding/json" + "net/http" + "time" + + spinhttp "github.com/spinframework/spin-go-sdk/v3/http" +) + +// Russia abolished DST in 2011, so Moscow is a constant UTC+3 offset and needs +// no tzdata lookup — which matters because the wasip2 sandbox has no +// /usr/share/zoneinfo to read. +var moscow = time.FixedZone("MSK", 3*60*60) + +type timeResponse struct { + Unix int64 `json:"unix"` + ISO string `json:"iso"` + HourMinute string `json:"hour_minute"` + Zone string `json:"zone"` +} + +func init() { + spinhttp.Handle(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.Header().Set("Allow", "GET") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + now := time.Now().In(moscow) + resp := timeResponse{ + Unix: now.Unix(), + ISO: now.Format(time.RFC3339), + HourMinute: now.Format("15:04"), + Zone: "Europe/Moscow (UTC+3)", + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(resp); err != nil { + http.Error(w, "encoding failed", http.StatusInternalServerError) + } + }) +} + +// main is required by the compiler but never executed — the entry point is the +// handler registered in init(). +func main() {} +``` + +### 1.3 Build and run + +```console +$ spin build +Building component moscow-time with `go tool componentize-go build` +Using /Users/elvira/Library/Caches/componentize-go/v2/go-darwin-arm64-bootstrap/bin/go. +Finished building all Spin components + +$ ls -la main.wasm +-rw-r--r-- 1 elvira staff 5242031 Aug 10 17:56 main.wasm + +$ spin up +Serving http://127.0.0.1:3000 +Available Routes: + moscow-time: http://127.0.0.1:3000/time + +$ curl -s http://127.0.0.1:3000/time | python3 -m json.tool +{ + "unix": 1786374003, + "iso": "2026-08-10T18:00:03+03:00", + "hour_minute": "18:00", + "zone": "Europe/Moscow (UTC+3)" +} +``` + +**main.wasm: 5,242,031 bytes (5.24 MB).** + +### 1.4 Design questions + +#### a) Browser WASM vs server WASM + +`GOOS=js GOARCH=wasm` targets a JavaScript host. The module cannot do anything +by itself — every capability arrives through the `syscall/js` bridge, so there is +no filesystem, no sockets, no environment, no clock except what JS hands over. It +ships alongside `wasm_exec.js`, and the browser's own sandbox is the security +boundary. + +`GOOS=wasip1` (or the wasip2 component this lab builds) targets a **WASI host**. +What is gained is a real system interface: files, sockets, environment variables, +clocks, standard streams — enough to run server code without a browser. What is +missing relative to a native binary is everything the host declines to grant, +and the grant is explicit rather than assumed. + +The concrete difference showed up in this lab twice. Timezone data: there is no +`/usr/share/zoneinfo` unless the host mounts it, so `time.LoadLocation` cannot +work — hence `time.FixedZone`. And networking: `allowed_outbound_hosts = []` +means the module has no way to open a socket, not that it is forbidden to. + +Both targets are sandboxed. Browser WASM is sandboxed by the browser and reaches +the world through JavaScript; server WASM is sandboxed by the WASI host and +reaches the world through capabilities the host chose to pass in. + +#### b) Why `-buildmode=c-shared`? — and why it is absent here + +**The scaffold does not use it.** Spin 4 builds with +`go tool componentize-go build`, which produces a **wasip2 component**, not a +wasip1 module, so the flag is not part of the pipeline at all. Removing it and +observing the failure, as the question suggests, is not possible. + +The reasoning the question is after still holds for the TinyGo path. A normal +executable exports `_start` and runs to completion. That is the wrong shape for +a Spin component: the host does not want to *run* the module, it wants to *call +into* it once per HTTP request, repeatedly, while the module stays instantiated. +`-buildmode=c-shared` produces a library — exported symbols and no `_start` — so +the host can invoke the registered handler. Without it, TinyGo emits a command +module, Spin finds no handler to call, and the lab's own pitfalls list records +the symptom: HTTP 500 with empty component logs. + +The componentize-go path reaches the same end differently. The component's +interface is described by WIT, and the build emits exports matching +`wasi:http/incoming-handler` — the contract is declared in the type system +rather than implied by a linker flag. §B.3(h) below shows what that contract +looks like when it does not match. + +#### c) `allowed_outbound_hosts = []` and capability-based security + +The empty list means the component is granted **no** outbound network capability. +This is not a firewall rule evaluated when a connection is attempted — the +capability is never handed to the guest, so the code has nothing to call. There +is no socket API present to misuse. + +That is the substantive difference from Docker's `--network none`. Both end with +"this workload cannot reach the network", but they get there from opposite +directions. Docker's default is full network access, narrowed by configuration; +the container still has a full syscall interface, and `--network none` removes +the interfaces and routes the kernel would otherwise offer. WASM's default is +**nothing**, widened by explicit grant; the guest has no ambient authority to +remove. + +The practical consequence is what happens when the configuration is wrong. Forget +`--network none` and the container silently has full network access. Forget +`allowed_outbound_hosts` and the component has none — the failure mode is denial, +not exposure. Deny-by-default versus allow-by-default, which is the same argument +`cap_drop: ALL` makes in Lab 6, taken one level further: capabilities cannot be +dropped because they were never granted. + +Docker's model is also coarse. `--network none` is all or nothing; letting a +container reach exactly one host means a custom network or an egress proxy. Spin +takes a host allowlist directly in the manifest, versioned alongside the code. + +#### d) Stdlib gaps + +**TinyGo was not used, so its gaps were not hit.** The build used upstream Go via +`componentize-go`, where `encoding/json` and reflection work normally — the +handler marshals a struct with `json.NewEncoder`, which the lab warns may fail +under TinyGo. + +The gap that *was* hit is the sandbox's, not the compiler's: **`time.LoadLocation` +cannot work**, because the wasip2 guest has no filesystem access unless granted +and therefore no `/usr/share/zoneinfo` to read. The lab anticipates this and +prescribes the same fix used here — a fixed UTC+3 offset, valid for Moscow +because Russia abolished DST in 2011. + +The distinction is worth keeping straight. A TinyGo stdlib gap is *"this Go +feature is not implemented in this compiler"*. A WASI gap is *"this call is +implemented, and the host did not grant the capability it needs"*. The first is +fixed by changing compilers; the second is the sandbox working as designed. + +The other real cost is size: 5.24 MB for one endpoint, because upstream Go +brings its full runtime and GC. TinyGo's whole reason for existing on this target +is producing far smaller modules by leaving most of that out. + +--- + +## Task 2 — Perf Comparison vs the Lab 6 Container + +### 2.1 Method + +Both services running simultaneously on the same machine. Warm latency via +`hyperfine --warmup 5 --runs 50` against `/time` (Spin) and `/health` (Docker). +Cold start: stop the runtime, restart it, poll until the first successful +response — five samples each, timed in Python around the loop. + +### 2.2 Results + +| Dimension | Lab 6 Docker | Lab 12 WASM/Spin | +|---|---|---| +| Artifact size | 16.2 MB (image) | **5.24 MB** (main.wasm) | +| Cold start (p50) | 139.9 ms | **137.2 ms** | +| Cold start (range) | 131.5 – 143.7 ms | 134.6 – 171.7 ms | +| Warm latency mean | 11.3 ± 1.1 ms | **10.5 ± 0.8 ms** | +| Warm latency range | 7.8 – 12.8 ms | 8.8 – 11.8 ms | + +Raw cold-start samples: + +``` +docker cold-1 = 137.5 ms spin cold-1 = 171.7 ms +docker cold-2 = 143.7 ms spin cold-2 = 136.3 ms +docker cold-3 = 139.9 ms spin cold-3 = 137.2 ms +docker cold-4 = 131.5 ms spin cold-4 = 134.6 ms +docker cold-5 = 140.7 ms spin cold-5 = 139.9 ms +``` + +**Cold start came out essentially identical — 137 ms against 140 ms.** That is +not the result the lab's framing predicts, and it is the most interesting number +here. §2.3(e) works through why. + +Warm latency differs by 7%, within the noise. Both figures are dominated by +process-spawn overhead for `curl` rather than by either service: Lab 4 measured +QuickNotes itself at 196 microseconds on loopback, roughly fifty times less than +what `hyperfine` reports for the whole invocation. The measurement is fair — +both sides pay the same overhead — but it is measuring `curl` more than it is +measuring the runtimes. + +The size difference is the one clear win: **3.1× smaller**, 5.24 MB against +16.2 MB. + +### 2.3 Design questions + +#### e) What dominates each cold start + +The lab's expectation is *"Container: image extract + namespace init. Spin: +wasmtime instantiation + WASM module load"*, implying WASM should win. It did +not, and the reason is that the expectation describes a first-run container while +the measurement used `docker start` on an existing one. + +**Docker (~140 ms):** no image extraction happens. The image was pulled and +unpacked once; `docker start` creates namespaces and cgroups, sets up the network +namespace and the port mapping, and `exec`s a statically linked binary that +serves its first request in microseconds. The dominant cost is the Docker daemon +round-trip and namespace setup — and, on Apple Silicon, that setup happens inside +the Linux VM that Docker Desktop runs, adding a hop that would not exist on Linux. + +**Spin (~137 ms):** no namespaces at all. Spin reads and validates the manifest, +loads the 5.24 MB module, JIT-compiles it in wasmtime, links the wasi-http +imports, and binds an HTTP listener. Module load and compilation scale with +module size, which is where the 5.24 MB of Go runtime shows up as a cost rather +than just as disk usage. + +Two different sets of work, arriving at the same wall-clock number by +coincidence. The comparison would separate them under conditions this rig did not +test: a cold image pull adds seconds to the container and nothing to Spin, while +Spin's per-*request* instantiation — the number that matters for +scale-to-zero — is microseconds against a container's ~140 ms, because a warm +Spin process instantiates a fresh module per request rather than starting a +process. + +That last point reframes the whole table. Comparing *runtime startup* is +comparing the wrong thing; the WASM advantage is that after startup there is no +per-tenant process at all. + +#### f) Where WASM wins, where Docker still wins + +**WASM wins where the unit of work is small, short, and numerous.** Edge +functions and per-request handlers: instantiating a module per request is +microseconds, so scale-to-zero is free and there is no cold-start tax on the +first request after idle — the property that made Lab 10's Render deployment take +14 seconds to wake. Massive multi-tenancy: thousands of untrusted tenants on one +process, each in its own instance, with no per-tenant kernel object. Plugin +systems, where untrusted third-party code must run inside a host application — +Envoy filters, database UDFs, extension APIs — because a container is far too +heavy and a shared library has no boundary at all. And portability: one artifact +runs on arm64 and amd64 unchanged, which Lab 6 showed containers do not (the +image built 16.2 MB on arm64 and 9.22 MB on amd64, and needed +`platforms: linux/amd64` in Lab 10 to deploy at all). + +**Docker still wins whenever the workload needs a real operating system.** Any +existing application that assumes threads, `fork`, signals, or a full filesystem +— which is most software. Anything needing native libraries, CGO, or hardware +access. Stateful services: databases and queues want persistent volumes, real +disk semantics, and a process model WASI does not fully provide. Long-running +workloads where startup cost amortises to nothing and the ecosystem — images, +registries, orchestrators, monitoring, the whole toolchain used across Labs 6, +8, 9 and 10 — is worth more than a few hundred milliseconds. + +The honest summary from this lab's own numbers: for one HTTP endpoint on a +laptop, WASM is 3× smaller and otherwise indistinguishable. The interesting +differences live at scale and at the sandbox boundary, neither of which a +two-service benchmark on a MacBook can show. + +#### g) What multi-tenant attack WASM makes harder + +**Container escape via a kernel vulnerability.** A container is a process with +namespaces, cgroups and seccomp filters applied; it shares one kernel with the +host and every other tenant, and it reaches that kernel through hundreds of +syscalls. Escapes work by finding a bug in one of them — Dirty COW, Dirty Pipe, +the `waitid` CVE-2017-5123, io_uring bugs — and using it to break out of the +namespace into the host, and from there into other tenants. + +A WASM guest has **no syscall interface to attack**. It cannot issue a syscall; +it can only call imported functions the host explicitly provided. With +`allowed_outbound_hosts = []` and no filesystem grant, the import list for this +component is close to empty. The kernel attack surface reachable from guest code +is not narrowed — it is absent, because there is no path from guest code to the +kernel that does not pass through host code the host wrote. + +Memory isolation is structural too. A WASM instance's linear memory is a bounded +region with every access bounds-checked by the runtime; there are no raw pointers +into host memory to corrupt. A container shares the host's address space model +and relies on the MMU plus kernel correctness for the same guarantee. + +The boundary is smaller and easier to audit, which is the real claim: not that +wasmtime has no bugs, but that its interface is a few dozen host functions +instead of a few hundred syscalls plus every kernel subsystem behind them. And a +sandbox escape in a container is a kernel exploit affecting every workload on the +machine; a sandbox escape in WASM is a runtime bug affecting that runtime. + +--- + +## Bonus Task — Two WASM Execution Models + +### B.1 The standalone WASI CLI module + +`wasm-cli/main.go` implements the same Moscow-time logic in the CGI shape: +request context from environment variables, response to stdout, process exits. + +```go +func main() { + method := os.Getenv("REQUEST_METHOD") + path := os.Getenv("PATH_INFO") + // ... 405 / 404 guards ... + now := time.Now().In(moscow) + body, _ := json.Marshal(timeResponse{ /* ... */ }) + fmt.Println("Content-Type: application/json") + fmt.Println() + fmt.Println(string(body)) +} +``` + +**Build** — the lab prescribes `tinygo build -target=wasi`, but TinyGo is not part +of this toolchain (see above). Upstream Go targets wasip1 directly: + +```console +$ GOOS=wasip1 GOARCH=wasm go build -o main.wasm . +$ ls -la main.wasm +-rwxr-xr-x 1 elvira staff 3184098 Aug 10 18:06 main.wasm +``` + +**Run:** + +```console +$ wasmtime run --env REQUEST_METHOD=GET --env PATH_INFO=/time main.wasm +Content-Type: application/json + +{"unix":1786374493,"iso":"2026-08-10T18:08:13+03:00","hour_minute":"18:08","zone":"Europe/Moscow (UTC+3)"} +``` + +### B.2 Comparison + +| Dimension | Spin component (wasip2) | CLI module (wasip1) | +|---|---|---| +| Size | 5.24 MB | **3.18 MB** | +| Model | persistent wasi-http server | per-invocation process | +| Per-request cost | 10.5 ms (via curl, warm) | **20.2 ± 1.2 ms** per `wasmtime run` | +| Runtime startup | ~137 ms once | none — startup *is* the request | + +The CLI module is **1.6 MB smaller** because it imports only wasi-cli, not the +wasi-http world with its types and resources. + +The 20.2 ms per invocation is the whole cost of a request in this model: process +spawn, wasmtime start, module load, JIT compile, run, exit — repeated every time. +Spin pays the equivalent once at startup and then serves requests out of a warm +process. + +### B.3 Design questions + +#### h) Why the Spin component cannot run under bare `wasmtime run` + +Because it exports the wrong thing, and `wasmtime` says so precisely: + +```console +$ wasmtime run ../wasm/moscow-time/main.wasm +Error: failed to run main module `../wasm/moscow-time/main.wasm` + +Caused by: + 0: component imports instance `wasi:http/types@0.3.0-rc-2026-03-15`, + but a matching implementation was not found in the linker + 1: instance export `fields` has the wrong type +``` + +`wasmtime run` executes a **command**: a module exporting `_start`, which it +calls once, in a world providing wasi-cli — stdin, stdout, args, environment, +clocks. The Spin component exports no `_start`. It exports +`wasi:http/incoming-handler` and *imports* `wasi:http/types`, expecting a host +that runs an HTTP server and calls in per request. `wasmtime run` provides no +such host, so linking fails before any code executes. + +The lab suggests `wasmtime serve` as the alternative, which does provide +wasi-http. That failed too, and for a sharper reason: + +```console +$ wasmtime serve ../wasm/moscow-time/main.wasm +Error: component imports instance `wasi:http/types@0.3.0-rc-2026-03-15`, + but a matching implementation was not found in the linker +Caused by: + 0: instance export `fields` has the wrong type + 1: resource implementation is missing +``` + +The version string is the whole story: Spin 4's SDK targets a **March 2026 +release candidate of wasi-http 0.3.0**, while wasmtime 47.0.3 implements the +stable 0.2.x. Same interface name, incompatible shapes — so the mismatch is not +only between execution models but between revisions of the same interface, with +the component ahead of the general-purpose runtime that Spin itself embeds. + +#### i) What Spin adds on top of wasmtime + +wasmtime is an engine: it compiles and instantiates modules and provides WASI +host implementations. Everything needed to run a *web service* sits above that, +and that layer is Spin. + +**A manifest and routing.** `spin.toml` declares components, HTTP routes, and +which component handles which path. Bare wasmtime has no concept of a route. + +**The server loop and instance lifecycle.** Spin owns the listener, and per +request instantiates a fresh module instance, runs the handler, and discards it. +That per-request instantiation is what makes the model safe — no state carries +between requests — and it is Spin's code, not wasmtime's. + +**Instance pooling.** Pre-instantiated modules and a pooling allocator turn +per-request instantiation from milliseconds into microseconds. Without it the +model would cost what §B.2 measured for `wasmtime run`: 20 ms per request. + +**Capability policy.** `allowed_outbound_hosts` is a Spin construct. Spin decides +which host functions to link into each component's instance; wasmtime enforces +that only what was linked can be called. + +**Toolchain and distribution.** Templates, `spin build`, plugins, packaging +components as OCI artifacts. + +The relationship is close to wasmtime being to Spin what a container runtime is +to an orchestrator: the engine executes, the layer above decides what to execute, +when, with which permissions, and in response to what. + +#### j) When each execution model fits + +**Per-invocation (`wasmtime run`, CGI-shaped)** fits work that is genuinely +one-shot and where a cold process per unit is acceptable or desirable. A +**scheduled batch job** — a nightly report generator, a cron-triggered +transform — is the clean example: it runs once, produces output, exits, and the +20 ms of startup is irrelevant against the work. It also fits anywhere strict +isolation per invocation is worth paying for, or where the host is a CLI rather +than a server: build-tool plugins, `git` hooks, sandboxed evaluation of untrusted +scripts. + +**Persistent server (Spin)** fits **any HTTP API under real traffic**. This +lab's own `/time` endpoint is the example: at any meaningful request rate, +20 ms of per-request startup against 10 ms of actual serving means over half the +budget is spent starting up. Spin amortises that to a one-time ~137 ms, then +serves from a warm process with per-request instantiation in microseconds — while +keeping the isolation, because each request still gets a fresh instance. + +The dividing line is not really "batch vs web" but **how many times the same code +runs**. Once or occasionally: pay startup per invocation and keep the model +simple. Continuously: pay it once and keep the isolation without the cost, which +is exactly the trade Spin's instance pooling exists to make. + +--- + +## Summary + +| Task | Status | +|------|--------| +| Task 1 — Spin SDK component serving `/time` | Complete | +| Task 2 — Perf comparison vs Lab 6 container | Complete | +| Bonus — Two WASM execution models | Complete | + +Toolchain note: completed on Spin 4.0.2 with `componentize-go` and upstream Go +1.25.12, rather than the Spin 3.4 + TinyGo stack the lab describes. The +substitution is forced by the current `spin new -t http-go` scaffold and is +documented above with the four intermediate failures. diff --git a/wasm-cli/go.mod b/wasm-cli/go.mod new file mode 100644 index 000000000..08e038d04 --- /dev/null +++ b/wasm-cli/go.mod @@ -0,0 +1,3 @@ +module quicknotes-wasm-cli + +go 1.25.5 diff --git a/wasm-cli/main.go b/wasm-cli/main.go new file mode 100644 index 000000000..cf31a66e2 --- /dev/null +++ b/wasm-cli/main.go @@ -0,0 +1,53 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "time" +) + +var moscow = time.FixedZone("MSK", 3*60*60) + +type timeResponse struct { + Unix int64 `json:"unix"` + ISO string `json:"iso"` + HourMinute string `json:"hour_minute"` + Zone string `json:"zone"` +} + +// This is the CGI-shaped model: request context arrives in environment +// variables, the response goes to stdout, and the process exits. There is no +// server loop — the host invokes the module once per request. +func main() { + method := os.Getenv("REQUEST_METHOD") + path := os.Getenv("PATH_INFO") + + if method != "" && method != "GET" { + fmt.Println("Status: 405 Method Not Allowed") + fmt.Println() + return + } + if path != "" && path != "/time" { + fmt.Println("Status: 404 Not Found") + fmt.Println() + return + } + + now := time.Now().In(moscow) + body, err := json.Marshal(timeResponse{ + Unix: now.Unix(), + ISO: now.Format(time.RFC3339), + HourMinute: now.Format("15:04"), + Zone: "Europe/Moscow (UTC+3)", + }) + if err != nil { + fmt.Println("Status: 500 Internal Server Error") + fmt.Println() + return + } + + fmt.Println("Content-Type: application/json") + fmt.Println() + fmt.Println(string(body)) +} diff --git a/wasm-cli/main.wasm b/wasm-cli/main.wasm new file mode 100755 index 000000000..02c4520ad Binary files /dev/null and b/wasm-cli/main.wasm differ diff --git a/wasm/moscow-time/.gitignore b/wasm/moscow-time/.gitignore new file mode 100644 index 000000000..b56501047 --- /dev/null +++ b/wasm/moscow-time/.gitignore @@ -0,0 +1,2 @@ +main.wasm +.spin/ diff --git a/wasm/moscow-time/go.mod b/wasm/moscow-time/go.mod new file mode 100644 index 000000000..a1fcae95c --- /dev/null +++ b/wasm/moscow-time/go.mod @@ -0,0 +1,15 @@ +module github.com/moscow_time + +go 1.25.5 + +require github.com/spinframework/spin-go-sdk/v3 v3.0.0 + +require ( + github.com/apparentlymart/go-userdirs v0.0.0-20200915174352-b0c018a67c13 // indirect + github.com/bytecodealliance/componentize-go v0.3.3 // indirect + github.com/gofrs/flock v0.13.0 // indirect + go.bytecodealliance.org/pkg v0.2.1 // indirect + golang.org/x/sys v0.37.0 // indirect +) + +tool github.com/bytecodealliance/componentize-go \ No newline at end of file diff --git a/wasm/moscow-time/go.sum b/wasm/moscow-time/go.sum new file mode 100644 index 000000000..f9165ab52 --- /dev/null +++ b/wasm/moscow-time/go.sum @@ -0,0 +1,23 @@ +github.com/apparentlymart/go-userdirs v0.0.0-20200915174352-b0c018a67c13 h1:JtuelWqyixKApmXm3qghhZ7O96P6NKpyrlSIe8Rwnhw= +github.com/apparentlymart/go-userdirs v0.0.0-20200915174352-b0c018a67c13/go.mod h1:7kfpUbyCdGJ9fDRCp3fopPQi5+cKNHgTE4ZuNrO71Cw= +github.com/bytecodealliance/componentize-go v0.3.3 h1:8OA2qjWQA45vTMy5e1dboCOBqhAArUfMtVWlWSLJl/k= +github.com/bytecodealliance/componentize-go v0.3.3/go.mod h1:w1QFtPLGI9o38epvMOPyCKbMc7q7GJ7yZhIvhGTpzA0= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= +github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= +github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/spinframework/spin-go-sdk/v3 v3.0.0 h1:YI5HTK0wXDu6KIZ3dzTqszqhnyGrnt4m7vH9judUkcA= +github.com/spinframework/spin-go-sdk/v3 v3.0.0/go.mod h1:TBYpyA7BXVL/7+uUJrNDA+OdlLFg3+NSvXSK7HVI1N4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.bytecodealliance.org/pkg v0.2.1 h1:TdRagooIcCW3UmlKqVO4cDR3GNDyfDnbiBzGI6TOvyg= +go.bytecodealliance.org/pkg v0.2.1/go.mod h1:OjA+V8g3uUFixeCKFfamm6sYhTJdg8fvwEdJ2GO0GSk= +golang.org/x/sys v0.0.0-20190509141414-a5b02f93d862/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/wasm/moscow-time/main.go b/wasm/moscow-time/main.go new file mode 100644 index 000000000..fd06055a1 --- /dev/null +++ b/wasm/moscow-time/main.go @@ -0,0 +1,49 @@ +package main + +import ( + "encoding/json" + "net/http" + "time" + + spinhttp "github.com/spinframework/spin-go-sdk/v3/http" +) + +// Russia abolished DST in 2011, so Moscow is a constant UTC+3 offset and needs +// no tzdata lookup — which matters because the wasip2 sandbox has no +// /usr/share/zoneinfo to read. +var moscow = time.FixedZone("MSK", 3*60*60) + +type timeResponse struct { + Unix int64 `json:"unix"` + ISO string `json:"iso"` + HourMinute string `json:"hour_minute"` + Zone string `json:"zone"` +} + +func init() { + spinhttp.Handle(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.Header().Set("Allow", "GET") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + now := time.Now().In(moscow) + resp := timeResponse{ + Unix: now.Unix(), + ISO: now.Format(time.RFC3339), + HourMinute: now.Format("15:04"), + Zone: "Europe/Moscow (UTC+3)", + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(resp); err != nil { + http.Error(w, "encoding failed", http.StatusInternalServerError) + } + }) +} + +// main is required by the compiler but never executed — the entry point is the +// handler registered in init(). +func main() {} diff --git a/wasm/moscow-time/spin.toml b/wasm/moscow-time/spin.toml new file mode 100644 index 000000000..ae767e59a --- /dev/null +++ b/wasm/moscow-time/spin.toml @@ -0,0 +1,20 @@ +#:schema https://schemas.spinframework.dev/spin/manifest-v2/latest.json + +spin_manifest_version = 2 + +[application] +name = "moscow-time" +version = "0.1.0" +authors = ["Elvira <239804565+HNS2112@users.noreply.github.com>"] +description = "" + +[[trigger.http]] +route = "/time" +component = "moscow-time" + +[component.moscow-time] +source = "main.wasm" +allowed_outbound_hosts = [] +[component.moscow-time.build] +command = "go tool componentize-go build" +watch = ["**/*.go", "go.mod"] diff --git a/wasm/moscow-time/wit/deps/wasi-cli-0.2.0/package.wit b/wasm/moscow-time/wit/deps/wasi-cli-0.2.0/package.wit new file mode 100644 index 000000000..8deef32c8 --- /dev/null +++ b/wasm/moscow-time/wit/deps/wasi-cli-0.2.0/package.wit @@ -0,0 +1,20 @@ +package wasi:cli@0.2.0; + +interface stdout { + use wasi:io/streams@0.2.0.{output-stream}; + + get-stdout: func() -> output-stream; +} + +interface stderr { + use wasi:io/streams@0.2.0.{output-stream}; + + get-stderr: func() -> output-stream; +} + +interface stdin { + use wasi:io/streams@0.2.0.{input-stream}; + + get-stdin: func() -> input-stream; +} + diff --git a/wasm/moscow-time/wit/deps/wasi-clocks-0.2.0/package.wit b/wasm/moscow-time/wit/deps/wasi-clocks-0.2.0/package.wit new file mode 100644 index 000000000..9e0ba3dca --- /dev/null +++ b/wasm/moscow-time/wit/deps/wasi-clocks-0.2.0/package.wit @@ -0,0 +1,29 @@ +package wasi:clocks@0.2.0; + +interface monotonic-clock { + use wasi:io/poll@0.2.0.{pollable}; + + type instant = u64; + + type duration = u64; + + now: func() -> instant; + + resolution: func() -> duration; + + subscribe-instant: func(when: instant) -> pollable; + + subscribe-duration: func(when: duration) -> pollable; +} + +interface wall-clock { + record datetime { + seconds: u64, + nanoseconds: u32, + } + + now: func() -> datetime; + + resolution: func() -> datetime; +} + diff --git a/wasm/moscow-time/wit/deps/wasi-http-0.2.0/package.wit b/wasm/moscow-time/wit/deps/wasi-http-0.2.0/package.wit new file mode 100644 index 000000000..11f7ff44d --- /dev/null +++ b/wasm/moscow-time/wit/deps/wasi-http-0.2.0/package.wit @@ -0,0 +1,571 @@ +package wasi:http@0.2.0; + +/// This interface defines all of the types and methods for implementing +/// HTTP Requests and Responses, both incoming and outgoing, as well as +/// their headers, trailers, and bodies. +interface types { + use wasi:clocks/monotonic-clock@0.2.0.{duration}; + use wasi:io/streams@0.2.0.{input-stream, output-stream}; + use wasi:io/error@0.2.0.{error as io-error}; + use wasi:io/poll@0.2.0.{pollable}; + + /// This type corresponds to HTTP standard Methods. + variant method { + get, + head, + post, + put, + delete, + connect, + options, + trace, + patch, + other(string), + } + + /// This type corresponds to HTTP standard Related Schemes. + variant scheme { + HTTP, + HTTPS, + other(string), + } + + /// Defines the case payload type for `DNS-error` above: + record DNS-error-payload { + rcode: option, + info-code: option, + } + + /// Defines the case payload type for `TLS-alert-received` above: + record TLS-alert-received-payload { + alert-id: option, + alert-message: option, + } + + /// Defines the case payload type for `HTTP-response-{header,trailer}-size` above: + record field-size-payload { + field-name: option, + field-size: option, + } + + /// These cases are inspired by the IANA HTTP Proxy Error Types: + /// https://www.iana.org/assignments/http-proxy-status/http-proxy-status.xhtml#table-http-proxy-error-types + variant error-code { + DNS-timeout, + DNS-error(DNS-error-payload), + destination-not-found, + destination-unavailable, + destination-IP-prohibited, + destination-IP-unroutable, + connection-refused, + connection-terminated, + connection-timeout, + connection-read-timeout, + connection-write-timeout, + connection-limit-reached, + TLS-protocol-error, + TLS-certificate-error, + TLS-alert-received(TLS-alert-received-payload), + HTTP-request-denied, + HTTP-request-length-required, + HTTP-request-body-size(option), + HTTP-request-method-invalid, + HTTP-request-URI-invalid, + HTTP-request-URI-too-long, + HTTP-request-header-section-size(option), + HTTP-request-header-size(option), + HTTP-request-trailer-section-size(option), + HTTP-request-trailer-size(field-size-payload), + HTTP-response-incomplete, + HTTP-response-header-section-size(option), + HTTP-response-header-size(field-size-payload), + HTTP-response-body-size(option), + HTTP-response-trailer-section-size(option), + HTTP-response-trailer-size(field-size-payload), + HTTP-response-transfer-coding(option), + HTTP-response-content-coding(option), + HTTP-response-timeout, + HTTP-upgrade-failed, + HTTP-protocol-error, + loop-detected, + configuration-error, + /// This is a catch-all error for anything that doesn't fit cleanly into a + /// more specific case. It also includes an optional string for an + /// unstructured description of the error. Users should not depend on the + /// string for diagnosing errors, as it's not required to be consistent + /// between implementations. + internal-error(option), + } + + /// This type enumerates the different kinds of errors that may occur when + /// setting or appending to a `fields` resource. + variant header-error { + /// This error indicates that a `field-key` or `field-value` was + /// syntactically invalid when used with an operation that sets headers in a + /// `fields`. + invalid-syntax, + /// This error indicates that a forbidden `field-key` was used when trying + /// to set a header in a `fields`. + forbidden, + /// This error indicates that the operation on the `fields` was not + /// permitted because the fields are immutable. + immutable, + } + + /// Field keys are always strings. + type field-key = string; + + /// Field values should always be ASCII strings. However, in + /// reality, HTTP implementations often have to interpret malformed values, + /// so they are provided as a list of bytes. + type field-value = list; + + /// This following block defines the `fields` resource which corresponds to + /// HTTP standard Fields. Fields are a common representation used for both + /// Headers and Trailers. + /// + /// A `fields` may be mutable or immutable. A `fields` created using the + /// constructor, `from-list`, or `clone` will be mutable, but a `fields` + /// resource given by other means (including, but not limited to, + /// `incoming-request.headers`, `outgoing-request.headers`) might be be + /// immutable. In an immutable fields, the `set`, `append`, and `delete` + /// operations will fail with `header-error.immutable`. + resource fields { + /// Construct an empty HTTP Fields. + /// + /// The resulting `fields` is mutable. + constructor(); + /// Construct an HTTP Fields. + /// + /// The resulting `fields` is mutable. + /// + /// The list represents each key-value pair in the Fields. Keys + /// which have multiple values are represented by multiple entries in this + /// list with the same key. + /// + /// The tuple is a pair of the field key, represented as a string, and + /// Value, represented as a list of bytes. In a valid Fields, all keys + /// and values are valid UTF-8 strings. However, values are not always + /// well-formed, so they are represented as a raw list of bytes. + /// + /// An error result will be returned if any header or value was + /// syntactically invalid, or if a header was forbidden. + from-list: static func(entries: list>) -> result; + /// Get all of the values corresponding to a key. If the key is not present + /// in this `fields`, an empty list is returned. However, if the key is + /// present but empty, this is represented by a list with one or more + /// empty field-values present. + get: func(name: field-key) -> list; + /// Returns `true` when the key is present in this `fields`. If the key is + /// syntactically invalid, `false` is returned. + has: func(name: field-key) -> bool; + /// Set all of the values for a key. Clears any existing values for that + /// key, if they have been set. + /// + /// Fails with `header-error.immutable` if the `fields` are immutable. + set: func(name: field-key, value: list) -> result<_, header-error>; + /// Delete all values for a key. Does nothing if no values for the key + /// exist. + /// + /// Fails with `header-error.immutable` if the `fields` are immutable. + delete: func(name: field-key) -> result<_, header-error>; + /// Append a value for a key. Does not change or delete any existing + /// values for that key. + /// + /// Fails with `header-error.immutable` if the `fields` are immutable. + append: func(name: field-key, value: field-value) -> result<_, header-error>; + /// Retrieve the full set of keys and values in the Fields. Like the + /// constructor, the list represents each key-value pair. + /// + /// The outer list represents each key-value pair in the Fields. Keys + /// which have multiple values are represented by multiple entries in this + /// list with the same key. + entries: func() -> list>; + /// Make a deep copy of the Fields. Equivelant in behavior to calling the + /// `fields` constructor on the return value of `entries`. The resulting + /// `fields` is mutable. + clone: func() -> fields; + } + + /// Headers is an alias for Fields. + type headers = fields; + + /// Trailers is an alias for Fields. + type trailers = fields; + + /// Represents an incoming HTTP Request. + resource incoming-request { + /// Returns the method of the incoming request. + method: func() -> method; + /// Returns the path with query parameters from the request, as a string. + path-with-query: func() -> option; + /// Returns the protocol scheme from the request. + scheme: func() -> option; + /// Returns the authority from the request, if it was present. + authority: func() -> option; + /// Get the `headers` associated with the request. + /// + /// The returned `headers` resource is immutable: `set`, `append`, and + /// `delete` operations will fail with `header-error.immutable`. + /// + /// The `headers` returned are a child resource: it must be dropped before + /// the parent `incoming-request` is dropped. Dropping this + /// `incoming-request` before all children are dropped will trap. + headers: func() -> headers; + /// Gives the `incoming-body` associated with this request. Will only + /// return success at most once, and subsequent calls will return error. + consume: func() -> result; + } + + /// Represents an outgoing HTTP Request. + resource outgoing-request { + /// Construct a new `outgoing-request` with a default `method` of `GET`, and + /// `none` values for `path-with-query`, `scheme`, and `authority`. + /// + /// * `headers` is the HTTP Headers for the Request. + /// + /// It is possible to construct, or manipulate with the accessor functions + /// below, an `outgoing-request` with an invalid combination of `scheme` + /// and `authority`, or `headers` which are not permitted to be sent. + /// It is the obligation of the `outgoing-handler.handle` implementation + /// to reject invalid constructions of `outgoing-request`. + constructor(headers: headers); + /// Returns the resource corresponding to the outgoing Body for this + /// Request. + /// + /// Returns success on the first call: the `outgoing-body` resource for + /// this `outgoing-request` can be retrieved at most once. Subsequent + /// calls will return error. + body: func() -> result; + /// Get the Method for the Request. + method: func() -> method; + /// Set the Method for the Request. Fails if the string present in a + /// `method.other` argument is not a syntactically valid method. + set-method: func(method: method) -> result; + /// Get the combination of the HTTP Path and Query for the Request. + /// When `none`, this represents an empty Path and empty Query. + path-with-query: func() -> option; + /// Set the combination of the HTTP Path and Query for the Request. + /// When `none`, this represents an empty Path and empty Query. Fails is the + /// string given is not a syntactically valid path and query uri component. + set-path-with-query: func(path-with-query: option) -> result; + /// Get the HTTP Related Scheme for the Request. When `none`, the + /// implementation may choose an appropriate default scheme. + scheme: func() -> option; + /// Set the HTTP Related Scheme for the Request. When `none`, the + /// implementation may choose an appropriate default scheme. Fails if the + /// string given is not a syntactically valid uri scheme. + set-scheme: func(scheme: option) -> result; + /// Get the HTTP Authority for the Request. A value of `none` may be used + /// with Related Schemes which do not require an Authority. The HTTP and + /// HTTPS schemes always require an authority. + authority: func() -> option; + /// Set the HTTP Authority for the Request. A value of `none` may be used + /// with Related Schemes which do not require an Authority. The HTTP and + /// HTTPS schemes always require an authority. Fails if the string given is + /// not a syntactically valid uri authority. + set-authority: func(authority: option) -> result; + /// Get the headers associated with the Request. + /// + /// The returned `headers` resource is immutable: `set`, `append`, and + /// `delete` operations will fail with `header-error.immutable`. + /// + /// This headers resource is a child: it must be dropped before the parent + /// `outgoing-request` is dropped, or its ownership is transfered to + /// another component by e.g. `outgoing-handler.handle`. + headers: func() -> headers; + } + + /// Parameters for making an HTTP Request. Each of these parameters is + /// currently an optional timeout applicable to the transport layer of the + /// HTTP protocol. + /// + /// These timeouts are separate from any the user may use to bound a + /// blocking call to `wasi:io/poll.poll`. + resource request-options { + /// Construct a default `request-options` value. + constructor(); + /// The timeout for the initial connect to the HTTP Server. + connect-timeout: func() -> option; + /// Set the timeout for the initial connect to the HTTP Server. An error + /// return value indicates that this timeout is not supported. + set-connect-timeout: func(duration: option) -> result; + /// The timeout for receiving the first byte of the Response body. + first-byte-timeout: func() -> option; + /// Set the timeout for receiving the first byte of the Response body. An + /// error return value indicates that this timeout is not supported. + set-first-byte-timeout: func(duration: option) -> result; + /// The timeout for receiving subsequent chunks of bytes in the Response + /// body stream. + between-bytes-timeout: func() -> option; + /// Set the timeout for receiving subsequent chunks of bytes in the Response + /// body stream. An error return value indicates that this timeout is not + /// supported. + set-between-bytes-timeout: func(duration: option) -> result; + } + + /// Represents the ability to send an HTTP Response. + /// + /// This resource is used by the `wasi:http/incoming-handler` interface to + /// allow a Response to be sent corresponding to the Request provided as the + /// other argument to `incoming-handler.handle`. + resource response-outparam { + /// Set the value of the `response-outparam` to either send a response, + /// or indicate an error. + /// + /// This method consumes the `response-outparam` to ensure that it is + /// called at most once. If it is never called, the implementation + /// will respond with an error. + /// + /// The user may provide an `error` to `response` to allow the + /// implementation determine how to respond with an HTTP error response. + set: static func(param: response-outparam, response: result); + } + + /// This type corresponds to the HTTP standard Status Code. + type status-code = u16; + + /// Represents an incoming HTTP Response. + resource incoming-response { + /// Returns the status code from the incoming response. + status: func() -> status-code; + /// Returns the headers from the incoming response. + /// + /// The returned `headers` resource is immutable: `set`, `append`, and + /// `delete` operations will fail with `header-error.immutable`. + /// + /// This headers resource is a child: it must be dropped before the parent + /// `incoming-response` is dropped. + headers: func() -> headers; + /// Returns the incoming body. May be called at most once. Returns error + /// if called additional times. + consume: func() -> result; + } + + /// Represents an incoming HTTP Request or Response's Body. + /// + /// A body has both its contents - a stream of bytes - and a (possibly + /// empty) set of trailers, indicating that the full contents of the + /// body have been received. This resource represents the contents as + /// an `input-stream` and the delivery of trailers as a `future-trailers`, + /// and ensures that the user of this interface may only be consuming either + /// the body contents or waiting on trailers at any given time. + resource incoming-body { + /// Returns the contents of the body, as a stream of bytes. + /// + /// Returns success on first call: the stream representing the contents + /// can be retrieved at most once. Subsequent calls will return error. + /// + /// The returned `input-stream` resource is a child: it must be dropped + /// before the parent `incoming-body` is dropped, or consumed by + /// `incoming-body.finish`. + /// + /// This invariant ensures that the implementation can determine whether + /// the user is consuming the contents of the body, waiting on the + /// `future-trailers` to be ready, or neither. This allows for network + /// backpressure is to be applied when the user is consuming the body, + /// and for that backpressure to not inhibit delivery of the trailers if + /// the user does not read the entire body. + %stream: func() -> result; + /// Takes ownership of `incoming-body`, and returns a `future-trailers`. + /// This function will trap if the `input-stream` child is still alive. + finish: static func(this: incoming-body) -> future-trailers; + } + + /// Represents a future which may eventaully return trailers, or an error. + /// + /// In the case that the incoming HTTP Request or Response did not have any + /// trailers, this future will resolve to the empty set of trailers once the + /// complete Request or Response body has been received. + resource future-trailers { + /// Returns a pollable which becomes ready when either the trailers have + /// been received, or an error has occured. When this pollable is ready, + /// the `get` method will return `some`. + subscribe: func() -> pollable; + /// Returns the contents of the trailers, or an error which occured, + /// once the future is ready. + /// + /// The outer `option` represents future readiness. Users can wait on this + /// `option` to become `some` using the `subscribe` method. + /// + /// The outer `result` is used to retrieve the trailers or error at most + /// once. It will be success on the first call in which the outer option + /// is `some`, and error on subsequent calls. + /// + /// The inner `result` represents that either the HTTP Request or Response + /// body, as well as any trailers, were received successfully, or that an + /// error occured receiving them. The optional `trailers` indicates whether + /// or not trailers were present in the body. + /// + /// When some `trailers` are returned by this method, the `trailers` + /// resource is immutable, and a child. Use of the `set`, `append`, or + /// `delete` methods will return an error, and the resource must be + /// dropped before the parent `future-trailers` is dropped. + get: func() -> option, error-code>>>; + } + + /// Represents an outgoing HTTP Response. + resource outgoing-response { + /// Construct an `outgoing-response`, with a default `status-code` of `200`. + /// If a different `status-code` is needed, it must be set via the + /// `set-status-code` method. + /// + /// * `headers` is the HTTP Headers for the Response. + constructor(headers: headers); + /// Get the HTTP Status Code for the Response. + status-code: func() -> status-code; + /// Set the HTTP Status Code for the Response. Fails if the status-code + /// given is not a valid http status code. + set-status-code: func(status-code: status-code) -> result; + /// Get the headers associated with the Request. + /// + /// The returned `headers` resource is immutable: `set`, `append`, and + /// `delete` operations will fail with `header-error.immutable`. + /// + /// This headers resource is a child: it must be dropped before the parent + /// `outgoing-request` is dropped, or its ownership is transfered to + /// another component by e.g. `outgoing-handler.handle`. + headers: func() -> headers; + /// Returns the resource corresponding to the outgoing Body for this Response. + /// + /// Returns success on the first call: the `outgoing-body` resource for + /// this `outgoing-response` can be retrieved at most once. Subsequent + /// calls will return error. + body: func() -> result; + } + + /// Represents an outgoing HTTP Request or Response's Body. + /// + /// A body has both its contents - a stream of bytes - and a (possibly + /// empty) set of trailers, inducating the full contents of the body + /// have been sent. This resource represents the contents as an + /// `output-stream` child resource, and the completion of the body (with + /// optional trailers) with a static function that consumes the + /// `outgoing-body` resource, and ensures that the user of this interface + /// may not write to the body contents after the body has been finished. + /// + /// If the user code drops this resource, as opposed to calling the static + /// method `finish`, the implementation should treat the body as incomplete, + /// and that an error has occured. The implementation should propogate this + /// error to the HTTP protocol by whatever means it has available, + /// including: corrupting the body on the wire, aborting the associated + /// Request, or sending a late status code for the Response. + resource outgoing-body { + /// Returns a stream for writing the body contents. + /// + /// The returned `output-stream` is a child resource: it must be dropped + /// before the parent `outgoing-body` resource is dropped (or finished), + /// otherwise the `outgoing-body` drop or `finish` will trap. + /// + /// Returns success on the first call: the `output-stream` resource for + /// this `outgoing-body` may be retrieved at most once. Subsequent calls + /// will return error. + write: func() -> result; + /// Finalize an outgoing body, optionally providing trailers. This must be + /// called to signal that the response is complete. If the `outgoing-body` + /// is dropped without calling `outgoing-body.finalize`, the implementation + /// should treat the body as corrupted. + /// + /// Fails if the body's `outgoing-request` or `outgoing-response` was + /// constructed with a Content-Length header, and the contents written + /// to the body (via `write`) does not match the value given in the + /// Content-Length. + finish: static func(this: outgoing-body, trailers: option) -> result<_, error-code>; + } + + /// Represents a future which may eventaully return an incoming HTTP + /// Response, or an error. + /// + /// This resource is returned by the `wasi:http/outgoing-handler` interface to + /// provide the HTTP Response corresponding to the sent Request. + resource future-incoming-response { + /// Returns a pollable which becomes ready when either the Response has + /// been received, or an error has occured. When this pollable is ready, + /// the `get` method will return `some`. + subscribe: func() -> pollable; + /// Returns the incoming HTTP Response, or an error, once one is ready. + /// + /// The outer `option` represents future readiness. Users can wait on this + /// `option` to become `some` using the `subscribe` method. + /// + /// The outer `result` is used to retrieve the response or error at most + /// once. It will be success on the first call in which the outer option + /// is `some`, and error on subsequent calls. + /// + /// The inner `result` represents that either the incoming HTTP Response + /// status and headers have recieved successfully, or that an error + /// occured. Errors may also occur while consuming the response body, + /// but those will be reported by the `incoming-body` and its + /// `output-stream` child. + get: func() -> option>>; + } + + /// Attempts to extract a http-related `error` from the wasi:io `error` + /// provided. + /// + /// Stream operations which return + /// `wasi:io/stream/stream-error::last-operation-failed` have a payload of + /// type `wasi:io/error/error` with more information about the operation + /// that failed. This payload can be passed through to this function to see + /// if there's http-related information about the error to return. + /// + /// Note that this function is fallible because not all io-errors are + /// http-related errors. + http-error-code: func(err: borrow) -> option; +} + +/// This interface defines a handler of incoming HTTP Requests. It should +/// be exported by components which can respond to HTTP Requests. +interface incoming-handler { + use types.{incoming-request, response-outparam}; + + /// This function is invoked with an incoming HTTP Request, and a resource + /// `response-outparam` which provides the capability to reply with an HTTP + /// Response. The response is sent by calling the `response-outparam.set` + /// method, which allows execution to continue after the response has been + /// sent. This enables both streaming to the response body, and performing other + /// work. + /// + /// The implementor of this function must write a response to the + /// `response-outparam` before returning, or else the caller will respond + /// with an error on its behalf. + handle: func(request: incoming-request, response-out: response-outparam); +} + +/// This interface defines a handler of outgoing HTTP Requests. It should be +/// imported by components which wish to make HTTP Requests. +interface outgoing-handler { + use types.{outgoing-request, request-options, future-incoming-response, error-code}; + + /// This function is invoked with an outgoing HTTP Request, and it returns + /// a resource `future-incoming-response` which represents an HTTP Response + /// which may arrive in the future. + /// + /// The `options` argument accepts optional parameters for the HTTP + /// protocol's transport layer. + /// + /// This function may return an error if the `outgoing-request` is invalid + /// or not allowed to be made. Otherwise, protocol errors are reported + /// through the `future-incoming-response`. + handle: func(request: outgoing-request, options: option) -> result; +} + +/// The `wasi:http/proxy` world captures a widely-implementable intersection of +/// hosts that includes HTTP forward and reverse proxies. Components targeting +/// this world may concurrently stream in and out any number of incoming and +/// outgoing HTTP requests. +world proxy { + import wasi:random/random@0.2.0; + import wasi:io/error@0.2.0; + import wasi:io/poll@0.2.0; + import wasi:io/streams@0.2.0; + import wasi:cli/stdout@0.2.0; + import wasi:cli/stderr@0.2.0; + import wasi:cli/stdin@0.2.0; + import wasi:clocks/monotonic-clock@0.2.0; + import types; + import outgoing-handler; + import wasi:clocks/wall-clock@0.2.0; + + export incoming-handler; +} diff --git a/wasm/moscow-time/wit/deps/wasi-io-0.2.0/package.wit b/wasm/moscow-time/wit/deps/wasi-io-0.2.0/package.wit new file mode 100644 index 000000000..962734d42 --- /dev/null +++ b/wasm/moscow-time/wit/deps/wasi-io-0.2.0/package.wit @@ -0,0 +1,48 @@ +package wasi:io@0.2.0; + +interface poll { + resource pollable { + ready: func() -> bool; + block: func(); + } + + poll: func(in: list>) -> list; +} + +interface error { + resource error { + to-debug-string: func() -> string; + } +} + +interface streams { + use error.{error}; + use poll.{pollable}; + + variant stream-error { + last-operation-failed(error), + closed, + } + + resource input-stream { + read: func(len: u64) -> result, stream-error>; + blocking-read: func(len: u64) -> result, stream-error>; + skip: func(len: u64) -> result; + blocking-skip: func(len: u64) -> result; + subscribe: func() -> pollable; + } + + resource output-stream { + check-write: func() -> result; + write: func(contents: list) -> result<_, stream-error>; + blocking-write-and-flush: func(contents: list) -> result<_, stream-error>; + flush: func() -> result<_, stream-error>; + blocking-flush: func() -> result<_, stream-error>; + subscribe: func() -> pollable; + write-zeroes: func(len: u64) -> result<_, stream-error>; + blocking-write-zeroes-and-flush: func(len: u64) -> result<_, stream-error>; + splice: func(src: borrow, len: u64) -> result; + blocking-splice: func(src: borrow, len: u64) -> result; + } +} + diff --git a/wasm/moscow-time/wit/deps/wasi-random-0.2.0/package.wit b/wasm/moscow-time/wit/deps/wasi-random-0.2.0/package.wit new file mode 100644 index 000000000..413273153 --- /dev/null +++ b/wasm/moscow-time/wit/deps/wasi-random-0.2.0/package.wit @@ -0,0 +1,8 @@ +package wasi:random@0.2.0; + +interface random { + get-random-bytes: func(len: u64) -> list; + + get-random-u64: func() -> u64; +} + diff --git a/wasm/moscow-time/wit/world.wit b/wasm/moscow-time/wit/world.wit new file mode 100644 index 000000000..c92e2f9a2 --- /dev/null +++ b/wasm/moscow-time/wit/world.wit @@ -0,0 +1,6 @@ +// We actually don't use this; it's just to let bindgen! find the corresponding world in wit/deps. +package componentize-go:example; + +world wasip2-example { + include wasi:http/proxy@0.2.0; +}