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
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ tidy. `api/` and `sdk/` carry their own `go.mod` and their own Apache-2.0
before the split a client importing the SDK had its licence scanner report
Elastic-2.0 on their own build.

They are tagged separately — `v1.0.5`, `api/v1.0.5`, `sdk/v1.0.5` — and released
They are tagged separately — `v1.1.0`, `api/v1.1.0`, `sdk/v1.1.0` — and released
in lockstep, so that one version number means one thing. A change to the wire
contract therefore touches a module whose version is a promise to people outside
this repository.
Expand Down Expand Up @@ -160,6 +160,7 @@ go test ./... # Standard tests
- **S3 mode** — with `registry.s3` configured, `plugins_dir` is a local cache: archives are pushed out-of-band (`plugins push`), then downloaded on demand (singleflight-deduplicated), sha256-verified and unpacked before execution; **push must precede register** or `CreatePlugin` fails with `FAILED_PRECONDITION`; S3 outage surfaces as `UNAVAILABLE`, not `NOT_FOUND`
- **Pipeline order** — `build` → `push` → `register` (`task run` / `task setup` chain them); `--force` re-push of a registered plugin invalidates its recorded checksum, so re-register it
- **Entrypoint is always named `plugin`** — build output and migrate scan require `plugins/{group}/{name}/{version}/plugin`; Dockerfiles must `COPY` the entrypoint to `/plugin` (sidecars optional)
- **A plugin runs outside its image** — the service unpacks the image filesystem and execs `plugin` as a plain process on its own base (`debian:trixie-slim`). Wrappers resolve paths from `DIR=${0%/*}`, never from `/`; absolute symlinks are skipped on unpack. `plugins build` proves it per version: it relativises in-bundle absolute links, round-trips the archive through `plugarchive`, and smoke-runs `plugin` in the runtime image as uid 65532 with an empty env — a failure leaves only `build.log`. `TestSmokeImageMatchesServiceBase` keeps the smoke image equal to the Dockerfile's runtime stage
- **`easyp-svc plugins build <registry-path>`** — uses Docker multi-stage builds to extract image filesystems to the output directory (`plugins/` by default); supports `--filter`, `--parallel`, `--force`, `--dry-run`
- **`easyp-svc plugins push [path]`** — packs each version directory into `plugin.tgz` and uploads it to S3; `--cfg` supplies `registry.s3`, flags override it; `--filter`, `--force`, `--dry-run`
- **`easyp-svc plugins register [path]`** — registers built plugins via gRPC `CreatePlugin` (default path `plugins`); `--cfg` supplies `registry.plugins_dir` as the command prefix, and a config that omits the key resolves to its declared default, as the service does; `--dry-run`, `--fail-on-error` (default true). It sends `--parallel` 8 at once and backs off on the rate and concurrency limits — the SDK deliberately does not retry `ResourceExhausted` — but reports the licence tier's plugin cap at once. Every config under `deploy/` sets `rate_limit.max_concurrent_per_ip: 10` for it; against a server left at the default of 2, pass `--parallel 2`
Expand Down
14 changes: 11 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,17 @@ RUN --mount=type=cache,target=/go/pkg/mod \

# No --platform here on purpose: this stage is the image being shipped, so it
# has to be the target's. Only the apt step is emulated, which is seconds.
FROM debian:bookworm-slim

# upgrade as well as install: bookworm-slim is cut at a point in time and its
#
# trixie, not bookworm, for the plugins rather than for the service: the binary
# above is static and does not care. Plugins are dynamically linked and run on
# this image's glibc, and protoc's own plugins (cpp, python, java, ruby…) are
# built against 2.38; bookworm ships 2.36, so 15 catalogue plugins failed with
# "GLIBC_2.38 not found". glibc is backward compatible, so everything that ran on
# bookworm runs here unchanged — checked plugin by plugin across the catalogue.
# `plugins build` smoke-tests against this same base; keep the two in step.
FROM debian:trixie-slim

# upgrade as well as install: trixie-slim is cut at a point in time and its
# libraries carry whatever advisories were open then. The release scan refuses
# HIGH and CRITICAL findings in the OS layer, and it is right to — but a pinned
# base means the same image is refused a week later for a CVE nobody here
Expand Down
39 changes: 36 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Every release publishes an image, a Helm chart and the binaries. Pick one.

| Tag | Meaning |
|-----|---------|
| `v1.0.5` | a release; immutable |
| `v1.1.0` | a release; immutable |
| `latest` | the newest release — only moves on a release tag |
| `edge` | the tip of `master`; moves on every push |
| `sha-<short>` | one commit; immutable |
Expand All @@ -38,7 +38,7 @@ kubectl create secret generic easyp-env \
--from-literal=DB_POSTGRES_DSN='postgres://user:pass@host:5432/easyp?sslmode=require'

helm install easyp oci://ghcr.io/easyp-tech/charts/easyp-service \
--version 1.0.5 \
--version 1.1.0 \
--set secrets.existingSecret=easyp-env \
--set tls.enabled=false
```
Expand Down Expand Up @@ -628,6 +628,22 @@ every build), `dockerfile` (a different file), `args` (arguments the entrypoint
is run with); a version may be a mapping that overrides any of those for itself
or sets `skip: true`.

Each built version is then checked the way the service will run it, and a
version that fails is emptied down to its `build.log` so that nothing ships it:

1. absolute symlinks that point at a file the bundle carries are rewritten as
relative ones, the rest removed — the service would skip them;
2. the bundle is packed and unpacked with the service's own unpacker;
3. `plugin` is run in the service's base image (`--runtime-image`, default
`debian:trixie-slim`) as uid 65532, with an empty environment, on a small
synthetic request. Any `CodeGeneratorResponse` passes, even one carrying an
error.

A plugin that will not answer without options takes them from a `smoke` block in
`plugin.yaml` — `smoke: {parameter: "extern_path=.=crate::proto"}` — and
`smoke: {skip: true}` turns the run off for one that cannot be exercised that
way. `--no-smoke` skips step 3 for a whole build; steps 1 and 2 always run.

Filters are globs on `group/name`, optionally with a version:
`--filter 'protocolbuffers/*'`, `--filter 'grpc/go:v1.6.2'`. `--parallel` sets
how many build at once, `--force` rebuilds what is already in `plugins/`, and
Expand Down Expand Up @@ -667,6 +683,23 @@ What the service needs from the result:
writes a `CodeGeneratorResponse` on stdout, and exits non-zero on failure.
Sidecars next to it are fine — a jar, a `node_modules`, a shared library —
and are packed with it.
- **Nothing outside the bundle.** The service does not run the image: it
unpacks its filesystem into `plugins/{group}/{name}/{version}/` and runs
`plugin` there as a plain process on its own base image. A wrapper script
therefore resolves every path from its own location, never from `/`:

```sh
#!/bin/sh
DIR=${0%/*}
exec "$DIR/nodejs/bin/node" "$DIR/app/protoc-gen-es.js" "$@"
```

The JVM recipes find their JRE with
`for JAVA in "$DIR"/usr/lib/jvm/*/bin/java; do exec "$JAVA" -jar …; done`, and
the Python ones run under the bundle's own dynamic loader so the interpreter
never mixes its libraries with the service's — `community/nanopb` for glibc,
`community/danielgtaylor-betterproto` for musl. A plugin may write to its
working directory; the service gives each run a fresh one.
- **`ARG VERSION` selects what to build.** The Dockerfile is one recipe for
every version in `plugin.yaml`; a version that needs a different recipe gets
its own `dockerfile:` entry.
Expand Down Expand Up @@ -979,7 +1012,7 @@ A release tag produces, in this order:
To verify an image before running it:

```bash
cosign verify ghcr.io/easyp-tech/service:v1.0.5 \
cosign verify ghcr.io/easyp-tech/service:v1.1.0 \
--certificate-identity-regexp '^https://github.com/easyp-tech/service/\.github/workflows/release\.yml@refs/tags/v' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
```
Expand Down
23 changes: 19 additions & 4 deletions cmd/easyp-svc/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ type pluginConfig struct {
Dockerfile string `yaml:"dockerfile"`
Args []string `yaml:"args"`
Versions []versionEntry `yaml:"versions"`
Smoke smokeConfig `yaml:"smoke"`
}

// versionEntry accepts both scalar (`- v1.2.3`) and mapping forms. The mapping
Expand Down Expand Up @@ -119,6 +120,7 @@ type buildJob struct {
args []string
pluginDir string
outputDir string
smoke smokeConfig
}

func (j buildJob) key() string {
Expand All @@ -144,6 +146,7 @@ func runPluginsBuild(
dryRun bool,
nonInteractive bool,
keepGoing bool,
verify verifyOptions,
) error {
err := validateRegistryDir(registryPath)
if err != nil {
Expand Down Expand Up @@ -178,7 +181,7 @@ func runPluginsBuild(
tracker := newBuildTracker(len(toBuild), interactive)

stop := startTicker(tracker)
executeBuilds(ctx, toBuild, parallel, keepGoing, tracker)
executeBuilds(ctx, toBuild, parallel, keepGoing, verify, tracker)
stop()

printBuildSummary(tracker, total, cached, skipped)
Expand Down Expand Up @@ -320,6 +323,7 @@ func jobsFromConfig(
args: mergeArgs(cfg.Args, ver.args),
pluginDir: pluginDir,
outputDir: filepath.Join(outputDir, group, name, ver.version),
smoke: cfg.Smoke,
})
}

Expand Down Expand Up @@ -397,21 +401,23 @@ func checkDocker(ctx context.Context) error {
return nil
}

func executeBuilds(ctx context.Context, jobs []buildJob, parallel int, keepGoing bool, tracker *buildTracker) {
func executeBuilds(
ctx context.Context, jobs []buildJob, parallel int, keepGoing bool, verify verifyOptions, tracker *buildTracker,
) {
group, ctx := errgroup.WithContext(ctx)
group.SetLimit(parallel)

for _, j := range jobs {
job := j
group.Go(func() error {
return buildOne(ctx, job, keepGoing, tracker)
return buildOne(ctx, job, keepGoing, verify, tracker)
})
}

_ = group.Wait()
}

func buildOne(ctx context.Context, job buildJob, keepGoing bool, tracker *buildTracker) error {
func buildOne(ctx context.Context, job buildJob, keepGoing bool, verify verifyOptions, tracker *buildTracker) error {
start := time.Now()

err := os.MkdirAll(job.outputDir, dirPermissions)
Expand Down Expand Up @@ -440,6 +446,15 @@ func buildOne(ctx context.Context, job buildJob, keepGoing bool, tracker *buildT
return keepOrFail(keepGoing, fmt.Errorf("build %s: %w", job.key(), normErr))
}

smokeOut, verifyErr := verifyBundle(ctx, job, verify)
if verifyErr != nil {
discardErr := discardFailedBuild(job)
writeBuildLog(job, append(out, smokeOut...), errors.Join(verifyErr, discardErr))
tracker.finish(job.key(), false, verifyErr.Error(), time.Since(start).Round(time.Second))

return keepOrFail(keepGoing, fmt.Errorf("build %s: %w", job.key(), verifyErr))
}

// A log left over from an earlier failure would otherwise outlive the
// successful rebuild and misreport this version as broken.
_ = os.Remove(filepath.Join(job.outputDir, buildLogName))
Expand Down
16 changes: 16 additions & 0 deletions cmd/easyp-svc/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,10 @@ func getPluginsBuildCommand() *cli.Command {
cmd.Bool(flagDryRun),
cmd.Bool(flagNonInteractive),
cmd.Bool("keep-going"),
verifyOptions{
runtimeImage: cmd.String("runtime-image"),
smoke: !cmd.Bool("no-smoke"),
},
)
},
}
Expand Down Expand Up @@ -628,5 +632,17 @@ func buildFlags() []cli.Flag {
Usage: "continue building remaining plugins after a failure",
Value: true,
},
&cli.StringFlag{
Name: "runtime-image",
Usage: "image each built plugin is smoke-tested in; must match the base of the " +
"service image the plugins will run under",
Value: defaultRuntimeImage,
},
&cli.BoolFlag{
Name: "no-smoke",
Usage: "skip running each built plugin in the runtime image; the archive is still " +
"checked to unpack the way the service unpacks it",
Value: false,
},
}
}
Loading
Loading