Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 12 additions & 42 deletions js/tools/tsplugingen/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@ NPM ?= npm
NPX ?= npx
NODE ?= node

# sdk-range.sh and publish-check.sh run npm and node too.
# publish-check.sh runs npm and node too.
export NPM
export NODE

# One knob for four things: the OpenAPI branch, the generator branch, the
# dist-tag of the SDK to build against, and the dist-tag to publish under.
# One knob for three things: the OpenAPI branch, the generator branch, and the
# dist-tag to publish under.
CHANNEL ?= prod-staging
DIST_TAG = $(if $(filter prod-stable,$(CHANNEL)),latest,next)

Expand All @@ -32,27 +32,6 @@ OPENAPI_GEN_MOD = unikraft.com/x/tools/openapi-gen
OPENAPI_GEN_REVISION = $(eval OPENAPI_GEN_REVISION := $(shell $(GO) list -m -f '{{.Version}}' $(OPENAPI_GEN_MOD)@$(CHANNEL) 2>/dev/null))$(OPENAPI_GEN_REVISION)
OPENAPI_GEN ?= $(GO) run $(OPENAPI_GEN_MOD)@$(OPENAPI_GEN_REVISION)

# A leaf subpath, so the plugin package does not pull the SDK's idiomatic layer
# into its module graph. Needs @unikraft/cloud >=0.1.1, which exports it.
CLIENT_IMPORT ?= @unikraft/cloud/core/http

SDK_SPEC ?= @unikraft/cloud@$(DIST_TAG)

# `build` installs the SDK from $(BUILD), two directories below this one, so
# npm would look up a relative local spec from the wrong directory there. Only
# path- and tarball-shaped specs become absolute; a registry spec such as
# "@unikraft/cloud@next" has to reach npm unchanged.
SDK_SPEC_ABS = $(if $(filter /% ./% ../% %.tgz,$(SDK_SPEC)),$(abspath $(SDK_SPEC)),$(SDK_SPEC))

# Derives the range: "0.1.1-next.N" becomes ">=0.1.1-0 <0.2.0".
# sdk-range.sh explains why.
#
# The $(eval) memoizes the value, like OPENAPI_GEN_REVISION above. Each
# expansion would otherwise run the script again, and each run can ask the npm
# registry. One lookup also guarantees that package.json and configHash carry
# the same range when the registry moves mid-build.
SDK_RANGE ?= $(eval SDK_RANGE := $(shell ./sdk-range.sh $(SDK_SPEC_ABS)))$(SDK_RANGE)

PUBLISH_FLAGS ?=

# Every plugin under SPEC_ROOT that has a compiled spec.
Expand All @@ -70,13 +49,11 @@ VERSION = $(shell awk '/^info:/{f=1;next} /^[^[:space:]#]/{f=0} f&&/^[[:space:]]
SPEC_HASH = $(shell $(SHA256) "$(SPEC)" 2>/dev/null | cut -c1-64)
TEMPLATES_HASH = $(shell $(SHA256) $(sort $(wildcard $(TEMPLATES)/* $(STATIC)/*)) 2>/dev/null | $(SHA256) | cut -c1-64)

# The spec and the templates are not the only inputs. The peer range, the
# client import and the generator decide the output as well, and none of them
# leaves a mark in any file the two hashes above cover. Without this, staging
# opening a new patch (0.1.1-next.N -> 0.1.2-next.0) rewrites every peer range
# while publish still reports the published version as built from these
# sources.
CONFIG_HASH = $(shell printf '%s\n%s\n%s\n' "$(SDK_RANGE)" "$(CLIENT_IMPORT)" "$(OPENAPI_GEN)" | $(SHA256) | cut -c1-64)
# The spec and the templates are not the only inputs. The generator decides
# the output as well, and it leaves no mark in any file the two hashes above
# cover. Without this, a new generator revision ships in no package: publish
# still reports the published version as built from these sources.
CONFIG_HASH = $(shell printf '%s\n' "$(OPENAPI_GEN)" | $(SHA256) | cut -c1-64)

.PHONY: all list clean generate build publish publish-all config check-plugins

Expand Down Expand Up @@ -105,9 +82,6 @@ config:
@echo "dist-tag: $(DIST_TAG)"
@echo "spec root: $(SPEC_ROOT)"
@echo "plugins: $(PLUGINS)"
@echo "client import: $(CLIENT_IMPORT)"
@echo "sdk spec: $(SDK_SPEC_ABS)"
@echo "sdk range: $(SDK_RANGE)"
@echo "generator: $(OPENAPI_GEN)"
@echo "templates: $(TEMPLATES_HASH)"
@echo "config: $(CONFIG_HASH)"
Expand All @@ -122,7 +96,6 @@ generate:
@test -f "$(SPEC)" || { echo "error: no spec at $(SPEC) (compile it with 'npx tsp compile $(PLUGIN)/api.tsp')"; exit 1; }
@test -n "$(VERSION)" || { echo "error: no semver info.version in $(SPEC)"; exit 1; }
@$(if $(filter file,$(origin OPENAPI_GEN)),test -n "$(OPENAPI_GEN_REVISION)" || { echo "error: could not resolve $(OPENAPI_GEN_MOD)@$(CHANNEL) to a revision with '$(GO) list -m'"; exit 1; })
@test -n "$(SDK_RANGE)" || { echo "error: could not resolve a version from SDK_SPEC=$(SDK_SPEC), so the @unikraft/cloud peer range is unknown; set SDK_RANGE explicitly"; exit 1; }
@test -n "$(SPEC_HASH)" || { echo "error: could not hash $(SPEC) with '$(SHA256)'"; exit 1; }
@test -n "$(TEMPLATES_HASH)" || { echo "error: could not hash $(TEMPLATES)/ and $(STATIC)/ with '$(SHA256)'"; exit 1; }
@test -n "$(CONFIG_HASH)" || { echo "error: could not hash the generation config with '$(SHA256)'"; exit 1; }
Expand All @@ -135,21 +108,18 @@ generate:
-v "package=api" \
-v "pluginName=$(PLUGIN)" \
-v "version=$(VERSION)" \
-v "sdkVersion=$(SDK_RANGE)" \
-v "clientImport=$(CLIENT_IMPORT)" \
-v "specHash=$(SPEC_HASH)" \
-v "templatesHash=$(TEMPLATES_HASH)" \
-v "configHash=$(CONFIG_HASH)"
cp -R $(STATIC)/. "$(BUILD)/"
@echo "generated @unikraft/cloud-plugin-$(PLUGIN)-api@$(VERSION) (peer @unikraft/cloud $(SDK_RANGE)) -> $(BUILD)"
@echo "generated @unikraft/cloud-plugin-$(PLUGIN)-api@$(VERSION) -> $(BUILD)"

## Generate and compile one plugin into an ESM package.
#
# The SDK goes on the install line positionally, which satisfies the
# peerDependency from a local tarball or path, so npm never queries the registry
# for the peer while the SDK is unpublished.
# The package has no runtime dependencies: the Transport contract is one of its
# own files, so the install brings in the compiler and the formatter only.
build: generate
cd "$(BUILD)" && $(NPM) install --no-save --no-audit --no-fund "$(SDK_SPEC_ABS)"
cd "$(BUILD)" && $(NPM) install --no-save --no-audit --no-fund
-cd "$(BUILD)" && $(NPX) --no-install @biomejs/biome format --write src
cd "$(BUILD)" && $(NPM) run build
@echo "built $(BUILD)/dist"
Expand Down
102 changes: 34 additions & 68 deletions js/tools/tsplugingen/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,9 @@ registry.
- **Input:** `<PLUGIN>/openapi.yaml` in the plugins repository.
- **Generator:** [`openapi-gen`](https://github.com/unikraft-cloud/x/tree/prod-staging/tools/openapi-gen)
with the templates in [`templates/`](templates). These templates are the
platform SDK's own templates, changed to use an external transport.
- **Peer dependency:** `@unikraft/cloud`. The generated classes extend its
`ApiClient`.

> **Needs `@unikraft/cloud` >= 0.1.1.** `CLIENT_IMPORT` defaults to the
> `@unikraft/cloud/core/http` subpath, which
> [js-sdk#26](https://github.com/unikraft-cloud/js-sdk/pull/26) adds. Until
> that lands and publishes, `build` cannot resolve the peer and npm fails with
> `notarget`. Point `SDK_SPEC` at a local js-sdk checkout or tarball to build
> before then.
platform SDK's own templates, changed to take an external transport.
- **Dependencies:** none. The generated classes take a `Transport`, and the
contract for it ships inside the package. See below.

## Generated code only

Expand All @@ -40,7 +33,29 @@ package and wraps it. For the same reason, this package does not derive the base
URL from the specification. `servers` in `api.tsp` hardcodes the `sandbox`
segment, but a plugin answers under the name that you attach it with. The SDK
builds the URL in `src/core/plugin.ts` instead, and this package takes a
finished `baseUrl`.
finished transport.

## The transport contract

Every generated class takes one argument, a `Transport`: an object with
`request()`, `bytes()` and `stream()`. The SDK's `ApiClient` in
`@unikraft/cloud/core/http` is one. The type is structural, so any object with
these three methods is one too.

The contract is `templates/transport.ts.tmpl`, which `generate` writes to
`src/transport.ts` in the package. It is the only copy of the interface. The
package therefore needs no dependency on `@unikraft/cloud`, and a caller cannot
get two copies of the SDK in one tree.

js-sdk holds itself to the contract. `ApiClient` satisfies the interface by
shape, a comment on the class points here, and the js-sdk test suite checks
`ApiClient` against the published plugin package. The header of the file names
the changes that are safe (a new optional parameter, a wider input type) and
the ones that break every plugin package (a rename, a new required parameter, a
changed return type). A breaking change needs the same change in `ApiClient`
and every plugin package republished in the same cycle. Because the file is a
template, `templatesHash` covers it, so a changed contract makes `publish`
refuse to skip a plugin that npm holds under the old one.

## The specifications are not in the repository

Expand All @@ -59,7 +74,7 @@ already flattens its shared schema names with `@@friendlyName`.
## Usage

```sh
make config # show the resolved channel, plugins and peer range
make config # show the resolved channel, plugins and generator
make list # list the plugins that have a compiled specification
make plugin-sandbox # generate and build one plugin
make all # every plugin
Expand All @@ -70,53 +85,6 @@ The build writes its output to `.build/<PLUGIN>/dist`, which git ignores.

The generated package builds with TypeScript 7 and formats with Biome 2.

## Do not hard-code the peer range

The Makefile computes `SDK_RANGE` from the `@unikraft/cloud` version that the
channel resolves to. Do not replace it with a constant such as `^0.1.0`.

npm's semver excludes a prerelease from a range unless some comparator carries a
prerelease tag on the same `major.minor.patch`. js-sdk publishes its staging
channel as `0.1.1-next.N`. Neither `^0.1.0` nor `>=0.1.0` matches that version.

npm does not report the mismatch as a conflict. npm installs a **second copy**
of `@unikraft/cloud` under this package instead. The tree then holds two
`ApiClient` classes and two `UnikraftCloudError` classes. Every `instanceof`
check that a caller makes against the SDK's error type returns `false`. The
install prints no warning.

On a prerelease channel, the Makefile adds a `-0` lower bound, so the range
matches:

| SDK version | Derived range |
| -------------- | ------------------ |
| `0.0.3` | `>=0.0.3 <0.0.4` |
| `0.1.0` | `>=0.1.0 <0.2.0` |
| `0.1.1-next.0` | `>=0.1.1-0 <0.2.0` |
| `1.2.3` | `>=1.2.3 <2.0.0` |

### A prerelease range expires at the next stable release

npm ties the prerelease exemption to one exact `major.minor.patch`, so a
derived range covers a whole staging cycle and then stops:

| SDK version | `>=0.1.1-0 <0.2.0` |
| -------------- | ------------------ |
| `0.1.1-next.0` | matches |
| `0.1.1-next.9` | matches |
| `0.1.1` | matches |
| `0.1.2` | matches |
| `0.1.2-next.0` | **no match** |

The range survives every `next.N` bump. It expires on one event: staging opens
the next patch, which happens when stable ships `0.1.1`. A
plugin published before that point would quietly get a second `@unikraft/cloud`
nested under it.

`configHash` below is what catches this. The peer range is part of the
published fingerprint, so at that boundary `publish` fails instead of skipping,
and the plugin has to be regenerated and republished against the new range.

## A publish checks the sources that it came from

The package version is the OpenAPI specification's `info.version`, and nothing forces
Expand All @@ -140,12 +108,12 @@ the hashes into the package:
| --------------- | ----------------------------------------------------- |
| `specHash` | `<PLUGIN>/openapi.yaml` |
| `templatesHash` | `templates/` and `static/` |
| `configHash` | `SDK_RANGE`, `CLIENT_IMPORT` and `OPENAPI_GEN` |
| `configHash` | `OPENAPI_GEN` |

The specification is not the only thing that moves. A template fix changes the
generated code while the specification stands still, and so does a new peer
range, a different client import, or a new generator revision. None of these
leave a mark in a file that the first two hashes cover.
generated code while the specification stands still, and so does a new
generator revision. Neither leaves a mark in a file that the first hash
covers.

By default, `OPENAPI_GEN` names the exact revision that the `CHANNEL` branch
resolves to, so a new commit on that branch changes `configHash`. An
Expand Down Expand Up @@ -174,11 +142,8 @@ report success.
| Variable | Default | Description |
| --------------- | ------------------------------------------------------ | --------------------------------------------------------------------- |
| `SPEC_ROOT` | `../../../../plugins` | Root that holds `<PLUGIN>/openapi.yaml`. |
| `CHANNEL` | `prod-staging` | Release channel. Sets the generator branch, the dist-tag and the SDK. |
| `CHANNEL` | `prod-staging` | Release channel. Sets the generator branch and the dist-tag. |
| `OPENAPI_GEN` | `go run unikraft.com/x/tools/openapi-gen@<revision>` | The generator command. `<revision>` is the commit that `CHANNEL` resolves to. |
| `CLIENT_IMPORT` | `@unikraft/cloud/core/http` | Where the generated classes import `ApiClient` from. |
| `SDK_SPEC` | `@unikraft/cloud@<dist-tag>` | npm package specifier for the peer. A tarball or a path also works, and is resolved relative to this directory. |
| `SDK_RANGE` | _derived from_ `SDK_SPEC` | The peer range that `generate` writes into `package.json`. See above. |
| `BUILD_ROOT` | `.build` | Directory that `generate` writes each plugin into. |
| `PUBLISH_FLAGS` | _(empty)_ | Extra `npm publish` flags. CI passes `--provenance`. |

Expand All @@ -189,5 +154,6 @@ report success.
| `models.ts.tmpl` | `src/api/models.gen.ts` | request and response models, enums |
| `resources.tmpl` | `src/api/*.gen.ts`, `index.gen.ts` | one `…Api` class per tag, and a barrel |
| `index.ts.tmpl` | `src/index.ts` | the container class that groups them |
| `package.json.tmpl` | `package.json` | manifest, exports, peer range |
| `transport.ts.tmpl` | `src/transport.ts` | the `Transport` contract, verbatim |
| `package.json.tmpl` | `package.json` | manifest and exports |
| `README.md.tmpl` | `README.md` | the per-package readme |
2 changes: 1 addition & 1 deletion js/tools/tsplugingen/publish-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ echo "error: $name@$ver is on npm, but it came from different sources." >&2
[ "$was_tmpl" = "$tmpl_hash" ] ||
echo " The generator templates or the static build config changed." >&2
[ "$was_cfg" = "$cfg_hash" ] ||
echo " The peer range, the client import or the generator changed." >&2
echo " The generator changed." >&2
echo " A new version must ship the change." >&2
echo " on npm: spec=$was_spec templates=$was_tmpl config=$was_cfg" >&2
echo " current: spec=$spec_hash templates=$tmpl_hash config=$cfg_hash" >&2
Expand Down
67 changes: 0 additions & 67 deletions js/tools/tsplugingen/sdk-range.sh

This file was deleted.

2 changes: 1 addition & 1 deletion js/tools/tsplugingen/static/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"compilerOptions": {
"module": "node20",
"target": "ES2022",
"lib": ["ES2024", "ESNext.Array", "ESNext.Collection", "ESNext.Iterator"],
"lib": ["ES2024", "ESNext.Array", "ESNext.Collection", "ESNext.Iterator", "DOM"],

"types": [],

Expand Down
21 changes: 13 additions & 8 deletions js/tools/tsplugingen/templates/README.md.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -17,24 +17,29 @@ this package and creates the instance for you.
## Install

```sh
npm install @unikraft/cloud-plugin-{{ $plugin }}-api @unikraft/cloud
npm install @unikraft/cloud-plugin-{{ $plugin }}-api
```

`@unikraft/cloud` is a peer dependency, because the generated classes extend
its `ApiClient` transport.
The package has no dependencies. Each generated class takes a `Transport`, an
object with `request()`, `bytes()` and `stream()`. The `ApiClient` of
`@unikraft/cloud` is one. If you want to use it as the transport, install the
SDK.

## Usage

The plugin runs inside a single instance and answers on that instance's plugin
route, so `baseUrl` is the full plugin endpoint rather than a metro root.

```ts
import { ApiClient } from "@unikraft/cloud/core/http";
import { {{ $class }} } from "@unikraft/cloud-plugin-{{ $plugin }}-api";

const api = new {{ $class }}({
baseUrl: `https://api.fra.unikraft.cloud/v1/instances/${uuid}/plugins/{{ $plugin }}`,
token: process.env.UKC_TOKEN,
});
const api = new {{ $class }}(
new ApiClient({
baseUrl: `https://api.fra.unikraft.cloud/v1/instances/${uuid}/plugins/{{ $plugin }}`,
token: process.env.UKC_TOKEN,
}),
);
```

The last path segment is whatever name the plugin was attached under in the
Expand All @@ -45,7 +50,7 @@ SDK build that URL for you, use `@unikraft/cloud` instead.

| Import | Carries |
| --- | --- |
| `@unikraft/cloud-plugin-{{ $plugin }}-api` | `{{ $class }}`{{ range $tags }}, `{{ pascalcase . }}Api`{{ end }}, `models` |
| `@unikraft/cloud-plugin-{{ $plugin }}-api` | `{{ $class }}`{{ range $tags }}, `{{ pascalcase . }}Api`{{ end }}, `models`, and the types `Transport`, `RequestArgs`, `CallOptions` |
| `@unikraft/cloud-plugin-{{ $plugin }}-api/models` | The wire types |

`{{ $class }}` groups one client per resource:
Expand Down
Loading