diff --git a/AGENTS.md b/AGENTS.md index e4a8260..8656ed3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. @@ -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 `** — 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` diff --git a/Dockerfile b/Dockerfile index 35be479..f431f1e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 2ad9f39..68fd1dc 100644 --- a/README.md +++ b/README.md @@ -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-` | one commit; immutable | @@ -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 ``` @@ -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 @@ -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. @@ -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 ``` diff --git a/cmd/easyp-svc/build.go b/cmd/easyp-svc/build.go index 6851707..4a840ac 100644 --- a/cmd/easyp-svc/build.go +++ b/cmd/easyp-svc/build.go @@ -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 @@ -119,6 +120,7 @@ type buildJob struct { args []string pluginDir string outputDir string + smoke smokeConfig } func (j buildJob) key() string { @@ -144,6 +146,7 @@ func runPluginsBuild( dryRun bool, nonInteractive bool, keepGoing bool, + verify verifyOptions, ) error { err := validateRegistryDir(registryPath) if err != nil { @@ -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) @@ -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, }) } @@ -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) @@ -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)) diff --git a/cmd/easyp-svc/main.go b/cmd/easyp-svc/main.go index 7cac289..07137e3 100644 --- a/cmd/easyp-svc/main.go +++ b/cmd/easyp-svc/main.go @@ -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"), + }, ) }, } @@ -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, + }, } } diff --git a/cmd/easyp-svc/verify.go b/cmd/easyp-svc/verify.go new file mode 100644 index 0000000..8d18453 --- /dev/null +++ b/cmd/easyp-svc/verify.go @@ -0,0 +1,332 @@ +package main + +import ( + "bytes" + "context" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/descriptorpb" + "google.golang.org/protobuf/types/pluginpb" + + "github.com/easyp-tech/service/internal/plugarchive" +) + +// defaultRuntimeImage is the image a built plugin is smoke-tested in. It has to +// be the base of the service's own runtime stage: a plugin is a dynamically +// linked process that runs on that image's libraries, and a smoke test anywhere +// else proves nothing about the service. TestSmokeImageMatchesServiceBase keeps +// the two in step. +const defaultRuntimeImage = "debian:trixie-slim" + +const ( + // smokeTimeout bounds one smoke run. JVM plugins start in a second or two; + // the image pull on first use is what takes time, and it happens once. + smokeTimeout = 3 * time.Minute + + // smokeUser is the service image's user. A plugin that only works as root + // does not work in the service. + smokeUser = "65532:65532" + + // stderrTail bounds how much of a failing plugin's stderr reaches the build + // log line; the full output is in build.log. + stderrTail = 600 +) + +// ErrSmokeFailed marks a plugin that built but does not run the way the +// service runs it. +var ErrSmokeFailed = errors.New("plugin does not run in the service runtime") + +// smokeConfig is the optional `smoke` block of a plugin.yaml. +// +// Parameter is passed as CodeGeneratorRequest.parameter, for plugins that +// refuse to run without options. Skip turns the run off for a plugin that +// cannot be exercised with a synthetic request; it is meant to carry a comment +// saying why, since it removes the only check that the archive works. +type smokeConfig struct { + Parameter string `yaml:"parameter"` + Skip bool `yaml:"skip"` +} + +// smokeRunner runs an unpacked bundle the way the service would and reports +// whether it produced a CodeGeneratorResponse. A variable so tests can verify +// what happens around a failure without a Docker daemon. +type smokeRunner func(ctx context.Context, image, bundleDir, parameter string) ([]byte, error) + +// verifyOptions controls the checks run on every freshly built version. +type verifyOptions struct { + runtimeImage string + smoke bool + run smokeRunner +} + +// verifyBundle makes a built version directory what the service can use, or +// says why it cannot be. +// +// Three steps, each one a way a catalogue plugin was found broken in the field: +// absolute symlinks are made relative (the service skips absolute ones, and a +// Debian JRE needs its /etc/java-17-openjdk links to keep working); the bundle +// is packed and unpacked with the code the service uses, so an archive the +// service would refuse never leaves this machine; and the entrypoint is run in +// the service's base image with an empty environment, as the service's user, +// with a private working directory — which is what caught 22 plugins whose +// wrapper exec'd a path that only existed inside their build image. +func verifyBundle(ctx context.Context, job buildJob, opts verifyOptions) ([]byte, error) { + err := relativizeSymlinks(job.outputDir) + if err != nil { + return nil, fmt.Errorf("normalising symlinks: %w", err) + } + + unpacked, cleanup, err := roundTrip(job.outputDir) + if err != nil { + return nil, err + } + defer cleanup() + + if !opts.smoke || job.smoke.Skip { + return nil, nil + } + + run := opts.run + if run == nil { + run = dockerSmoke + } + + out, err := run(ctx, opts.runtimeImage, unpacked, job.smoke.Parameter) + if err != nil { + return out, fmt.Errorf("%w: %w", ErrSmokeFailed, err) + } + + return out, nil +} + +// relativizeSymlinks rewrites every absolute symlink under root whose target +// exists inside root as the equivalent relative link, and removes the rest. +// +// A build dumps an image filesystem, where absolute links are normal and +// resolve against the image root. Unpacked into plugins_dir the same link would +// resolve against the service's root instead, so the service skips them; the +// ones that point at something the bundle carries are worth keeping, and +// rewriting them here is the only place that can. +func relativizeSymlinks(root string) error { + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + + if entry.Type()&fs.ModeSymlink == 0 { + return nil + } + + target, err := os.Readlink(path) + if err != nil { + return fmt.Errorf("os.Readlink %s: %w", path, err) + } + + if !filepath.IsAbs(target) { + return nil + } + + return relativizeOne(root, path, target) + }) + if err != nil { + return fmt.Errorf("walking %s: %w", root, err) + } + + return nil +} + +// relativizeOne replaces one absolute link at path with a relative one, or +// removes it when root holds nothing at its target. +func relativizeOne(root, path, target string) error { + inside := filepath.Join(root, target) + + _, statErr := os.Lstat(inside) + if statErr != nil { + err := os.Remove(path) + if err != nil { + return fmt.Errorf("os.Remove %s: %w", path, err) + } + + return nil + } + + rel, err := filepath.Rel(filepath.Dir(path), inside) + if err != nil { + return fmt.Errorf("filepath.Rel %s: %w", path, err) + } + + err = os.Remove(path) + if err != nil { + return fmt.Errorf("os.Remove %s: %w", path, err) + } + + err = os.Symlink(rel, path) + if err != nil { + return fmt.Errorf("os.Symlink %s: %w", path, err) + } + + return nil +} + +// roundTrip packs dir and unpacks the result with plugarchive, the code the +// service runs on every download, and returns where it landed. +func roundTrip(dir string) (string, func(), error) { + archive, err := packPluginDir(dir) + if err != nil { + return "", func() {}, err + } + defer func() { _ = os.Remove(archive) }() + + parent, err := os.MkdirTemp("", "plugin-verify-*") + if err != nil { + return "", func() {}, fmt.Errorf("os.MkdirTemp: %w", err) + } + + cleanup := func() { _ = os.RemoveAll(parent) } + unpacked := filepath.Join(parent, "bundle") + + err = plugarchive.Unpack(archive, unpacked) + if err != nil { + cleanup() + + return "", func() {}, fmt.Errorf("the service would refuse this archive: %w", err) + } + + return unpacked, cleanup, nil +} + +// dockerSmoke runs the bundle's entrypoint in image with the request on stdin. +// +// `env -i` empties the environment, as the service does for every plugin; the +// working directory is /tmp, writable by the service user, standing in for the +// private one the service creates per run. The bundle is mounted read-only. +func dockerSmoke(ctx context.Context, image, bundleDir, parameter string) ([]byte, error) { + request, err := proto.Marshal(smokeRequest(parameter)) + if err != nil { + return nil, fmt.Errorf("proto.Marshal: %w", err) + } + + ctx, cancel := context.WithTimeout(ctx, smokeTimeout) + defer cancel() + + //nolint:gosec // every argument is ours; bundleDir is a temp dir this process created + cmd := exec.CommandContext(ctx, "docker", "run", "--rm", "-i", + "--user", smokeUser, + "--workdir", "/tmp", + "-v", bundleDir+":/p:ro", + "--entrypoint", "/usr/bin/env", + image, + "-i", "/p/"+plugarchive.EntrypointName, + ) + + var stdout, stderr bytes.Buffer + + cmd.Stdin = bytes.NewReader(request) + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err = cmd.Run() + if err != nil { + return stderr.Bytes(), fmt.Errorf("%w: %s", err, tail(stderr.String())) + } + + var response pluginpb.CodeGeneratorResponse + + err = proto.Unmarshal(stdout.Bytes(), &response) + if err != nil { + return stderr.Bytes(), fmt.Errorf("stdout is not a CodeGeneratorResponse: %w", err) + } + + return stderr.Bytes(), nil +} + +// tail returns the last stderrTail bytes of output on one line. +func tail(output string) string { + output = strings.Join(strings.Fields(output), " ") + if len(output) > stderrTail { + return "…" + output[len(output)-stderrTail:] + } + + return output +} + +// smokeRequest is a request every protoc plugin should be able to answer: one +// proto3 file with a message and a service, and the options the Go and Java +// generators insist on. A plugin that also needs its own options gets them as +// parameter, from plugin.yaml. +// +// It asserts nothing about what comes back beyond its being a response — an +// `error` in the response still counts as running, because that is a plugin +// rejecting a synthetic request, not a plugin that cannot start. +func smokeRequest(parameter string) *pluginpb.CodeGeneratorRequest { + file := &descriptorpb.FileDescriptorProto{ + Name: new("probe/v1/probe.proto"), + Package: new("probe.v1"), + Syntax: new("proto3"), + Options: &descriptorpb.FileOptions{ + GoPackage: new("example.com/probe/v1;probev1"), + JavaPackage: new("com.example.probe.v1"), + JavaMultipleFiles: new(true), + }, + MessageType: []*descriptorpb.DescriptorProto{{ + Name: new("Ping"), + Field: []*descriptorpb.FieldDescriptorProto{{ + Name: new("id"), + Number: new(int32(1)), + Label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL.Enum(), + Type: descriptorpb.FieldDescriptorProto_TYPE_STRING.Enum(), + JsonName: new("id"), + }}, + }}, + Service: []*descriptorpb.ServiceDescriptorProto{{ + Name: new("PingService"), + Method: []*descriptorpb.MethodDescriptorProto{{ + Name: new("Ping"), + InputType: new(".probe.v1.Ping"), + OutputType: new(".probe.v1.Ping"), + }}, + }}, + } + + const protocMajor = 29 + + request := &pluginpb.CodeGeneratorRequest{ + FileToGenerate: []string{file.GetName()}, + ProtoFile: []*descriptorpb.FileDescriptorProto{file}, + CompilerVersion: &pluginpb.Version{Major: new(int32(protocMajor)), Minor: new(int32(0))}, + } + + if parameter != "" { + request.Parameter = new(parameter) + } + + return request +} + +// discardFailedBuild empties a version directory that failed verification, +// keeping only its build log. +// +// Leaving the files in place would leave the entrypoint in place, and that is +// what `push`, `register` and the cache check all look for: a broken bundle +// would be reported failed here and then shipped by the next command anyway. +func discardFailedBuild(job buildJob) error { + err := os.RemoveAll(job.outputDir) + if err != nil { + return fmt.Errorf("os.RemoveAll %s: %w", job.outputDir, err) + } + + err = os.MkdirAll(job.outputDir, dirPermissions) + if err != nil { + return fmt.Errorf("os.MkdirAll %s: %w", job.outputDir, err) + } + + return nil +} diff --git a/cmd/easyp-svc/verify_test.go b/cmd/easyp-svc/verify_test.go new file mode 100644 index 0000000..9db2462 --- /dev/null +++ b/cmd/easyp-svc/verify_test.go @@ -0,0 +1,178 @@ +package main + +import ( + "context" + "errors" + "os" + "path/filepath" + "regexp" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/easyp-tech/service/internal/plugarchive" +) + +// newBundle lays out a built version directory with an entrypoint. +func newBundle(t *testing.T) buildJob { + t.Helper() + + out := filepath.Join(t.TempDir(), "plugins", "bufbuild", "connect-kotlin", "v0.1.10") + require.NoError(t, os.MkdirAll(out, dirPermissions)) + require.NoError(t, os.WriteFile(filepath.Join(out, plugarchive.EntrypointName), []byte("#!/bin/sh\n"), binPermissions)) + + return buildJob{group: "bufbuild", name: "connect-kotlin", version: "v0.1.10", outputDir: out} +} + +// TestRelativizeSymlinksKeepsWhatTheBundleCarries pins the difference between +// the two kinds of absolute link an image dump holds. One that points at a file +// the bundle carries is rewritten so it still resolves once unpacked anywhere — +// a Debian JRE reads its security config through exactly such a link, and +// dropping it broke the JVM. One that points at nothing the bundle has is +// removed, which is what the service would do with it anyway. +func TestRelativizeSymlinksKeepsWhatTheBundleCarries(t *testing.T) { + t.Parallel() + + job := newBundle(t) + root := job.outputDir + + conf := filepath.Join(root, "etc", "java-17-openjdk", "security", "java.security") + require.NoError(t, os.MkdirAll(filepath.Dir(conf), dirPermissions)) + require.NoError(t, os.WriteFile(conf, []byte("security"), 0o644)) + + link := filepath.Join(root, "usr", "lib", "jvm", "java-17", "conf", "security", "java.security") + require.NoError(t, os.MkdirAll(filepath.Dir(link), dirPermissions)) + require.NoError(t, os.Symlink("/etc/java-17-openjdk/security/java.security", link)) + + dangling := filepath.Join(root, "usr", "share", "zoneinfo", "localtime") + require.NoError(t, os.MkdirAll(filepath.Dir(dangling), dirPermissions)) + require.NoError(t, os.Symlink("/etc/localtime", dangling)) + + relative := filepath.Join(root, "lib-alias") + require.NoError(t, os.Symlink("usr/lib", relative)) + + require.NoError(t, relativizeSymlinks(root)) + + target, err := os.Readlink(link) + require.NoError(t, err) + assert.False(t, filepath.IsAbs(target), "a link into the bundle must become relative, got %q", target) + + body, err := os.ReadFile(link) + require.NoError(t, err) + assert.Equal(t, "security", string(body), "and still resolve to the same file") + + _, err = os.Lstat(dangling) + assert.True(t, os.IsNotExist(err), "a link to nothing the bundle carries is removed") + + kept, err := os.Readlink(relative) + require.NoError(t, err) + assert.Equal(t, "usr/lib", kept, "relative links are left alone") +} + +// TestVerifyRefusesWhatTheServiceWouldRefuse is the round trip's reason to +// exist: an archive the service's unpacker rejects fails the build here, on the +// machine that made it, instead of on the first GenerateCode after a push. +func TestVerifyRefusesWhatTheServiceWouldRefuse(t *testing.T) { + t.Parallel() + + job := newBundle(t) + require.NoError(t, os.Symlink("../../../../../etc/passwd", filepath.Join(job.outputDir, "escape"))) + + _, err := verifyBundle(t.Context(), job, verifyOptions{smoke: false}) + + require.ErrorIs(t, err, plugarchive.ErrUnsafePath) +} + +// TestSmokeRunsUnpackedBundleWithItsParameter checks what the smoke step is +// handed: the bundle as the service would unpack it, not the build output, and +// the parameter from plugin.yaml. +func TestSmokeRunsUnpackedBundleWithItsParameter(t *testing.T) { + t.Parallel() + + job := newBundle(t) + job.smoke = smokeConfig{Parameter: "extern_path=.=crate::proto"} + + var gotDir, gotParam, gotImage string + + _, err := verifyBundle(t.Context(), job, verifyOptions{ + smoke: true, + runtimeImage: defaultRuntimeImage, + run: func(_ context.Context, image, dir, parameter string) ([]byte, error) { + gotImage, gotDir, gotParam = image, dir, parameter + assert.FileExists(t, filepath.Join(dir, plugarchive.EntrypointName)) + + return nil, nil + }, + }) + require.NoError(t, err) + + assert.Equal(t, defaultRuntimeImage, gotImage) + assert.Equal(t, "extern_path=.=crate::proto", gotParam) + assert.NotEqual(t, job.outputDir, gotDir, "the smoke run must see the unpacked archive, not the build output") + assert.NoDirExists(t, gotDir, "the unpacked copy is removed afterwards") +} + +// TestSmokeSkipIsHonoured covers the escape hatch: a plugin.yaml that says skip +// is not run, though its archive is still checked. +func TestSmokeSkipIsHonoured(t *testing.T) { + t.Parallel() + + job := newBundle(t) + job.smoke = smokeConfig{Skip: true} + + _, err := verifyBundle(t.Context(), job, verifyOptions{ + smoke: true, + run: func(context.Context, string, string, string) ([]byte, error) { + t.Fatal("a plugin marked skip must not be run") + + return nil, nil + }, + }) + require.NoError(t, err) +} + +// TestFailedSmokeLeavesNothingToShip pins why a failed version is emptied +// rather than merely reported: push, register and the build cache all key on +// the entrypoint being there. Left in place, a plugin that cannot start would +// be marked failed by this build and shipped by the next command. +func TestFailedSmokeLeavesNothingToShip(t *testing.T) { + t.Parallel() + + job := newBundle(t) + + _, err := verifyBundle(t.Context(), job, verifyOptions{ + smoke: true, + run: func(context.Context, string, string, string) ([]byte, error) { + return []byte("exec: /nodejs/bin/node: not found"), errors.New("exit status 127") + }, + }) + require.ErrorIs(t, err, ErrSmokeFailed) + + require.NoError(t, discardFailedBuild(job)) + + assert.False(t, job.cached(), "a failed version must be rebuilt next time, not taken from cache") + + scanRoot := filepath.Dir(filepath.Dir(filepath.Dir(job.outputDir))) + found, err := scanPlugins(scanRoot, "") + require.NoError(t, err) + assert.Empty(t, found, "push and register must not find a version that failed verification") +} + +// TestSmokeImageMatchesServiceBase keeps the smoke test honest. A plugin is a +// dynamically linked process running on the service image's libraries, so a +// smoke run in any other base proves nothing — the glibc 2.38 failures were +// exactly a plugin built for one Debian and run on another. +func TestSmokeImageMatchesServiceBase(t *testing.T) { + t.Parallel() + + dockerfile, err := os.ReadFile("../../Dockerfile") + require.NoError(t, err) + + froms := regexp.MustCompile(`(?m)^FROM\s+(?:--\S+\s+)*(\S+)`).FindAllStringSubmatch(string(dockerfile), -1) + require.NotEmpty(t, froms) + + runtimeBase := froms[len(froms)-1][1] + assert.Equal(t, defaultRuntimeImage, runtimeBase, + "plugins build smoke-tests in %s but the service runs on %s", defaultRuntimeImage, runtimeBase) +} diff --git a/deploy/.env.dev.example b/deploy/.env.dev.example index 3f1a2a1..c4b2cfe 100644 --- a/deploy/.env.dev.example +++ b/deploy/.env.dev.example @@ -85,7 +85,7 @@ REGISTRY_S3_SECRET_ACCESS_KEY= # EASYP_GID=1000 # --- Service image --- -# Which published image the two tier containers run. A release tag (v1.0.5), +# Which published image the two tier containers run. A release tag (v1.1.0), # `edge` for the tip of master, or `sha-` to pin one commit. Bump this and # run `docker compose pull && docker compose up -d` to upgrade. # @@ -93,7 +93,7 @@ REGISTRY_S3_SECRET_ACCESS_KEY= # "the last release", and compose will happily keep a cached layer from months # ago. A config file in this repository and the binary that reads it have to # agree about which defaults exist. -# EASYP_SERVICE_VERSION=v1.0.5 +# EASYP_SERVICE_VERSION=v1.1.0 # --- Observability (docker-compose.observability.yml) --- # The telemetry backends write to an object store. These may point at the same diff --git a/deploy/charts/easyp-service/Chart.yaml b/deploy/charts/easyp-service/Chart.yaml index e83a6b8..d5dc95f 100644 --- a/deploy/charts/easyp-service/Chart.yaml +++ b/deploy/charts/easyp-service/Chart.yaml @@ -51,14 +51,14 @@ type: application # YAML, an alert incapable of producing anything. tests/render.sh now runs # `promtool test rules` against real series rather than only `check rules`, # which reads expressions without knowing whether one can ever fire. -version: 1.0.5 +version: 1.1.0 # appVersion tracks the service release this chart was validated against and is # the default image tag — so it has to be a tag that exists. It was "0.9.0", # and the registry publishes "v0.9.0": a default install pulled a tag that has # never existed and stopped at ImagePullBackOff; `helm template` renders a -# wrong tag as happily as a right one. v1.0.5 is the release this chart ships +# wrong tag as happily as a right one. v1.1.0 is the release this chart ships # with — do not publish the chart before that tag exists. -appVersion: "v1.0.5" +appVersion: "v1.1.0" home: https://github.com/easyp-tech/service sources: diff --git a/deploy/charts/easyp-service/README.md b/deploy/charts/easyp-service/README.md index df704f5..b89d2ab 100644 --- a/deploy/charts/easyp-service/README.md +++ b/deploy/charts/easyp-service/README.md @@ -34,7 +34,7 @@ checkout is not required: ```bash 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 ``` diff --git a/deploy/docker-compose.dev.yml b/deploy/docker-compose.dev.yml index 66894b5..a84c2b6 100644 --- a/deploy/docker-compose.dev.yml +++ b/deploy/docker-compose.dev.yml @@ -135,7 +135,7 @@ services: # loader learned to supply defaults, with an error naming a key whose default # is documented. Set EASYP_SERVICE_VERSION to a release tag, to `edge` for # the tip of master, or to `sha-` to pin the exact commit under test. - image: ghcr.io/easyp-tech/service:${EASYP_SERVICE_VERSION:-v1.0.5} + image: ghcr.io/easyp-tech/service:${EASYP_SERVICE_VERSION:-v1.1.0} container_name: easyp-api-community restart: always user: "${EASYP_UID:-65532}:${EASYP_GID:-65532}" @@ -222,7 +222,7 @@ services: # loader learned to supply defaults, with an error naming a key whose default # is documented. Set EASYP_SERVICE_VERSION to a release tag, to `edge` for # the tip of master, or to `sha-` to pin the exact commit under test. - image: ghcr.io/easyp-tech/service:${EASYP_SERVICE_VERSION:-v1.0.5} + image: ghcr.io/easyp-tech/service:${EASYP_SERVICE_VERSION:-v1.1.0} container_name: easyp-api-enterprise restart: always user: "${EASYP_UID:-65532}:${EASYP_GID:-65532}" diff --git a/go.mod b/go.mod index 9f76310..2531f97 100644 --- a/go.mod +++ b/go.mod @@ -41,23 +41,23 @@ require ( github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_model v0.6.2 github.com/sethvargo/go-envconfig v1.3.0 - github.com/stretchr/testify v1.11.1 + github.com/stretchr/testify v1.12.1 github.com/urfave/cli/v3 v3.9.0 - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 - go.opentelemetry.io/otel v1.44.0 - go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 - go.opentelemetry.io/otel/metric v1.44.0 - go.opentelemetry.io/otel/sdk v1.44.0 - go.opentelemetry.io/otel/sdk/metric v1.44.0 - go.opentelemetry.io/otel/trace v1.44.0 - golang.org/x/sync v0.22.0 - golang.org/x/term v0.45.0 + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.70.0 + go.opentelemetry.io/otel v1.47.0 + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.47.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.47.0 + go.opentelemetry.io/otel/metric v1.47.0 + go.opentelemetry.io/otel/sdk v1.47.0 + go.opentelemetry.io/otel/sdk/metric v1.47.0 + go.opentelemetry.io/otel/trace v1.47.0 + golang.org/x/sync v0.23.0 + golang.org/x/term v0.46.0 golang.org/x/time v0.15.0 - google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa + google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc google.golang.org/grpc v1.83.2 - google.golang.org/protobuf v1.36.11 + google.golang.org/protobuf v1.36.12 gopkg.in/yaml.v3 v3.0.1 ) @@ -80,20 +80,19 @@ require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect github.com/easyp-tech/protoc-gen-easydoc v0.4.0 // indirect github.com/easyp-tech/protoc-gen-mcp v0.5.0 // indirect - github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/google/jsonschema-go v0.4.3 // indirect github.com/google/uuid v1.6.0 // indirect github.com/grafana/pyroscope-go/godeltaprof v0.1.10 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect github.com/klauspost/compress v1.18.6 // indirect + github.com/kr/text v0.2.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/mfridman/interpolate v0.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.20.1 // indirect github.com/segmentio/asm v1.2.1 // indirect @@ -101,14 +100,15 @@ require ( github.com/sethvargo/go-retry v0.3.0 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/proto/otlp v1.10.0 // indirect + go.opentelemetry.io/otel/log v1.47.0 // indirect + go.opentelemetry.io/proto/otlp v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect - golang.org/x/crypto v0.55.0 // indirect - golang.org/x/net v0.58.0 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/net v0.59.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.41.0 // indirect - golang.org/x/tools v0.49.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc // indirect ) diff --git a/go.sum b/go.sum index 63608dc..df865bf 100644 --- a/go.sum +++ b/go.sum @@ -47,8 +47,7 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/easyp-tech/protoc-gen-easydoc v0.4.0 h1:JJREL3C/+EKf9lzypTAGo6QeUWDtDKVxK1aq8EbRzzc= @@ -56,8 +55,8 @@ github.com/easyp-tech/protoc-gen-easydoc v0.4.0/go.mod h1:/NhDdfMihhuPWYUy74Hf0V github.com/easyp-tech/protoc-gen-mcp v0.5.0 h1:1zCrjUwtRAXm2uuN7MCn2qF9yunixgAF9ZtHpay4XiI= github.com/easyp-tech/protoc-gen-mcp v0.5.0/go.mod h1:VcNiEFyqn+6lMGfOZonDIK5S3CVOf7FLMImeNDS9OAM= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= @@ -83,8 +82,8 @@ github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 h1:QGLs github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0/go.mod h1:hM2alZsMUni80N33RBe6J0e423LB+odMj7d3EMP9l20= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3/go.mod h1:NbCUVmiS4foBGBHOYlCT25+YmGpJ32dZPi75pGEUpj4= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY= github.com/hellofresh/health-go/v5 v5.5.5 h1:JZwZ8kZzAgjdGCvjgrIJTcu1sImvZoHbwAj7CK19fpw= github.com/hellofresh/health-go/v5 v5.5.5/go.mod h1:W+6uiWHS/m9jaB0aYBVlUBTeyE98yom6f+0ewLoBPYQ= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= @@ -112,8 +111,6 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -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/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4= github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= @@ -136,72 +133,76 @@ github.com/sethvargo/go-envconfig v1.3.0 h1:gJs+Fuv8+f05omTpwWIu6KmuseFAXKrIaOZS github.com/sethvargo/go-envconfig v1.3.0/go.mod h1:JLd0KFWQYzyENqnEPWWZ49i4vzZo/6nRidxI8YvGiHw= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/urfave/cli/v3 v3.9.0 h1:AV9lIiPv3ukYnxunaCUsHnEozptYmDN2F0+yWqLMn/c= github.com/urfave/cli/v3 v3.9.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 h1:0Qx7VGBacMm9ZENQ7TnNObTYI4ShC+lHI16seduaxZo= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0/go.mod h1:Sje3i3MjSPKTSPvVWCaL8ugBzJwik3u4smCjUeuupqg= -go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= -go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 h1:8UQVDcZxOJLtX6gxtDt3vY2WTgvZqMQRzjsqiIHQdkc= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0/go.mod h1:2lmweYCiHYpEjQ/lSJBYhj9jP1zvCvQW4BqL9dnT7FQ= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk= -go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= -go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= -go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA= -go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk= -go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= -go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= -go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= -go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= -go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= -go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= -go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= -go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.70.0 h1:oECp5f+hN7nkwjU/8BxQ/q23bGPb8FIrD839owX222E= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.70.0/go.mod h1:DqEFwLumhzMBDQv9PcWbyoDxHI/4lAk6CM4nJBH39sc= +go.opentelemetry.io/otel v1.47.0 h1:j7ALJ/zgkS7Z6aeJW09p8VC9804bC+PpeTfCD4XPnOM= +go.opentelemetry.io/otel v1.47.0/go.mod h1:8wS9O2qfXrYrzp6hIF/HOYJJf/wIhFPhR2xLuP+iXQU= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.47.0 h1:LaLVGJtIzHcvHjVs2OftmKcc+M9mdOqS8vZ1fBD1Yes= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.47.0/go.mod h1:vF6H5sNDuQBJWgSX00iZgbIqk2MZZoVMH0ar5t5bOpw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0 h1:julhjPeUH/q/7hinbSdDdqt5h7Zw9YWmRlWRhI0jd54= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0/go.mod h1:Ao2mz688LH/tFf0yMAenidq6k2YNSx6SIY2q6jDACck= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.47.0 h1:UFpxOpYPmMNUtOWhdb+nC1WELIgVf8z9higURrbzYU4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.47.0/go.mod h1:YBGjxe3lt0jtXoEXxGrGhv/jM7oUBUDVK7zhAvNyEjc= +go.opentelemetry.io/otel/log v1.47.0 h1:cOTS1CcLbSQeZKanGJ+0JpF/+t4PELi3O3bbl2lqCcI= +go.opentelemetry.io/otel/log v1.47.0/go.mod h1:9byitSQ5pLC6PpqwGXjqdMKya6ZTswHRZh2vvXT33nw= +go.opentelemetry.io/otel/metric v1.47.0 h1:4PptaldXx3Eat1XjMZ68pPJEs5wrhlemctZE9a3UdWY= +go.opentelemetry.io/otel/metric v1.47.0/go.mod h1:ADGSXxRrXM6bjbvLo535EstVFlPpPYZm4LBKixjDHwU= +go.opentelemetry.io/otel/metric/x v0.69.0 h1:DjRLr15H83v+hCW7JA9NoJvOkYTtmq5YoDRbe9deYpM= +go.opentelemetry.io/otel/metric/x v0.69.0/go.mod h1:uVvsMPMFFyj/HUQfrUnH3JjnOQ1dwFDorgFLRBasM0k= +go.opentelemetry.io/otel/sdk v1.47.0 h1:zWXEr4j2lFefG87TU6Yg8a7ngfohIKFZHKp0Hf5hC6I= +go.opentelemetry.io/otel/sdk v1.47.0/go.mod h1:VUc24kiOeoGsxG8G9ULx3fWKvB7jMhnGE8Oi607lgR0= +go.opentelemetry.io/otel/sdk/metric v1.47.0 h1:lfISg2j93VT6yqdk9OfUaZmw/GfcZqCCV3jdXtsPnKw= +go.opentelemetry.io/otel/sdk/metric v1.47.0/go.mod h1:ypLp+mW1Nt2x+Szt3b5/i1syodyts49lMOwxpDI3VGw= +go.opentelemetry.io/otel/trace v1.47.0 h1:JOjX/Oci8K94QHddo+bbfya/Ai/nf6/dt9ZfrFNWSrM= +go.opentelemetry.io/otel/trace v1.47.0/go.mod h1:jNaSLa2PZEYFG6fRjJABAu+bw4FS08uDmPg28lTghu0= +go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk= +go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= -golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= -golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= -golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= -golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= +golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= -golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= -golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= -golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= -golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= +golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= -google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc h1:rZHRz0ogq4Pid1IKtA/ivHi8XONVF5tqUQXA2TtgpkE= +google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:K/n0XLazCJHjD73YuMyNJsN38BPPy1kfFnKxUXaThFU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc h1:4bNTbnb44EqGVy9HaQxSY2jnafSUdgV3qHtedvNpDKg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/adapters/registry/registry.go b/internal/adapters/registry/registry.go index cd4d62c..3afc7a9 100644 --- a/internal/adapters/registry/registry.go +++ b/internal/adapters/registry/registry.go @@ -110,6 +110,8 @@ type ( maxOutputSize int64 `db:"-"` pluginConfig PluginConfig `db:"-"` guard *safe.Guard `db:"-"` + // workRoot holds one private working directory per run; see Generate. + workRoot string `db:"-"` } ) @@ -260,6 +262,7 @@ func (r *Registry) Get(ctx context.Context, pluginGroup, pluginName, pluginVersi dbFormat.maxOutputSize = r.maxOutputSize dbFormat.guard = r.guard + dbFormat.workRoot = r.tmpDir return &dbFormat, nil } @@ -468,20 +471,11 @@ func (p *plugin) Generate(ctx context.Context, req *pluginpb.CodeGeneratorReques } // 2. Prepare command execution - //nolint:gosec // The command is validated by ValidateConfig and retrieved from the registry database. - cmd := exec.CommandContext(ctx, p.pluginConfig.Command[0], p.pluginConfig.Command[1:]...) - - // Clean env, only propagate configured env variables - cmd.Env = make([]string, 0, len(p.pluginConfig.Env)) - for k, v := range p.pluginConfig.Env { - cmd.Env = append(cmd.Env, k+"="+v) + cmd, cleanup, err := p.command(ctx, requestData) + if err != nil { + return nil, err } - - // Stdin setup - cmd.Stdin = bytes.NewReader(requestData) - - // Process group isolation (Unix-specific pgid setup) - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + defer cleanup() // Stdout and stderr pipes stdoutPipe, err := cmd.StdoutPipe() @@ -567,6 +561,40 @@ func (p *plugin) Generate(ctx context.Context, req *pluginpb.CodeGeneratorReques return &response, nil } +// command builds the plugin process: its configured command line, only its +// configured environment, the request on stdin and a process group of its own. +// The returned cleanup removes the run's working directory. +// +// The working directory is private, writable and removed after the run. Without +// one the plugin inherited the service's cwd, which is / in the image and not +// writable by its user — and some plugins write there: betterproto creates its +// output package directories relative to cwd and failed on every request. It +// also keeps whatever a plugin leaves behind out of the service's way and out of +// the next run's. +func (p *plugin) command(ctx context.Context, requestData []byte) (*exec.Cmd, func(), error) { //nolint:funcorder,lll // helper for Generate above + workDir, err := os.MkdirTemp(p.workRoot, "run-*") + if err != nil { + return nil, nil, fmt.Errorf("%w: creating a working directory: %w", core.ErrGenerationFailed, err) + } + + //nolint:gosec // The command is validated by ValidateConfig and retrieved from the registry database. + cmd := exec.CommandContext(ctx, p.pluginConfig.Command[0], p.pluginConfig.Command[1:]...) + + // Clean env, only propagate configured env variables + cmd.Env = make([]string, 0, len(p.pluginConfig.Env)) + for k, v := range p.pluginConfig.Env { + cmd.Env = append(cmd.Env, k+"="+v) + } + + cmd.Stdin = bytes.NewReader(requestData) + cmd.Dir = workDir + + // Process group isolation (Unix-specific pgid setup) + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + + return cmd, func() { _ = os.RemoveAll(workDir) }, nil +} + // Create implements core.Registry. func (r *Registry) Create(ctx context.Context, req core.CreatePluginRequest) (*core.PluginInfo, error) { validateErr := ValidateConfig(req.Config, r.pluginsDir) diff --git a/internal/adapters/registry/workdir_test.go b/internal/adapters/registry/workdir_test.go new file mode 100644 index 0000000..4b3a004 --- /dev/null +++ b/internal/adapters/registry/workdir_test.go @@ -0,0 +1,64 @@ +package registry + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/pluginpb" + + "github.com/easyp-tech/service/internal/safe" +) + +// TestGenerateRunsInPrivateWorkingDirectory pins the working directory a plugin +// gets. It used to inherit the service's, which is / in the image and not +// writable by the service user, and betterproto — which creates its output +// package directories relative to cwd — failed on every request there. +// +// The plugin here does what betterproto does, records where it ran, and the +// test checks that the directory was writable, was not the service's own, and +// is gone once Generate returns. +func TestGenerateRunsInPrivateWorkingDirectory(t *testing.T) { + t.Parallel() + + pluginsDir := t.TempDir() + workRoot := filepath.Join(pluginsDir, tmpDirName) + require.NoError(t, os.MkdirAll(workRoot, dirPerm)) + + marker := filepath.Join(t.TempDir(), "cwd") + script := filepath.Join(pluginsDir, "plugin") + require.NoError(t, os.WriteFile(script, []byte("#!/bin/sh\nmkdir -p probe/v1 && pwd > \"$MARKER\"\n"), 0o755)) + + plug := &plugin{ + GroupName: "community", + Name: "betterproto", + Version: "v1.2.5", + maxOutputSize: 1 << 20, + guard: safe.NewGuard(nil, "test"), + workRoot: workRoot, + pluginConfig: PluginConfig{ + Command: []string{script}, + Env: map[string]string{"MARKER": marker}, + }, + } + + _, err := plug.Generate(t.Context(), &pluginpb.CodeGeneratorRequest{}) + require.NoError(t, err, "a plugin writing into its working directory must succeed") + + recorded, err := os.ReadFile(marker) + require.NoError(t, err) + + ranIn := filepath.Clean(string(recorded[:len(recorded)-1])) + serviceCwd, err := os.Getwd() + require.NoError(t, err) + + assert.NotEqual(t, serviceCwd, ranIn, "the plugin must not share the service's working directory") + assert.Contains(t, ranIn, tmpDirName, "the run directory belongs under the plugin volume's staging area") + assert.NoDirExists(t, ranIn, "the run directory is removed after the plugin exits") + + entries, err := os.ReadDir(workRoot) + require.NoError(t, err) + assert.Empty(t, entries, "nothing is left behind between runs") +} diff --git a/internal/plugarchive/plugarchive.go b/internal/plugarchive/plugarchive.go index c52b6e4..cae4dda 100644 --- a/internal/plugarchive/plugarchive.go +++ b/internal/plugarchive/plugarchive.go @@ -218,6 +218,11 @@ func extractTo(archivePath string, destDir string) error { return err } + err = checkParentContained(destDir, target) + if err != nil { + return err + } + err = extractEntry(tarReader, header, destDir, target) if err != nil { return err @@ -275,7 +280,6 @@ func extractRegular(tarReader *tar.Reader, header *tar.Header, target string) er return nil } -// extractSymlink recreates a symlink entry verbatim. // extractSymlink materialises a symlink entry, refusing one that points outside // the directory being unpacked into. // @@ -290,7 +294,20 @@ func extractRegular(tarReader *tar.Reader, header *tar.Header, target string) er // archive is being written out right now, so intermediate links may not resolve // yet, and a link to a path that does not exist is still a link that will // resolve once something creates it. +// +// An absolute link anywhere but the entrypoint is skipped rather than refused. +// `plugins build` dumps a whole image filesystem, and base images are full of +// them — /etc/localtime, the dynamic loader under lib64, fontconfig — none of +// which a plugin reaches through its own directory. Refusing them refused the +// archive, which is how 47 of the 80 catalogue plugins never unpacked. The link +// cannot be created safely either: it would point into the service's own +// filesystem. The entrypoint stays refused, because skipping it would turn a +// hostile archive into a merely broken one without saying why. func extractSymlink(root string, header *tar.Header, target string) error { + if filepath.IsAbs(filepath.FromSlash(header.Linkname)) && target != filepath.Join(root, EntrypointName) { + return nil + } + err := checkSymlinkTarget(root, target, header.Linkname) if err != nil { return err @@ -308,9 +325,72 @@ func extractSymlink(root string, header *tar.Header, target string) error { return fmt.Errorf("os.Symlink: %w", err) } + // The lexical check above trusts every link it walks through to be what its + // name says. One that is itself a link breaks that: `x -> .` then + // `l1 -> x/..` reads as "." but resolves to the parent of root. Once the + // link exists it can be resolved for real; a dangling one is left to the + // lexical check, since nothing can be written through it. + resolved, err := filepath.EvalSymlinks(target) + if err == nil && !isWithin(resolvedRoot(root), resolved) { + _ = os.Remove(target) + + return fmt.Errorf("%w: symlink %s resolves outside the archive: %s", ErrUnsafePath, target, resolved) + } + return nil } +// checkParentContained refuses an entry whose directory, as it exists on disk +// right now, resolves outside root. +// +// safeJoin only judges the entry's name, and the name is not where the bytes +// go: a directory component that an earlier entry made a symlink sends them +// wherever that link points. The deepest existing ancestor is resolved because +// the rest of the path does not exist yet and will be created as plain +// directories beneath it. +func checkParentContained(root, target string) error { + dir := filepath.Dir(target) + + for { + resolved, err := filepath.EvalSymlinks(dir) + if err == nil { + if !isWithin(resolvedRoot(root), resolved) { + return fmt.Errorf("%w: %s would be written outside the archive, through %s", ErrUnsafePath, target, resolved) + } + + return nil + } + + if !os.IsNotExist(err) { + return fmt.Errorf("resolving %s: %w", dir, err) + } + + parent := filepath.Dir(dir) + if parent == dir { + return fmt.Errorf("%w: no existing ancestor for %s", ErrUnsafePath, target) + } + + dir = parent + } +} + +// resolvedRoot is root with its own symlinks resolved, so that it compares +// equal to paths EvalSymlinks returns beneath it. A temp directory routinely +// sits behind one — /var is /private/var on macOS. +func resolvedRoot(root string) string { + resolved, err := filepath.EvalSymlinks(root) + if err != nil { + return filepath.Clean(root) + } + + return resolved +} + +// isWithin reports whether path is root or lies beneath it. +func isWithin(root, path string) bool { + return path == root || strings.HasPrefix(path, root+string(filepath.Separator)) +} + // checkSymlinkTarget reports whether linkname, resolved from the directory // holding target, stays inside root. An absolute linkname never does. func checkSymlinkTarget(root, target, linkname string) error { diff --git a/internal/plugarchive/plugarchive_test.go b/internal/plugarchive/plugarchive_test.go index 2f92267..0d5e288 100644 --- a/internal/plugarchive/plugarchive_test.go +++ b/internal/plugarchive/plugarchive_test.go @@ -253,3 +253,50 @@ func TestUnpackAllowsSymlinkIntoSubdirectory(t *testing.T) { require.NoError(t, err) assert.Equal(t, "../plugin", link) } + +// TestUnpackSkipsAbsoluteSymlinks pins what made 47 of the 80 catalogue plugins +// unusable: an image dump carries absolute links like /etc/localtime and the +// loader under lib64, and refusing them refused the whole archive. They are +// dropped now, and everything else in the archive still lands. +func TestUnpackSkipsAbsoluteSymlinks(t *testing.T) { + t.Parallel() + + archive := writeArchive(t, []*tar.Header{ + {Name: EntrypointName, Typeflag: tar.TypeReg, Mode: 0o755, Size: 2}, + {Name: "usr/share/zoneinfo/localtime", Typeflag: tar.TypeSymlink, Linkname: "/etc/localtime", Mode: 0o777}, + {Name: "lib64/ld-linux-x86-64.so.2", Typeflag: tar.TypeSymlink, Linkname: "/lib/x86_64-linux-gnu/ld.so", Mode: 0o777}, + {Name: "lib/real", Typeflag: tar.TypeReg, Mode: 0o644, Size: 1}, + }, map[string][]byte{EntrypointName: []byte("hi"), "lib/real": []byte("x")}) + + dest := filepath.Join(t.TempDir(), "v1.0.0") + require.NoError(t, Unpack(archive, dest)) + + assert.NoFileExists(t, filepath.Join(dest, "usr/share/zoneinfo/localtime")) + assert.NoFileExists(t, filepath.Join(dest, "lib64/ld-linux-x86-64.so.2")) + assert.FileExists(t, filepath.Join(dest, "lib/real")) + assert.FileExists(t, filepath.Join(dest, EntrypointName)) +} + +// TestUnpackRefusesWriteThroughSymlinkChain is the escape the lexical check +// missed. `x -> .` is harmless and `l1 -> x/..` reads as ".", but on disk it is +// the parent of the unpack root, so `l1/evil` was written beside the version +// directory rather than inside it. +func TestUnpackRefusesWriteThroughSymlinkChain(t *testing.T) { + t.Parallel() + + archive := writeArchive(t, []*tar.Header{ + {Name: "x", Typeflag: tar.TypeSymlink, Linkname: ".", Mode: 0o777}, + {Name: "l1", Typeflag: tar.TypeSymlink, Linkname: "x/..", Mode: 0o777}, + {Name: "l1/evil", Typeflag: tar.TypeReg, Mode: 0o644, Size: 7}, + {Name: EntrypointName, Typeflag: tar.TypeReg, Mode: 0o755, Size: 2}, + }, map[string][]byte{"l1/evil": []byte("escaped"), EntrypointName: []byte("hi")}) + + parent := filepath.Join(t.TempDir(), "group", "name") + dest := filepath.Join(parent, "v1.0.0") + + err := Unpack(archive, dest) + + require.ErrorIs(t, err, ErrUnsafePath) + assert.NoFileExists(t, filepath.Join(parent, "evil")) + assert.NoDirExists(t, dest) +} diff --git a/registry/apple/servicetalk/Dockerfile b/registry/apple/servicetalk/Dockerfile index 20e1c62..2c8d76b 100644 --- a/registry/apple/servicetalk/Dockerfile +++ b/registry/apple/servicetalk/Dockerfile @@ -22,7 +22,12 @@ COPY --from=build --link --chmod=0755 --chown=root:root /app/servicetalk-grpc-pr COPY --from=maven-deps /root/.m2/repository /maven-repository COPY --chmod=0755 <<"EOF" /plugin #!/bin/sh -exec /usr/bin/java -jar /servicetalk-grpc-protoc.jar "$@" +# Paths are relative to this script: the service runs the bundle from its +# plugins directory, not from the image it was built in. +DIR=${0%/*} +for JAVA in "$DIR"/usr/lib/jvm/*/bin/java; do exec "$JAVA" -jar "$DIR/servicetalk-grpc-protoc.jar" "$@"; done +echo "no JRE in $DIR/usr/lib/jvm" >&2 +exit 127 EOF USER nobody ENTRYPOINT ["/plugin"] diff --git a/registry/bufbuild/connect-es/Dockerfile b/registry/bufbuild/connect-es/Dockerfile index 3963fc0..0a58991 100644 --- a/registry/bufbuild/connect-es/Dockerfile +++ b/registry/bufbuild/connect-es/Dockerfile @@ -11,7 +11,10 @@ ARG VERSION COPY --link --from=build /app /app COPY --chmod=0755 <<"EOF" /plugin #!/bin/sh -exec /app/node_modules/.bin/protoc-gen-connect-es "$@" +# Paths are relative to this script: the service runs the bundle from its +# plugins directory, not from the image it was built in. +DIR=${0%/*} +exec "$DIR/nodejs/bin/node" "$DIR/app/node_modules/.bin/protoc-gen-connect-es" "$@" EOF USER nobody ENTRYPOINT ["/plugin"] diff --git a/registry/bufbuild/connect-kotlin/Dockerfile b/registry/bufbuild/connect-kotlin/Dockerfile index ab4b76a..b92b4f1 100644 --- a/registry/bufbuild/connect-kotlin/Dockerfile +++ b/registry/bufbuild/connect-kotlin/Dockerfile @@ -1,8 +1,10 @@ # syntax=docker/dockerfile:1.4 -FROM debian:bullseye-20230814 AS build +# The bullseye snapshot this stage used to pin no longer installs anything: its +# security pool moved, every fetch returned 404. The stage only downloads a jar. +FROM debian:trixie-slim AS build ARG VERSION RUN apt-get update \ - && apt-get install -y curl + && apt-get install -y --no-install-recommends ca-certificates curl WORKDIR /app RUN curl -fsSL -o /app/protoc-gen-connect-kotlin.jar https://repo1.maven.org/maven2/build/buf/protoc-gen-connect-kotlin/${VERSION#v}/protoc-gen-connect-kotlin-${VERSION#v}.jar @@ -18,5 +20,10 @@ COPY --from=build /app/protoc-gen-connect-kotlin.jar /app COPY --from=maven-deps /root/.m2/repository /maven-repository COPY --chmod=0755 <<"EOF" /plugin #!/bin/sh -exec /app/protoc-gen-connect-kotlin.jar "$@" +# Paths are relative to this script: the service runs the bundle from its +# plugins directory, not from the image it was built in. +DIR=${0%/*} +for JAVA in "$DIR"/usr/lib/jvm/*/bin/java; do exec "$JAVA" -jar "$DIR/app/protoc-gen-connect-kotlin.jar" "$@"; done +echo "no JRE in $DIR/usr/lib/jvm" >&2 +exit 127 EOF diff --git a/registry/bufbuild/connect-kotlin/Dockerfile.github b/registry/bufbuild/connect-kotlin/Dockerfile.github index 7639385..950bda0 100644 --- a/registry/bufbuild/connect-kotlin/Dockerfile.github +++ b/registry/bufbuild/connect-kotlin/Dockerfile.github @@ -1,10 +1,12 @@ # syntax=docker/dockerfile:1.22 # Download protoc-gen-connect-kotlin.jar from GitHub Releases (used when Maven # Central has no artifact, e.g. v0.1.4). -FROM debian:bullseye-20230814 AS build +# The bullseye snapshot this stage used to pin no longer installs anything: its +# security pool moved, every fetch returned 404. The stage only downloads a jar. +FROM debian:trixie-slim AS build ARG VERSION RUN apt-get update \ - && apt-get install -y curl \ + && apt-get install -y --no-install-recommends ca-certificates curl \ && mkdir -p /app \ && curl -fsSL -o /app/protoc-gen-connect-kotlin.jar \ "https://github.com/connectrpc/connect-kotlin/releases/download/${VERSION}/protoc-gen-connect-kotlin.jar" @@ -21,5 +23,10 @@ COPY --from=build /app/protoc-gen-connect-kotlin.jar /app COPY --from=maven-deps /root/.m2/repository /maven-repository COPY --chmod=0755 <<"EOF" /plugin #!/bin/sh -exec /app/protoc-gen-connect-kotlin.jar "$@" +# Paths are relative to this script: the service runs the bundle from its +# plugins directory, not from the image it was built in. +DIR=${0%/*} +for JAVA in "$DIR"/usr/lib/jvm/*/bin/java; do exec "$JAVA" -jar "$DIR/app/protoc-gen-connect-kotlin.jar" "$@"; done +echo "no JRE in $DIR/usr/lib/jvm" >&2 +exit 127 EOF diff --git a/registry/bufbuild/connect-kotlin/Dockerfile.source b/registry/bufbuild/connect-kotlin/Dockerfile.source index e742e2a..2342765 100644 --- a/registry/bufbuild/connect-kotlin/Dockerfile.source +++ b/registry/bufbuild/connect-kotlin/Dockerfile.source @@ -33,5 +33,10 @@ COPY --from=build /tmp/protoc-gen-connect-kotlin.jar /app/protoc-gen-connect-kot COPY --from=maven-deps /root/.m2/repository /maven-repository COPY --chmod=0755 <<"EOF" /plugin #!/bin/sh -exec /app/protoc-gen-connect-kotlin.jar "$@" +# Paths are relative to this script: the service runs the bundle from its +# plugins directory, not from the image it was built in. +DIR=${0%/*} +for JAVA in "$DIR"/usr/lib/jvm/*/bin/java; do exec "$JAVA" -jar "$DIR/app/protoc-gen-connect-kotlin.jar" "$@"; done +echo "no JRE in $DIR/usr/lib/jvm" >&2 +exit 127 EOF diff --git a/registry/bufbuild/connect-query/Dockerfile b/registry/bufbuild/connect-query/Dockerfile index b07ee2d..8a3d2ea 100644 --- a/registry/bufbuild/connect-query/Dockerfile +++ b/registry/bufbuild/connect-query/Dockerfile @@ -11,7 +11,10 @@ ARG VERSION COPY --link --from=build /app /app COPY --chmod=0755 <<"EOF" /plugin #!/bin/sh -exec /app/node_modules/.bin/protoc-gen-connect-query "$@" +# Paths are relative to this script: the service runs the bundle from its +# plugins directory, not from the image it was built in. +DIR=${0%/*} +exec "$DIR/nodejs/bin/node" "$DIR/app/node_modules/.bin/protoc-gen-connect-query" "$@" EOF USER nobody ENTRYPOINT ["/plugin"] diff --git a/registry/bufbuild/connect-web/Dockerfile b/registry/bufbuild/connect-web/Dockerfile index 974bfdd..ebf2afa 100644 --- a/registry/bufbuild/connect-web/Dockerfile +++ b/registry/bufbuild/connect-web/Dockerfile @@ -11,7 +11,10 @@ ARG VERSION COPY --link --from=build /app /app COPY --chmod=0755 <<"EOF" /plugin #!/bin/sh -exec /app/node_modules/.bin/protoc-gen-connect-web "$@" +# Paths are relative to this script: the service runs the bundle from its +# plugins directory, not from the image it was built in. +DIR=${0%/*} +exec "$DIR/nodejs/bin/node" "$DIR/app/node_modules/.bin/protoc-gen-connect-web" "$@" EOF USER nobody ENTRYPOINT ["/plugin"] diff --git a/registry/bufbuild/es/Dockerfile b/registry/bufbuild/es/Dockerfile index c61103e..3ad8839 100644 --- a/registry/bufbuild/es/Dockerfile +++ b/registry/bufbuild/es/Dockerfile @@ -22,7 +22,10 @@ COPY --link --from=build --chmod=0755 /app/protoc-gen-es.js /app/protoc-gen-es.j COPY --link --from=build /app/node_modules/typescript /app/node_modules/typescript COPY --chmod=0755 <<"EOF" /plugin #!/bin/sh -exec /nodejs/bin/node /app/protoc-gen-es.js "$@" +# Paths are relative to this script: the service runs the bundle from its +# plugins directory, not from the image it was built in. +DIR=${0%/*} +exec "$DIR/nodejs/bin/node" "$DIR/app/protoc-gen-es.js" "$@" EOF USER nobody ENTRYPOINT ["/plugin"] diff --git a/registry/bufbuild/knit-ts/Dockerfile b/registry/bufbuild/knit-ts/Dockerfile index a2c3174..07ee188 100644 --- a/registry/bufbuild/knit-ts/Dockerfile +++ b/registry/bufbuild/knit-ts/Dockerfile @@ -16,7 +16,10 @@ COPY --link --from=build --chmod=0755 /app/protoc-gen-knit-ts.js /app/protoc-gen COPY --link --from=build /app/node_modules/typescript /app/node_modules/typescript COPY --chmod=0755 <<"EOF" /plugin #!/bin/sh -exec /nodejs/bin/node /app/protoc-gen-knit-ts.js "$@" +# Paths are relative to this script: the service runs the bundle from its +# plugins directory, not from the image it was built in. +DIR=${0%/*} +exec "$DIR/nodejs/bin/node" "$DIR/app/protoc-gen-knit-ts.js" "$@" EOF USER nobody ENTRYPOINT ["/plugin"] diff --git a/registry/community/danielgtaylor-betterproto/Dockerfile b/registry/community/danielgtaylor-betterproto/Dockerfile index 6a3b541..13fd4a8 100644 --- a/registry/community/danielgtaylor-betterproto/Dockerfile +++ b/registry/community/danielgtaylor-betterproto/Dockerfile @@ -14,7 +14,12 @@ ARG VERSION COPY --from=build --link /app /app COPY --chmod=0755 <<"EOF" /plugin #!/bin/sh -exec /app/bin/protoc-gen-python_betterproto "$@" +# Paths are relative to this script: the service runs the bundle from its +# plugins directory, not from the image it was built in. +DIR=${0%/*} +# Alpine build: run under the bundle's own musl loader, the service image is glibc. +PYTHONHOME="$DIR/usr/local" PYTHONPATH="$DIR/app/lib/python3.11/site-packages" \ + exec "$DIR"/lib/ld-musl-*.so.1 --library-path "$DIR/lib:$DIR/usr/lib:$DIR/usr/local/lib" "$DIR/usr/local/bin/python3.11" "$DIR/app/bin/protoc-gen-python_betterproto" "$@" EOF USER nobody # Plugin uses os.makedirs - needs to run in a directory it can write to diff --git a/registry/community/nanopb/Dockerfile b/registry/community/nanopb/Dockerfile index 97d951b..a801925 100644 --- a/registry/community/nanopb/Dockerfile +++ b/registry/community/nanopb/Dockerfile @@ -19,7 +19,15 @@ COPY --link --from=base / / COPY --link --from=build --chmod=0755 /app /app COPY --chmod=0755 <<"EOF" /plugin #!/bin/sh -exec /app/bin/protoc-gen-nanopb "$@" +# Paths are relative to this script: the service runs the bundle from its +# plugins directory, not from the image it was built in. +DIR=${0%/*} +# Run under the bundle's own loader and libraries: the interpreter needs +# libexpat and friends the service image does not have, and must not mix its +# glibc with the host's. +for L in "$DIR"/lib/*-linux-gnu; do :; done +PYTHONHOME="$DIR/usr" PYTHONPATH="$DIR/app/lib/python3.11/site-packages" \ + exec "$L"/ld-linux-*.so.* --library-path "$L:$DIR/usr/lib/${L##*/}" "$DIR/usr/bin/python3.11" "$DIR/app/bin/protoc-gen-nanopb" "$@" EOF USER nobody ENTRYPOINT ["/plugin"] diff --git a/registry/community/nipunn1313-mypy-grpc/Dockerfile b/registry/community/nipunn1313-mypy-grpc/Dockerfile index 929bd6b..45f6362 100644 --- a/registry/community/nipunn1313-mypy-grpc/Dockerfile +++ b/registry/community/nipunn1313-mypy-grpc/Dockerfile @@ -20,7 +20,15 @@ COPY --link --from=base / / COPY --link --from=build /app /app COPY --chmod=0755 <<"EOF" /plugin #!/bin/sh -exec /app/bin/protoc-gen-mypy_grpc "$@" +# Paths are relative to this script: the service runs the bundle from its +# plugins directory, not from the image it was built in. +DIR=${0%/*} +# Run under the bundle's own loader and libraries: the interpreter needs +# libexpat and friends the service image does not have, and must not mix its +# glibc with the host's. +for L in "$DIR"/lib/*-linux-gnu; do :; done +PYTHONHOME="$DIR/usr" PYTHONPATH="$DIR/app/lib/python3.13/site-packages" \ + exec "$L"/ld-linux-*.so.* --library-path "$L:$DIR/usr/lib/${L##*/}" "$DIR/usr/bin/python3.13" "$DIR/app/bin/protoc-gen-mypy_grpc" "$@" EOF USER nobody ENTRYPOINT ["/plugin"] diff --git a/registry/community/nipunn1313-mypy/Dockerfile b/registry/community/nipunn1313-mypy/Dockerfile index f0c1a4c..83d209f 100644 --- a/registry/community/nipunn1313-mypy/Dockerfile +++ b/registry/community/nipunn1313-mypy/Dockerfile @@ -20,7 +20,15 @@ COPY --link --from=base / / COPY --link --from=build /app /app COPY --chmod=0755 <<"EOF" /plugin #!/bin/sh -exec /app/bin/protoc-gen-mypy "$@" +# Paths are relative to this script: the service runs the bundle from its +# plugins directory, not from the image it was built in. +DIR=${0%/*} +# Run under the bundle's own loader and libraries: the interpreter needs +# libexpat and friends the service image does not have, and must not mix its +# glibc with the host's. +for L in "$DIR"/lib/*-linux-gnu; do :; done +PYTHONHOME="$DIR/usr" PYTHONPATH="$DIR/app/lib/python3.13/site-packages" \ + exec "$L"/ld-linux-*.so.* --library-path "$L:$DIR/usr/lib/${L##*/}" "$DIR/usr/bin/python3.13" "$DIR/app/bin/protoc-gen-mypy" "$@" EOF USER nobody ENTRYPOINT ["/plugin"] diff --git a/registry/community/salesforce-reactive-grpc/Dockerfile b/registry/community/salesforce-reactive-grpc/Dockerfile index 2972edb..a512edc 100644 --- a/registry/community/salesforce-reactive-grpc/Dockerfile +++ b/registry/community/salesforce-reactive-grpc/Dockerfile @@ -21,7 +21,12 @@ COPY --from=build --link --chmod=0755 --chown=root:root /app/reactor-grpc-protoc COPY --from=maven-deps /root/.m2/repository /maven-repository COPY --chmod=0755 <<"EOF" /plugin #!/bin/sh -exec /usr/bin/java -jar /reactor-grpc-protoc.jar "$@" +# Paths are relative to this script: the service runs the bundle from its +# plugins directory, not from the image it was built in. +DIR=${0%/*} +for JAVA in "$DIR"/usr/lib/jvm/*/bin/java; do exec "$JAVA" -jar "$DIR/reactor-grpc-protoc.jar" "$@"; done +echo "no JRE in $DIR/usr/lib/jvm" >&2 +exit 127 EOF USER nobody ENTRYPOINT ["/plugin"] diff --git a/registry/community/scalapb-scala/Dockerfile b/registry/community/scalapb-scala/Dockerfile index 7465e84..062d709 100644 --- a/registry/community/scalapb-scala/Dockerfile +++ b/registry/community/scalapb-scala/Dockerfile @@ -19,7 +19,12 @@ COPY --link --from=base / / COPY --link --from=build /protoc-gen-scala.jar . COPY --chmod=0755 <<"EOF" /plugin #!/bin/sh -exec /usr/bin/java -jar /protoc-gen-scala.jar "$@" +# Paths are relative to this script: the service runs the bundle from its +# plugins directory, not from the image it was built in. +DIR=${0%/*} +for JAVA in "$DIR"/usr/lib/jvm/*/bin/java; do exec "$JAVA" -jar "$DIR/protoc-gen-scala.jar" "$@"; done +echo "no JRE in $DIR/usr/lib/jvm" >&2 +exit 127 EOF USER nobody ENTRYPOINT ["/plugin"] diff --git a/registry/community/scalapb-zio-grpc/Dockerfile b/registry/community/scalapb-zio-grpc/Dockerfile index 3ac6074..12d2330 100644 --- a/registry/community/scalapb-zio-grpc/Dockerfile +++ b/registry/community/scalapb-zio-grpc/Dockerfile @@ -19,7 +19,12 @@ COPY --from=base --link / / COPY --from=build --link /protoc-gen-zio.jar . COPY --chmod=0755 <<"EOF" /plugin #!/bin/sh -exec /usr/bin/java -jar /protoc-gen-zio.jar "$@" +# Paths are relative to this script: the service runs the bundle from its +# plugins directory, not from the image it was built in. +DIR=${0%/*} +for JAVA in "$DIR"/usr/lib/jvm/*/bin/java; do exec "$JAVA" -jar "$DIR/protoc-gen-zio.jar" "$@"; done +echo "no JRE in $DIR/usr/lib/jvm" >&2 +exit 127 EOF USER nobody ENTRYPOINT ["/plugin"] diff --git a/registry/community/stephenh-ts-proto/Dockerfile b/registry/community/stephenh-ts-proto/Dockerfile index 63261a7..255993d 100644 --- a/registry/community/stephenh-ts-proto/Dockerfile +++ b/registry/community/stephenh-ts-proto/Dockerfile @@ -20,7 +20,10 @@ COPY --link --from=node --chmod=0755 /nodejs/bin/node /nodejs/bin/node COPY --link --from=build /app /app COPY --chmod=0755 <<"EOF" /plugin #!/bin/sh -exec /nodejs/bin/node /app/node_modules/.bin/protoc-gen-ts_proto "$@" +# Paths are relative to this script: the service runs the bundle from its +# plugins directory, not from the image it was built in. +DIR=${0%/*} +exec "$DIR/nodejs/bin/node" "$DIR/app/node_modules/.bin/protoc-gen-ts_proto" "$@" EOF USER nobody ENTRYPOINT ["/plugin"] diff --git a/registry/community/timostamm-protobuf-ts/Dockerfile b/registry/community/timostamm-protobuf-ts/Dockerfile index 30dbaba..07cf33c 100644 --- a/registry/community/timostamm-protobuf-ts/Dockerfile +++ b/registry/community/timostamm-protobuf-ts/Dockerfile @@ -20,7 +20,10 @@ COPY --link --from=build --chmod=0755 /app/protoc-gen-ts.js /app/protoc-gen-ts.j COPY --link --from=build /app/node_modules/typescript /app/node_modules/typescript COPY --chmod=0755 <<"EOF" /plugin #!/bin/sh -exec /nodejs/bin/node /app/protoc-gen-ts.js "$@" +# Paths are relative to this script: the service runs the bundle from its +# plugins directory, not from the image it was built in. +DIR=${0%/*} +exec "$DIR/nodejs/bin/node" "$DIR/app/protoc-gen-ts.js" "$@" EOF USER nobody ENTRYPOINT ["/plugin"] diff --git a/registry/connectrpc/es/Dockerfile b/registry/connectrpc/es/Dockerfile index d408901..652b9c3 100644 --- a/registry/connectrpc/es/Dockerfile +++ b/registry/connectrpc/es/Dockerfile @@ -22,7 +22,10 @@ COPY --link --from=build --chmod=0755 /app/protoc-gen-connect-es.js /app/protoc- COPY --link --from=build /app/node_modules/typescript /app/node_modules/typescript COPY --chmod=0755 <<"EOF" /plugin #!/bin/sh -exec /nodejs/bin/node /app/protoc-gen-connect-es.js "$@" +# Paths are relative to this script: the service runs the bundle from its +# plugins directory, not from the image it was built in. +DIR=${0%/*} +exec "$DIR/nodejs/bin/node" "$DIR/app/protoc-gen-connect-es.js" "$@" EOF USER nobody ENTRYPOINT ["/plugin"] diff --git a/registry/connectrpc/kotlin/Dockerfile b/registry/connectrpc/kotlin/Dockerfile index bd765aa..4bc8457 100644 --- a/registry/connectrpc/kotlin/Dockerfile +++ b/registry/connectrpc/kotlin/Dockerfile @@ -22,7 +22,12 @@ COPY --from=build --link --chmod=0755 --chown=root:root /app/protoc-gen-connect- COPY --from=maven-deps /root/.m2/repository /maven-repository COPY --chmod=0755 <<"EOF" /plugin #!/bin/sh -exec /usr/bin/java -jar /protoc-gen-connect-kotlin.jar "$@" +# Paths are relative to this script: the service runs the bundle from its +# plugins directory, not from the image it was built in. +DIR=${0%/*} +for JAVA in "$DIR"/usr/lib/jvm/*/bin/java; do exec "$JAVA" -jar "$DIR/protoc-gen-connect-kotlin.jar" "$@"; done +echo "no JRE in $DIR/usr/lib/jvm" >&2 +exit 127 EOF USER nobody ENTRYPOINT ["/plugin"] diff --git a/registry/connectrpc/query-es/Dockerfile b/registry/connectrpc/query-es/Dockerfile index 5f676cc..53f8dca 100644 --- a/registry/connectrpc/query-es/Dockerfile +++ b/registry/connectrpc/query-es/Dockerfile @@ -22,7 +22,10 @@ COPY --link --from=build --chmod=0755 /app/protoc-gen-connect-query.js /app/prot COPY --link --from=build /app/node_modules/typescript /app/node_modules/typescript COPY --chmod=0755 <<"EOF" /plugin #!/bin/sh -exec /nodejs/bin/node /app/protoc-gen-connect-query.js "$@" +# Paths are relative to this script: the service runs the bundle from its +# plugins directory, not from the image it was built in. +DIR=${0%/*} +exec "$DIR/nodejs/bin/node" "$DIR/app/protoc-gen-connect-query.js" "$@" EOF USER nobody ENTRYPOINT ["/plugin"] diff --git a/registry/grpc/java/Dockerfile b/registry/grpc/java/Dockerfile index 160df66..b9f7645 100644 --- a/registry/grpc/java/Dockerfile +++ b/registry/grpc/java/Dockerfile @@ -31,7 +31,10 @@ COPY --link --from=build --chmod=0755 --chown=root:root /build/protoc-gen-grpc-j COPY --from=maven-deps /root/.m2/repository /maven-repository COPY --chmod=0755 <<"EOF" /plugin #!/bin/sh -exec /protoc-gen-grpc-java "$@" +# Paths are relative to this script: the service runs the bundle from its +# plugins directory, not from the image it was built in. +DIR=${0%/*} +exec "$DIR/protoc-gen-grpc-java" "$@" EOF USER nobody ENTRYPOINT ["/plugin"] diff --git a/registry/grpc/kotlin/Dockerfile b/registry/grpc/kotlin/Dockerfile index f365339..986e8ab 100644 --- a/registry/grpc/kotlin/Dockerfile +++ b/registry/grpc/kotlin/Dockerfile @@ -24,7 +24,12 @@ COPY --link --from=build --chmod=0644 --chown=root:root /build/protoc-gen-grpc-k COPY --from=maven-deps /root/.m2/repository /maven-repository COPY --chmod=0755 <<"EOF" /plugin #!/bin/sh -exec /usr/bin/java -jar /protoc-gen-grpc-kotlin.jar "$@" +# Paths are relative to this script: the service runs the bundle from its +# plugins directory, not from the image it was built in. +DIR=${0%/*} +for JAVA in "$DIR"/usr/lib/jvm/*/bin/java; do exec "$JAVA" -jar "$DIR/protoc-gen-grpc-kotlin.jar" "$@"; done +echo "no JRE in $DIR/usr/lib/jvm" >&2 +exit 127 EOF USER nobody ENTRYPOINT ["/plugin"] diff --git a/registry/grpc/node/Dockerfile b/registry/grpc/node/Dockerfile index 464774a..5458060 100644 --- a/registry/grpc/node/Dockerfile +++ b/registry/grpc/node/Dockerfile @@ -18,7 +18,10 @@ COPY --link --from=base / / COPY --link --from=build --chmod=0755 /build/grpc_node_plugin . COPY --chmod=0755 <<"EOF" /plugin #!/bin/sh -exec /grpc_node_plugin "$@" +# Paths are relative to this script: the service runs the bundle from its +# plugins directory, not from the image it was built in. +DIR=${0%/*} +exec "$DIR/grpc_node_plugin" "$@" EOF USER nobody ENTRYPOINT ["/plugin"]