From d876573cae735289a634bee1ebe4b73660a11a0f Mon Sep 17 00:00:00 2001 From: Edgar Sipki Date: Sat, 3 Oct 2026 10:02:50 +0300 Subject: [PATCH 1/5] plugins: unpack image dumps, run on trixie, give each run a writable cwd Run through the service's own path, 47 of the 80 catalogue plugins never worked. Three causes, all on the service side for 25 of them: - plugarchive refused any archive holding an absolute symlink, and every image dump holds some (/etc/localtime, the loader under lib64). Such links are now skipped; the entrypoint stays refused. The write-through-a-symlink-chain escape (x -> ., l1 -> x/.., l1/evil) is closed by resolving each entry's parent and every created link against the root. - protoc's own plugins need glibc 2.38; the image was bookworm (2.36). The runtime stage is trixie-slim now. glibc is backward compatible; the service binary is static. - plugins inherited the service's cwd, / and unwritable; betterproto writes there. Each run gets a private working directory under plugins_dir/.tmp. Checked against all 80 latest archives in the rebuilt image: 33 -> 58 work, no regressions (same output per plugin). The other 22 exec absolute paths in their wrappers and need a rebuild. Co-Authored-By: Claude Opus 5.5 (1M context) --- Dockerfile | 14 +++- internal/adapters/registry/registry.go | 54 ++++++++++---- internal/adapters/registry/workdir_test.go | 64 +++++++++++++++++ internal/plugarchive/plugarchive.go | 82 +++++++++++++++++++++- internal/plugarchive/plugarchive_test.go | 47 +++++++++++++ 5 files changed, 244 insertions(+), 17 deletions(-) create mode 100644 internal/adapters/registry/workdir_test.go 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/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) +} From 52808e71d4f65378381429a4dcb181ea71fd3dbd Mon Sep 17 00:00:00 2001 From: Edgar Sipki Date: Sat, 3 Oct 2026 10:09:45 +0300 Subject: [PATCH 2/5] plugins build: verify every bundle the way the service will run it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A version now builds only if it also survives what the service does to it: - absolute symlinks pointing at files the bundle carries are rewritten as relative ones (a Debian JRE reads java.security through one), the rest are removed; - the bundle is packed and unpacked with plugarchive, so an archive the service would refuse fails here rather than after a push; - the entrypoint is run in the service's base image (debian:trixie-slim, kept equal to the Dockerfile by a test) as uid 65532, with an empty environment, on a synthetic CodeGeneratorRequest; a CodeGeneratorResponse — even one carrying an error — passes. A version that fails is emptied down to its build.log, so push, register and the build cache no longer see an entrypoint for it. plugin.yaml gains an optional `smoke` block (parameter, skip); flags --runtime-image and --no-smoke. Checked end to end: grpc/go builds and passes; bufbuild/es, whose wrapper still execs /nodejs/bin/node, is refused with that message and leaves only its log. Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/easyp-svc/build.go | 23 ++- cmd/easyp-svc/main.go | 16 ++ cmd/easyp-svc/verify.go | 332 +++++++++++++++++++++++++++++++++++ cmd/easyp-svc/verify_test.go | 178 +++++++++++++++++++ 4 files changed, 545 insertions(+), 4 deletions(-) create mode 100644 cmd/easyp-svc/verify.go create mode 100644 cmd/easyp-svc/verify_test.go 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) +} From feb84193649f273a437dc6a7248bccc2b3ff4b11 Mon Sep 17 00:00:00 2001 From: Edgar Sipki Date: Sat, 3 Oct 2026 10:36:52 +0300 Subject: [PATCH 3/5] registry: make the 22 interpreted and wrapped plugins relocatable Their wrappers exec'd paths that only existed inside the build image (/usr/bin/java, /nodejs/bin/node, /app/...); the service runs the unpacked bundle from plugins_dir on its own base, so none of them could start. Each wrapper now resolves everything from DIR=${0%/*}: - JVM: the bundle's own JRE, found as usr/lib/jvm/*/bin/java; - Node: the bundle's node with the script path; - Python: the bundle's interpreter under the bundle's own dynamic loader (glibc or musl), so its libraries never mix with the host's; - native: the binary beside the wrapper. Inside the image $0 is /plugin and DIR is empty, so the paths read as before. bufbuild/connect-kotlin's download stage moves off a bullseye snapshot whose security pool now 404s. Rebuilt with smoke checks on: the latest version of all 22, and all 9 versions of connect-kotlin across its three Dockerfiles, run in debian:trixie-slim. README and AGENTS.md describe the bundle contract and the build checks. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 1 + README.md | 33 +++++++++++++++++++ registry/apple/servicetalk/Dockerfile | 7 +++- registry/bufbuild/connect-es/Dockerfile | 5 ++- registry/bufbuild/connect-kotlin/Dockerfile | 13 ++++++-- .../bufbuild/connect-kotlin/Dockerfile.github | 13 ++++++-- .../bufbuild/connect-kotlin/Dockerfile.source | 7 +++- registry/bufbuild/connect-query/Dockerfile | 5 ++- registry/bufbuild/connect-web/Dockerfile | 5 ++- registry/bufbuild/es/Dockerfile | 5 ++- registry/bufbuild/knit-ts/Dockerfile | 5 ++- .../danielgtaylor-betterproto/Dockerfile | 7 +++- registry/community/nanopb/Dockerfile | 10 +++++- .../community/nipunn1313-mypy-grpc/Dockerfile | 10 +++++- registry/community/nipunn1313-mypy/Dockerfile | 10 +++++- .../salesforce-reactive-grpc/Dockerfile | 7 +++- registry/community/scalapb-scala/Dockerfile | 7 +++- .../community/scalapb-zio-grpc/Dockerfile | 7 +++- .../community/stephenh-ts-proto/Dockerfile | 5 ++- .../timostamm-protobuf-ts/Dockerfile | 5 ++- registry/connectrpc/es/Dockerfile | 5 ++- registry/connectrpc/kotlin/Dockerfile | 7 +++- registry/connectrpc/query-es/Dockerfile | 5 ++- registry/grpc/java/Dockerfile | 5 ++- registry/grpc/kotlin/Dockerfile | 7 +++- registry/grpc/node/Dockerfile | 5 ++- 26 files changed, 173 insertions(+), 28 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e4a8260..5150f8b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/README.md b/README.md index 2ad9f39..7c86f47 100644 --- a/README.md +++ b/README.md @@ -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. 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"] From d0d4cd814da83de1c264be4c2d8e31f0e8f2f3db Mon Sep 17 00:00:00 2001 From: Edgar Sipki Date: Mon, 5 Oct 2026 08:17:30 +0300 Subject: [PATCH 4/5] release: v1.1.0 Every catalogue plugin now runs in the service: the image moves to trixie, plugarchive skips absolute symlinks and closes the symlink-chain escape, each run gets a private working directory, plugins build verifies every bundle the way the service runs it, and the 22 wrapped plugins resolve paths from their own directory. Minor rather than patch because the runtime base changed. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 2 +- README.md | 6 +++--- deploy/.env.dev.example | 4 ++-- deploy/charts/easyp-service/Chart.yaml | 6 +++--- deploy/charts/easyp-service/README.md | 2 +- deploy/docker-compose.dev.yml | 4 ++-- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5150f8b..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. diff --git a/README.md b/README.md index 7c86f47..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 ``` @@ -1012,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/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}" From 36b8d82a385349463fa977d150a6e591c18ab736 Mon Sep 17 00:00:00 2001 From: Edgar Sipki Date: Mon, 5 Oct 2026 08:25:13 +0300 Subject: [PATCH 5/5] deps: OpenTelemetry v1.47.0 for GO-2026-6505 govulncheck flagged the OTLP trace exporter (v1.43.0): its config logging can leak endpoint URLs into info logs, and the service calls it from telemetry.Init. Fixed upstream in v1.45.0; the otel modules move together to v1.47.0, which is what go mod tidy settles on with otelgrpc v0.70.0. Co-Authored-By: Claude Opus 5.5 (1M context) --- go.mod | 50 +++++++++++++-------------- go.sum | 105 +++++++++++++++++++++++++++++---------------------------- 2 files changed, 78 insertions(+), 77 deletions(-) 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=