From 2b1234a404822688626cee6a64fd1c5e68bb3c96 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:49:52 -0400 Subject: [PATCH 01/13] feat(imagepolicy): add target policies and CGO-aware semantic options --- AGENTS.md | 4 +- code/codeserver/codeserver_test.go | 31 +++ code/codeserver/doc.go | 8 +- code/codeserver/semantics_cgo.go | 17 +- code/codeserver/semantics_nosemantic.go | 10 +- code/codeserver/semantics_nosemantic_test.go | 33 +++ companions/imagepolicy_test.go | 133 ++++++++++++ docs/cgo.md | 20 ++ docs/image-policy.md | 80 +++++++ go.mod | 14 +- go.sum | 30 ++- imagepolicy/policy.go | 209 +++++++++++++++++++ imagepolicy/policy_test.go | 179 ++++++++++++++++ imagepolicy/regression_test.go | 71 +++++++ 14 files changed, 816 insertions(+), 23 deletions(-) create mode 100644 code/codeserver/semantics_nosemantic_test.go create mode 100644 companions/imagepolicy_test.go create mode 100644 docs/image-policy.md create mode 100644 imagepolicy/policy.go create mode 100644 imagepolicy/policy_test.go create mode 100644 imagepolicy/regression_test.go diff --git a/AGENTS.md b/AGENTS.md index 47d0cfa5f..4299e1edb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,8 +90,7 @@ image core alone cannot build. Run them locally before touching that surface. ## Where things live -`resources/` is the source of truth for every type. When unsure how something is -modelled, read it there first. +`resources/` is the source of truth for every type; read it there first. | Path | Owns | | --- | --- | @@ -102,6 +101,7 @@ modelled, read it there first. | `network/` | deterministic port allocation, DNS, native/container/public modes | | `configurations/` | what services provide and consume, injected as env vars | | `runners/`, `companions/` | process execution; sidecar images for language tooling | +| `imagepolicy/` | target-declared Dockerfile requirements ([docs](docs/image-policy.md)) | | `solutionhost/` | `ModuleManifest`, the one signed record of a module instance on a host: its workloads, endpoints and requested authority ([docs](docs/module-manifest.md)) | | `contracts/module/` | the ONE implementation of `codefly/module-contract/v1` (Go package `module`, imported as `modulecontract`) — the request a module publishes, strict decoding, every refusal as a named rule (`rules.go`), slot resolution, a serialisable model, shipped fixtures every reader is driven through and a self-check that deletes each rule and proves a fixture notices ([docs](docs/module-contract.md)). The cell (`codefly/cell/v1`) has no implementation in core | | `code/semantic` | the **only** package allowed to use cgo (tree-sitter) | diff --git a/code/codeserver/codeserver_test.go b/code/codeserver/codeserver_test.go index cbe023c99..de82a7b9f 100644 --- a/code/codeserver/codeserver_test.go +++ b/code/codeserver/codeserver_test.go @@ -7,6 +7,7 @@ import ( "path/filepath" "testing" + "github.com/codefly-dev/core/code" "github.com/codefly-dev/core/code/codeserver" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" codev0 "github.com/codefly-dev/core/generated/go/codefly/services/code/v0" @@ -36,3 +37,33 @@ func TestNewInstallsSemanticAnalyzer(t *testing.T) { t.Fatalf("semantic index = %s, failure = %#v", encoded, response.GetFailure()) } } + +// TestSemanticOptionsInstallsAnalyzer verifies the options provider carries the +// same analyzer New does, so a consumer that assembles its own +// DefaultCodeServer gets source semantics without naming core/code/semantic — +// the import that put an unconditional cgo edge in an agent. The +// codefly_nosemantic variant returns no options and is exercised by the +// CGO-free build guard (scripts/check_cgo_free.sh), not here. +func TestSemanticOptionsInstallsAnalyzer(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "app.py"), []byte("import requests\nclass Client: pass\n"), 0o644); err != nil { + t.Fatal(err) + } + + options := codeserver.SemanticOptions() + if len(options) == 0 { + t.Fatal("SemanticOptions returned no options on a CGO build") + } + + response, err := code.NewDefaultCodeServer(root, options...).Execute(t.Context(), &codev0.CodeRequest{ + Operation: &codev0.CodeRequest_GetSemanticIndex{GetSemanticIndex: &codev0.GetSemanticIndexRequest{}}, + }) + if err != nil { + t.Fatal(err) + } + index := response.GetGetSemanticIndex() + if index == nil || index.GetState() != basev0.SemanticIndexState_SEMANTIC_INDEX_STATE_COMPLETE { + encoded, _ := protojson.Marshal(index) + t.Fatalf("semantic index = %s, failure = %#v", encoded, response.GetFailure()) + } +} diff --git a/code/codeserver/doc.go b/code/codeserver/doc.go index a5055e0d4..0d9d813ef 100644 --- a/code/codeserver/doc.go +++ b/code/codeserver/doc.go @@ -2,8 +2,12 @@ // (the CLI, the gateway) run, deciding at build time whether to link the // tree-sitter semantic analyzer. // -// New is the single entry point, so consumers never reinvent the cgo/no-cgo -// split themselves. On a normal build it installs core/code/semantic — the +// New and SemanticOptions are the only entry points, so consumers never +// reinvent the cgo/no-cgo split themselves: New when codeserver builds the +// server, SemanticOptions when the consumer builds it and needs the analyzer +// option alone. Naming core/code/semantic directly instead pins a cgo edge the +// tag cannot remove, one repo away from the guard that would have caught it. +// On a normal build it installs core/code/semantic — the // tree-sitter CGO stack — so the server answers source-semantics operations. // Built with -tags codefly_nosemantic it returns a plain DefaultCodeServer that // never imports semantic, keeping the binary CGO-free (linkable with diff --git a/code/codeserver/semantics_cgo.go b/code/codeserver/semantics_cgo.go index 42bd07e55..02d78ae5c 100644 --- a/code/codeserver/semantics_cgo.go +++ b/code/codeserver/semantics_cgo.go @@ -7,11 +7,26 @@ import ( "github.com/codefly-dev/core/code/semantic" ) +// SemanticOptions returns the ServerOption values that install the tree-sitter +// semantic analyzer. It exists because a consumer that builds its own +// DefaultCodeServer needs the options, not a finished server: without it the +// only way to reach the analyzer was to import core/code/semantic directly, +// which is how an agent acquired an unconditional cgo edge that +// scripts/check_cgo_free.sh cannot see — it guards core's packages, and the +// import was one repo away. Take this instead of naming semantic.New yourself. +// +// Importing core/code/semantic pulls the tree-sitter CGO stack, so this variant +// cannot link under CGO_ENABLED=0. Build with -tags codefly_nosemantic to +// select the CGO-free variant, which returns no options. +func SemanticOptions() []code.ServerOption { + return []code.ServerOption{code.WithSemanticAnalyzer(semantic.New())} +} + // New returns a DefaultCodeServer with the tree-sitter semantic analyzer // installed. Importing core/code/semantic pulls the tree-sitter CGO stack, so // this variant cannot link under CGO_ENABLED=0 — an accidental CGO-free build // fails loudly at link time rather than silently dropping source semantics. // Build with -tags codefly_nosemantic to select the CGO-free variant. func New(root string, opts ...code.ServerOption) *code.DefaultCodeServer { - return code.NewDefaultCodeServer(root, append(opts, code.WithSemanticAnalyzer(semantic.New()))...) + return code.NewDefaultCodeServer(root, append(opts, SemanticOptions()...)...) } diff --git a/code/codeserver/semantics_nosemantic.go b/code/codeserver/semantics_nosemantic.go index 39923b9fe..4c29c2eef 100644 --- a/code/codeserver/semantics_nosemantic.go +++ b/code/codeserver/semantics_nosemantic.go @@ -4,10 +4,18 @@ package codeserver import "github.com/codefly-dev/core/code" +// SemanticOptions returns no options: this variant never imports +// core/code/semantic, so the binary links with CGO_ENABLED=0. A server built +// from these options reports an unsupported-operation failure for +// source-semantics requests, which is the same answer New gives here. +func SemanticOptions() []code.ServerOption { + return nil +} + // New returns a plain DefaultCodeServer with no semantic analyzer. Selected by // the codefly_nosemantic build tag, this variant never imports // core/code/semantic, so the binary links with CGO_ENABLED=0. Source-semantics // operations report an unsupported-operation failure. func New(root string, opts ...code.ServerOption) *code.DefaultCodeServer { - return code.NewDefaultCodeServer(root, opts...) + return code.NewDefaultCodeServer(root, append(opts, SemanticOptions()...)...) } diff --git a/code/codeserver/semantics_nosemantic_test.go b/code/codeserver/semantics_nosemantic_test.go new file mode 100644 index 000000000..707e9e5bf --- /dev/null +++ b/code/codeserver/semantics_nosemantic_test.go @@ -0,0 +1,33 @@ +//go:build codefly_nosemantic + +package codeserver_test + +import ( + "testing" + + "github.com/codefly-dev/core/code" + "github.com/codefly-dev/core/code/codeserver" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + codev0 "github.com/codefly-dev/core/generated/go/codefly/services/code/v0" +) + +func TestSemanticOptionsWithoutCGO(t *testing.T) { + options := codeserver.SemanticOptions() + if len(options) != 0 { + t.Fatal("SemanticOptions installed an analyzer in a no-semantic build") + } + for _, server := range []*code.DefaultCodeServer{ + codeserver.New(t.TempDir()), + code.NewDefaultCodeServer(t.TempDir(), options...), + } { + response, err := server.Execute(t.Context(), &codev0.CodeRequest{ + Operation: &codev0.CodeRequest_GetSemanticIndex{GetSemanticIndex: &codev0.GetSemanticIndexRequest{}}, + }) + if err != nil { + t.Fatal(err) + } + if response.GetFailure().GetCode() != basev0.FailureCode_FAILURE_CODE_UNSUPPORTED_OPERATION { + t.Fatalf("expected unsupported semantics, got %v", response) + } + } +} diff --git a/companions/imagepolicy_test.go b/companions/imagepolicy_test.go new file mode 100644 index 000000000..d44342ea1 --- /dev/null +++ b/companions/imagepolicy_test.go @@ -0,0 +1,133 @@ +package companions_test + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/codefly-dev/core/companions" + "github.com/codefly-dev/core/imagepolicy" + "github.com/stretchr/testify/require" +) + +// unpinnedUpstreamBases are the companion bases that still float on a tag. +// +// Every entry is a build whose inputs change under it without anything saying +// so: the tag moves, the next build is different bytes, and a scan of the +// previous one described an image that no longer exists. These are build-time +// images rather than deployed ones, which is why they were never caught — but +// they are what compiles and generates user code. +// +// The list exists so the invariant can be turned on NOW, before the pinning is +// done, instead of waiting for it. Adding an entry is a deliberate, reviewed +// decision and the only correct direction is down. Pinning one means deleting +// its line: TestEveryAllowedUnpinnedBaseIsStillUnpinned fails on an entry that +// no longer applies, so the list cannot quietly outlive the debt. +// +// Pinning these is not a drive-by edit. Companion images are a separate release +// contract — built from their own manifest and published by the CLI, not by the +// core release flow — so moving a base means republishing the companion. See +// docs/runbooks/publish-companions.md. +var unpinnedUpstreamBases = []string{ + "alpine:3.24.2", + "docker:29-dind", + "golang:1.27-alpine", + "node:26.3.0-alpine3.22", + "python:3.14.1-alpine3.21", + "rust:1.99-alpine", +} + +// corePolicy is what core requires of its own images. It is an ordinary +// imagepolicy.Policy, the same shape a deployment target declares, so this test +// also demonstrates the package against real Dockerfiles rather than fixtures. +// +// No registry allow-list: where a companion is published is the builder's +// decision, and TestRegistryLiteralsAreConfinedToTheBaseImageDefault already +// owns the one reference core still carries. No required labels: a companion is +// not deployed into a customer's cluster, so a compliance programme's labels do +// not apply to it. +var corePolicy = imagepolicy.Policy{RequireDigest: true} + +// refusals runs the policy over one companion and drops the base-image +// argument's default. +// +// That default is a tag on purpose: the publisher overrides it with the digest +// it just pushed, so the tag in the file is not a claim about what gets built. +// The same carve-out, for the same reason, as the registry-literal test. +func refusals(t *testing.T, spec companions.BuildSpec) []imagepolicy.Refusal { + t.Helper() + content := dockerfile(t, spec) + baseDefault := "" + for _, instruction := range companions.Instructions(content) { + text := strings.TrimSpace(instruction.Text) + if prefix := "ARG " + companions.BaseImageArg + "="; strings.HasPrefix(text, prefix) { + baseDefault = strings.Trim(strings.TrimPrefix(text, prefix), `"'`) + } + } + var kept []imagepolicy.Refusal + for _, refusal := range imagepolicy.Check(content, corePolicy) { + if spec.Base != "" && baseDefault != "" && refusal.Detail == baseDefault && errors.Is(refusal, imagepolicy.ErrUnpinnedBase) { + continue + } + kept = append(kept, refusal) + } + return kept +} + +// These files ship scratch-based builders and the publish action's smoke image. +// Apply the same policy so replacing scratch with a floating base fails here. +func TestCoreImageTemplatesSatisfyPolicy(t *testing.T) { + for _, path := range []string{ + "runners/golang/templates/builder/Dockerfile.tmpl", + "runners/rust/templates/builder/Dockerfile.tmpl", + ".github/actions/publish-service-image/testdata/Dockerfile", + } { + t.Run(path, func(t *testing.T) { + content, err := os.ReadFile(filepath.Join(repositoryRoot(t), path)) + require.NoError(t, err) + require.Empty(t, imagepolicy.Check(string(content), corePolicy)) + }) + } +} + +// A companion base that is neither pinned nor a known debt entry is a new +// floating input, and it fails here rather than in whatever build first +// notices the tag moved. +func TestNoNewUnpinnedCompanionBase(t *testing.T) { + allowed := make(map[string]bool, len(unpinnedUpstreamBases)) + for _, base := range unpinnedUpstreamBases { + allowed[base] = true + } + for _, spec := range buildSpecs(t) { + for _, refusal := range refusals(t, spec) { + // The debt list excuses a floating tag and nothing else: any other + // rule failing is a finding whatever the reference is. + if errors.Is(refusal, imagepolicy.ErrUnpinnedBase) && allowed[refusal.Detail] { + continue + } + require.Failf(t, "companion image policy", + "%s:%d %v. Pin the base by digest; add it to unpinnedUpstreamBases "+ + "only as a reviewed exception.", + spec.Dockerfile, refusal.Line, refusal) + } + } +} + +// The debt list shrinks or it rots. An entry that no longer appears unpinned in +// any companion has been pinned (or removed), and leaving it behind would let +// the next floating base of the same name back in unnoticed. +func TestEveryAllowedUnpinnedBaseIsStillUnpinned(t *testing.T) { + seen := map[string]bool{} + for _, spec := range buildSpecs(t) { + for _, refusal := range refusals(t, spec) { + seen[refusal.Detail] = true + } + } + for _, base := range unpinnedUpstreamBases { + require.Truef(t, seen[base], + "unpinnedUpstreamBases lists %q, but no companion names it unpinned any more. "+ + "Delete the entry in the change that pinned it.", base) + } +} diff --git a/docs/cgo.md b/docs/cgo.md index 09d4ca04e..0f7cfa8e9 100644 --- a/docs/cgo.md +++ b/docs/cgo.md @@ -51,6 +51,26 @@ srv := codeserver.New(root) // + optional code.ServerOption values `CGO_ENABLED=0`. Source-semantics operations report an unsupported-operation failure. +### When you build the server yourself + +A consumer that assembles its own `DefaultCodeServer` — because it adds options +of its own, or wraps the server — needs the *options*, not a finished server. +Take them from `codeserver`, which splits on the same tag: + +```go +opts := append(myOptions, codeserver.SemanticOptions()...) +srv := code.NewDefaultCodeServer(root, opts...) +``` + +`SemanticOptions` returns the analyzer option on a default build and **nothing** +under `-tags codefly_nosemantic`. Never name `semantic.New()` to fill this gap: +that import is unconditional, so it pins a cgo edge the tag cannot remove. It is +also invisible to `scripts/check_cgo_free.sh`, which proves *core's* packages +link CGO-free and cannot see a consumer's import graph — so the break surfaces +as a failed `CGO_ENABLED=0` link in another repo, long after the import landed. +That is not hypothetical: it is how the service agents came to require cgo, and +why a static (scratch-based) agent image was not buildable. + `GetProjectInfo` is the exception, because it is not a source-semantics operation: it reports dependencies, packages and file hashes, and only its per-file import inventory needs a parser. Go takes that inventory with diff --git a/docs/image-policy.md b/docs/image-policy.md new file mode 100644 index 000000000..1be48cc36 --- /dev/null +++ b/docs/image-policy.md @@ -0,0 +1,80 @@ +# Image policy + +`imagepolicy.Check(dockerfile, policy)` checks Dockerfile defaults against a +policy supplied by a deployment target. Requirements are data: Core contains +no customer registry or compliance-label roster. A zero `Policy` requires +nothing and performs no parsing. + +## Rules + +The checker returns all base-reference refusals in source order, followed by +missing final-stage labels in policy order. Each `Refusal` carries a line +(zero for file-wide findings), detail and a named error usable with `errors.Is`. + +| Policy | Refusal | Meaning | +| --- | --- | --- | +| `RequireDigest` | `ErrUnpinnedBase` | a FROM base has no valid content digest | +| `AllowedRegistries` | `ErrRegistryNotAllowed` | a FROM base is outside the allowed registry hosts/repository paths | +| `RequiredLabels` | `ErrMissingLabel` | the final stage lacks a required label with a known, non-empty value | +| either reference rule | `ErrUnresolvedBase` | a base is invalid or depends on an argument without a known default | +| any active policy | `ErrInvalidDockerfile` | BuildKit cannot parse the Dockerfile, or it has no stage | + +Registry matching uses normalized image names and complete path components: +`registry.example/team` accepts `registry.example/team/app`, but neither +`registry.example/team-evil/app` nor `registry.example.evil/team/app`. +`alpine` normalizes to `docker.io/library/alpine`. An empty registry list permits +any registry; an empty entry in a non-empty list permits none. + +The checker reuses BuildKit's Dockerfile parser, instruction model and shell +expander, plus `distribution/reference` for image names and digests. This keeps +heredoc contents out of the instruction stream and handles continuation lines, +escape directives and quoted or legacy label syntax without another parser. + +`scratch` needs neither a digest nor a registry. A reference to an earlier stage +inherits that stage's labels and variables; naming a stage does not exempt its +own external base. Only global ARG defaults resolve FROM instructions. Stage +ARG/ENV values resolve labels, with ENV taking precedence. Unrelated builder +labels do not satisfy the final stage, and an empty or unknown overwrite removes +a label's known value. Inherited stage maps are copied so sibling stages cannot +change one another's labels. + +## Source-check boundary + +This is a check of **Dockerfile defaults**, not a certificate for a built image. +It checks all FROM instructions and the last stage's labels. It does not read +external base-image labels, build argument overrides, automatic platform +arguments, named build contexts, external COPY/ADD sources or an alternate +`--target`. Unknown argument values (including shell fallback expressions over +undeclared variables) are refused conservatively when a reference rule needs +them. Declare defaults explicitly when checking source. + +A build integration must validate its effective inputs and selected target. +Core does not yet connect this package to CLI builds, carry target policy in +coordinate declarations, or switch vulnerability auditing to enforcement. +Those changes belong to the CLI and policy producers, once the build location +and policy delivery are decided; see [configuration-contract.md](configuration-contract.md). + +Artifact scanning is complementary. The existing `builderv0.AuditRequest` +`fail_on_vuln` flag asks for `state=ERROR` on HIGH-or-above findings and defaults +to false (report-only). `agents/services.BuilderWrapper.AuditContainer` applies +that contract to Trivy image reports. A target needs both source/input checks +and artifact auditing; passing this package alone does not prove an image is +vulnerability-free or compliant. + +## Core's own images + +`companions/imagepolicy_test.go` runs `RequireDigest` over every companion +Dockerfile, both runner builder templates (`runners/golang` and `runners/rust`), +and the publish-service-image action's smoke-test Dockerfile. The latter three +use scratch and require no exceptions. + +The companion base-image argument's tag default is exempted only from the +unpinned-base refusal: the publisher overrides it with the digest it just +published. Existing companion build-spec tests enforce that default's shape. + +Six distinct floating upstream bases remain explicitly listed in +`unpinnedUpstreamBases`. This is a **temporary stopgap**, not completed pinning: +new references fail and stale exceptions fail once no companion uses them. +Pinning them requires the companion build/publish contract in +[runbooks/publish-companions.md](runbooks/publish-companions.md). No companion +images are rebuilt or published by adding this guard. diff --git a/go.mod b/go.mod index dd72734c9..f9d4043e9 100644 --- a/go.mod +++ b/go.mod @@ -37,6 +37,7 @@ require ( github.com/grpc-ecosystem/grpc-gateway/v2 v2.31.0 github.com/hashicorp/go-multierror v1.1.1 github.com/mitchellh/mapstructure v1.5.0 + github.com/moby/buildkit v0.34.1 github.com/moby/patternmatcher v0.6.1 github.com/muesli/termenv v0.16.0 github.com/opencontainers/image-spec v1.1.1 @@ -88,9 +89,10 @@ require ( code.gitea.io/sdk/gitea v0.23.2 // indirect dario.cat/mergo v1.0.2 // indirect github.com/42wim/httpsig v1.2.4 // indirect - github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29 // indirect github.com/ProtonMail/go-crypto v1.4.1 // indirect github.com/VividCortex/ewma v1.2.0 // indirect + github.com/agext/levenshtein v1.2.3 // indirect github.com/alecthomas/chroma/v2 v2.23.1 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect @@ -106,7 +108,7 @@ require ( github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/cloudflare/circl v1.6.3 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect - github.com/containerd/log v0.1.0 // indirect + github.com/containerd/typeurl/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/davidmz/go-pageant v1.0.2 // indirect github.com/dlclark/regexp2 v1.12.0 // indirect @@ -160,23 +162,22 @@ require ( github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/sys/atomicwriter v0.1.0 // indirect - github.com/moby/sys/sequential v0.7.0 // indirect github.com/moby/term v0.5.2 // indirect github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect - github.com/morikuni/aec v1.1.0 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect github.com/oklog/ulid/v2 v2.1.2 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/pjbgf/sha1cd v0.7.0 // indirect + github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sergi/go-diff v1.4.0 // indirect github.com/shoenig/go-m1cpu v0.2.1 // indirect - github.com/sirupsen/logrus v1.9.4 // indirect github.com/skeema/knownhosts v1.3.2 // indirect github.com/tklauser/numcpus v0.12.0 // indirect + github.com/tonistiigi/go-csvvalue v0.0.0-20240814133006-030d3b2625d0 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xlab/treeprint v1.2.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect @@ -185,7 +186,6 @@ require ( gitlab.com/gitlab-org/api/client-go v1.46.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0 // indirect go.opentelemetry.io/otel/log v1.47.0 // indirect go.opentelemetry.io/otel/metric v1.47.0 // indirect go.opentelemetry.io/proto/otlp v1.11.0 // indirect @@ -195,7 +195,7 @@ require ( golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.23.0 // indirect - golang.org/x/time v0.15.0 // indirect + golang.org/x/time v0.16.0 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gotest.tools/v3 v3.5.1 // indirect diff --git a/go.sum b/go.sum index 1b37d362f..d318e05ff 100644 --- a/go.sum +++ b/go.sum @@ -21,12 +21,14 @@ github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF0 github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= -github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= -github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29 h1:0kQAzHq8vLs7Pptv+7TxjdETLf/nIqJpIB4oC6Ba4vY= +github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29/go.mod h1:ZWa7ssZJT30CCDGJ7fk/2SBTq9BIQrrVjrcss0UW2s0= github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow= github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4= +github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= +github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY= @@ -91,8 +93,10 @@ github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= -github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= -github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/log v0.2.0 h1:BewD/umNgVnoczglOpX8eRMyEy5t5iPlu5AIpnWDONc= +github.com/containerd/log v0.2.0/go.mod h1:/M7L7CXKcPTfNC74XzaK+5H5KbO5+4lJVpuVI6vRLoM= +github.com/containerd/typeurl/v2 v2.3.0 h1:HZHPhRWo5XMy3QGQoPrUzbW/2ckwjfweHmOwlkIrPAQ= +github.com/containerd/typeurl/v2 v2.3.0/go.mod h1:Qk+PAdUYArVj41TnGi6rJ+48RF0PkcTc4i/taoBcK0w= github.com/creativeprojects/go-selfupdate v1.6.0 h1:Bu3cIgdyfI1Pg8XsL8nbaT2uMjfZ8HIoxnBmPJbN0sw= github.com/creativeprojects/go-selfupdate v1.6.0/go.mod h1:Ids8O474XGQG0jZ5vpBIhWffcGYjUP6ccOI0mMcvQbI= github.com/cyphar/filepath-securejoin v0.7.0 h1:s0Y3ITPy6sQn5xt54DuYvTF8hu134ooYLUb58DX/HjE= @@ -266,6 +270,8 @@ github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwX github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/moby/buildkit v0.34.1 h1:jVdxsXX0UpU0q1Et+9lFezPmh9sGE6vF0TrKZaGlfy4= +github.com/moby/buildkit v0.34.1/go.mod h1:e+Za70y2a3nL1U2Y0P8TvhPyKp2Ok2NS+Q4siSArDiM= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= @@ -303,6 +309,8 @@ github.com/pjbgf/sha1cd v0.7.0 h1:ZRNPKHj+gfkLBf0KJv/p1Hmz+7bqCT5o311YX0nq+DA= github.com/pjbgf/sha1cd v0.7.0/go.mod h1:pKR5Li+qTCo+ebqITZfwPlJGoCFCvSO00qxjbNtTUFQ= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= @@ -327,8 +335,8 @@ github.com/shoenig/go-m1cpu v0.2.1/go.mod h1:KkDOw6m3ZJQAPHbrzkZki4hnx+pDRR1Lo+l github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk= github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= -github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo= +github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q= github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg= github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -343,6 +351,8 @@ github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRU github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI= github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4= github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg= +github.com/tonistiigi/go-csvvalue v0.0.0-20240814133006-030d3b2625d0 h1:2f304B10LaZdB8kkVEaoXvAMVan2tl9AiK4G0odjQtE= +github.com/tonistiigi/go-csvvalue v0.0.0-20240814133006-030d3b2625d0/go.mod h1:278M4p8WsNh3n4a1eqiFcV2FGk7wE5fwUpUom9mK9lE= github.com/tree-sitter-grammars/tree-sitter-kotlin v1.1.0 h1:SWIUDASa+WPhDDem1U5IJpYwQEezkqXrUI61OcnORzM= github.com/tree-sitter-grammars/tree-sitter-kotlin v1.1.0/go.mod h1:eH+flFf3QOa9c9BY9g3Bz02F7zTq30kGIG3cgB0lSlI= github.com/tree-sitter/go-tree-sitter v0.25.0 h1:sx6kcg8raRFCvc9BnXglke6axya12krCJF5xJ2sftRU= @@ -405,8 +415,8 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0 h1:julhjPeUH/q/7hinbSd 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/exporters/otlp/otlptrace/otlptracehttp v1.45.0 h1:QBajQ2SrwQijzHyZbQlPsuIzpl/ll8DY6wPWsajeGcI= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0/go.mod h1:08ZQLjrPLQ6R4kAXvuOvODEer5Yh4CoFvll5qB2BCI8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0 h1:KrC1YrQeSt46ITMWAbgQx1M1eV1/1TKzttrBzymPmss= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0/go.mod h1:zDSEzoEqsOrgBeGvH66KRgxh90VonFyJqBHA0Pk3+rM= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.47.0 h1:N3YQCxjxQ/bMjyc3heladfRm9t9RTksGQH8z4w6yU/0= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.47.0/go.mod h1:Mp8HOFqcaUyypCuGv9IhDdTHnJ56lSudSHMd+pVSCEA= go.opentelemetry.io/otel/log v1.47.0 h1:cOTS1CcLbSQeZKanGJ+0JpF/+t4PELi3O3bbl2lqCcI= @@ -468,8 +478,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 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/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= +golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= diff --git a/imagepolicy/policy.go b/imagepolicy/policy.go new file mode 100644 index 000000000..42ac0c345 --- /dev/null +++ b/imagepolicy/policy.go @@ -0,0 +1,209 @@ +// Package imagepolicy checks Dockerfile defaults against requirements supplied +// by a deployment target. It does not build images or replace artifact scanning. +package imagepolicy + +import ( + _ "crypto/sha256" // register the image digest algorithm for reference.Parse + "errors" + "fmt" + "maps" + "strings" + + "github.com/distribution/reference" + "github.com/moby/buildkit/frontend/dockerfile/instructions" + "github.com/moby/buildkit/frontend/dockerfile/parser" + "github.com/moby/buildkit/frontend/dockerfile/shell" +) + +var ( + // ErrUnpinnedBase identifies a base without a content digest. + ErrUnpinnedBase = errors.New("base image is not pinned by digest") + // ErrRegistryNotAllowed identifies a base outside the allowed registry paths. + ErrRegistryNotAllowed = errors.New("base image registry is not allowed") + // ErrMissingLabel identifies a required final-stage label without a known value. + ErrMissingLabel = errors.New("required label is missing") + // ErrUnresolvedBase identifies a base with an unknown default or invalid reference. + ErrUnresolvedBase = errors.New("base image reference cannot be resolved") + // ErrInvalidDockerfile identifies input the Dockerfile parser cannot read. + ErrInvalidDockerfile = errors.New("invalid Dockerfile") +) + +// Policy is what a deployment target requires. A zero Policy requires nothing. +type Policy struct { + // AllowedRegistries contains registry hosts, optionally narrowed to repository + // paths (for example registry.example/team). Matching respects path boundaries. + // Empty permits any registry; familiar references are normalized to docker.io. + AllowedRegistries []string + // RequireDigest refuses a base named by tag alone. + RequireDigest bool + // RequiredLabels contains keys the final stage must set to known non-empty + // values, directly or through an earlier stage in this Dockerfile. + RequiredLabels []string +} + +// Refusal is a named rule failing at a source line (zero for file-wide failures). +type Refusal struct { + Rule error + Line int + Detail string +} + +func (r Refusal) Error() string { + return fmt.Sprintf("line %d: %s: %s", r.Line, r.Rule, r.Detail) +} +func (r Refusal) Unwrap() error { return r.Rule } + +// Check returns all reference refusals, followed by missing final-stage labels. +// It checks every FROM using global ARG defaults, and the last stage's labels. +// Build argument overrides, alternate build targets, external image metadata and +// COPY --from images are outside this source check. Callers must also validate +// effective build inputs and scan the artifact before enforcing deployment policy. +func Check(dockerfile string, policy Policy) []Refusal { + if !policy.RequireDigest && len(policy.AllowedRegistries) == 0 && len(policy.RequiredLabels) == 0 { + return nil + } + parsed, err := parser.Parse(strings.NewReader(dockerfile)) + if err != nil { + return []Refusal{{Rule: ErrInvalidDockerfile, Detail: err.Error()}} + } + stages, metaArgs, err := instructions.Parse(parsed.AST, nil) + if err != nil { + return []Refusal{{Rule: ErrInvalidDockerfile, Detail: err.Error()}} + } + if len(stages) == 0 { + return []Refusal{{Rule: ErrInvalidDockerfile, Detail: "no build stage"}} + } + lex := shell.NewLex(parsed.EscapeToken) + lex.SkipUnsetEnv = true // retain unknown variables rather than certifying an empty substitution + global := values{} + for _, command := range metaArgs { + applyArgs(lex, command, global, nil, nil) + } + var refusals []Refusal + previous := map[string]stageState{} + var current stageState + for _, stage := range stages { + base, resolved := expand(lex, stage.BaseName, global) + parent, internal := previous[strings.ToLower(base)] + current = stageState{labels: values{}, args: values{}, env: values{}} + if resolved && internal { + current = stageState{maps.Clone(parent.labels), maps.Clone(parent.args), maps.Clone(parent.env)} + } else if policy.RequireDigest || len(policy.AllowedRegistries) > 0 { + line := stage.Location[0].Start.Line + if !resolved { + refusals = append(refusals, Refusal{ErrUnresolvedBase, line, stage.BaseName}) + } else { + refusals = append(refusals, checkReference(base, line, policy)...) + } + } + for _, command := range stage.Commands { + switch command := command.(type) { + case *instructions.ArgCommand: + applyArgs(lex, *command, current.args, current.env, global) + case *instructions.EnvCommand: + applyPairs(lex, command.Env, current.env, current.variables()) + case *instructions.LabelCommand: + applyPairs(lex, command.Labels, current.labels, current.variables()) + } + } + // Register only after checking the base: FROM alpine AS alpine still + // names an external image, not itself. + if stage.Name != "" { + previous[strings.ToLower(stage.Name)] = current + } + } + for _, key := range policy.RequiredLabels { + if value, known := current.labels.Get(key); !known || value == "" { + refusals = append(refusals, Refusal{ErrMissingLabel, 0, key}) + } + } + return refusals +} + +func checkReference(base string, line int, policy Policy) []Refusal { + if base == "scratch" { + return nil + } + ref, err := reference.ParseNormalizedNamed(base) + if err != nil { + return []Refusal{{ErrUnresolvedBase, line, base}} + } + var refusals []Refusal + if _, pinned := ref.(reference.Digested); policy.RequireDigest && !pinned { + refusals = append(refusals, Refusal{ErrUnpinnedBase, line, base}) + } + if len(policy.AllowedRegistries) > 0 && !allowed(ref.Name(), policy.AllowedRegistries) { + refusals = append(refusals, Refusal{ErrRegistryNotAllowed, line, base}) + } + return refusals +} + +func allowed(name string, registries []string) bool { + for _, registry := range registries { + registry = strings.TrimSuffix(registry, "/") + if registry != "" && (name == registry || strings.HasPrefix(name, registry+"/")) { + return true + } + } + return false +} + +// A nil value shadows an inherited value whose replacement is unknown. +type values map[string]*string + +func (v values) Get(key string) (string, bool) { + if value := v[key]; value != nil { + return *value, true + } + return "", false +} +func (v values) Keys() []string { + keys := make([]string, 0, len(v)) + for key := range v { + keys = append(keys, key) + } + return keys +} + +type stageState struct{ labels, args, env values } + +func (s stageState) variables() values { + merged := maps.Clone(s.args) + maps.Copy(merged, s.env) // ENV takes precedence over ARG + return merged +} + +func expand(lex *shell.Lex, word string, env values) (string, bool) { + value, unmatched, err := lex.ProcessWord(word, env) + return value, err == nil && len(unmatched) == 0 +} + +func applyArgs(lex *shell.Lex, command instructions.ArgCommand, args, env, global values) { + for _, arg := range command.Args { + if arg.Value == nil { + if value, ok := global[arg.Key]; ok { + args[arg.Key] = value + } + continue + } + variables := maps.Clone(args) + maps.Copy(variables, env) + if value, ok := expand(lex, *arg.Value, variables); ok { + args[arg.Key] = &value + } else { + args[arg.Key] = nil + } + } +} + +func applyPairs(lex *shell.Lex, pairs instructions.KeyValuePairs, dst, env values) { + for _, pair := range pairs { + key, keyOK := expand(lex, pair.Key, env) + value, valueOK := expand(lex, pair.Value, env) + if keyOK && valueOK { + dst[key] = &value + } else { + dst[key] = nil + } + } +} diff --git a/imagepolicy/policy_test.go b/imagepolicy/policy_test.go new file mode 100644 index 000000000..a08912a3d --- /dev/null +++ b/imagepolicy/policy_test.go @@ -0,0 +1,179 @@ +package imagepolicy_test + +import ( + "errors" + "testing" + + "github.com/codefly-dev/core/imagepolicy" +) + +// Every Dockerfile body below is the shape a real image in the fleet is built +// from, reduced to the instructions the policy reads. Checking invented shapes +// would prove the regexes work; checking these proves the rules land on what we +// actually ship. + +// pinned is the service-go-grpc runtime stage: a digest-pinned Alpine with no +// label of any kind. It satisfies a digest rule and fails a label rule, which +// is exactly the fleet's position today. +const pinned = ` +FROM alpine:3.23.5@sha256:fd791d74b68913cbb027c6546007b3f0d3bc45125f797758156952bc2d6daf40 +RUN apk add --no-cache ca-certificates +USER 65532:65532 +CMD ["./app"] +` + +func rules(refusals []imagepolicy.Refusal) []error { + out := make([]error, 0, len(refusals)) + for _, refusal := range refusals { + out = append(out, refusal.Rule) + } + return out +} + +func requireRules(t *testing.T, refusals []imagepolicy.Refusal, want ...error) { + t.Helper() + got := rules(refusals) + if len(got) != len(want) { + t.Fatalf("got %d refusals %v, want %d %v", len(got), got, len(want), want) + } + for i, rule := range want { + if !errors.Is(refusals[i], rule) { + t.Fatalf("refusal %d = %v, want %v", i, refusals[i], rule) + } + } +} + +func TestDigestPinnedBasePasses(t *testing.T) { + requireRules(t, imagepolicy.Check(pinned, imagepolicy.Policy{RequireDigest: true})) +} + +// service-rust's runtime and obin service-xlsx both name a floating tag. These +// are not merely unscanned, they are unreproducible: the bytes behind the tag +// move, so a clean scan describes an image that may no longer exist. +func TestFloatingTagIsRefused(t *testing.T) { + for _, body := range []string{"FROM debian:bookworm-slim\n", "FROM node:26-slim\n", "FROM golang:1.27\n"} { + requireRules(t, imagepolicy.Check(body, imagepolicy.Policy{RequireDigest: true}), imagepolicy.ErrUnpinnedBase) + } +} + +// scratch has no registry to allow and no digest to pin, so the reference +// rules cannot apply. This is the whole reason a scratch image satisfies a +// registry mandate with nothing to migrate. +func TestScratchSatisfiesEveryReferenceRule(t *testing.T) { + body := "FROM scratch\nCOPY app /app\nUSER 65532:65532\n" + requireRules(t, imagepolicy.Check(body, imagepolicy.Policy{ + RequireDigest: true, + AllowedRegistries: []string{"nylartifactory.jfrog.io/devsecops-dkr-prod-virtual"}, + })) +} + +// service-postgres, service-redis, minio and service-vault all write +// `ARG X=` then `FROM ${X}`. A reader that skipped the substitution +// would pass all four without ever seeing a reference. +func TestBaseFromArgumentDefaultIsResolved(t *testing.T) { + body := ` +ARG POSTGRES_IMAGE=postgres:17.10-alpine3.24@sha256:742f40ea20b9ff2ff31db5458d127452988a2164df9e17441e191f3b72252193 +FROM ${POSTGRES_IMAGE} AS runtime +` + requireRules(t, imagepolicy.Check(body, imagepolicy.Policy{RequireDigest: true})) + + floating := "ARG REDIS_IMAGE=redis:8.8.0-alpine\nFROM ${REDIS_IMAGE}\n" + requireRules(t, imagepolicy.Check(floating, imagepolicy.Policy{RequireDigest: true}), imagepolicy.ErrUnpinnedBase) +} + +// An argument with no default in the file leaves the base genuinely unknown. +// Reporting that is not pedantry: passing it would certify a file that does +// not say what it builds on. +func TestUnresolvableBaseIsReported(t *testing.T) { + requireRules(t, imagepolicy.Check("FROM ${SUPPLIED_ELSEWHERE}\n", imagepolicy.Policy{RequireDigest: true}), + imagepolicy.ErrUnresolvedBase) +} + +// service-nextjs builds four stages on one another (`FROM base AS deps`). A +// stage name is an edge inside the file, not a registry reference, and holding +// it to the reference rules would refuse every multi-stage build we have. +func TestStageReferencesAreNotTreatedAsRegistries(t *testing.T) { + body := ` +FROM node:24.17.0-alpine3.23@sha256:7c70d1235c0b4c2bc9eeed5393d19f1bbdde6885ba0d58ba62bb385d7b0f3ff1 AS base +FROM base AS deps +FROM base AS builder +FROM base AS runner +COPY --from=builder /app /app +` + requireRules(t, imagepolicy.Check(body, imagepolicy.Policy{RequireDigest: true})) +} + +// service-vault and minio put --platform=$BUILDPLATFORM before the reference. +// Reading the flag as the base would make every one of them unresolvable. +func TestPlatformFlagIsNotTheReference(t *testing.T) { + body := "FROM --platform=$BUILDPLATFORM golang:1.26.10-alpine3.24@sha256:9ce231b28998b32e116927c61aa5c406183061d4f3f2d61b292c88e7f6bdf7ca AS source\n" + requireRules(t, imagepolicy.Check(body, imagepolicy.Policy{RequireDigest: true})) +} + +// The registry rule is per target and the lists conflict between targets, so +// the same file passes one policy and fails another. Nothing here is written +// for a particular customer. +func TestRegistryAllowListIsPerTarget(t *testing.T) { + body := "FROM alpine:3.23.5@sha256:fd791d74b68913cbb027c6546007b3f0d3bc45125f797758156952bc2d6daf40\n" + + mirrored := imagepolicy.Policy{AllowedRegistries: []string{"example.jfrog.io/virtual"}} + requireRules(t, imagepolicy.Check(body, mirrored), imagepolicy.ErrRegistryNotAllowed) + + remapped := "FROM example.jfrog.io/virtual/alpine:3.23.5@sha256:fd791d74b68913cbb027c6546007b3f0d3bc45125f797758156952bc2d6daf40\n" + requireRules(t, imagepolicy.Check(remapped, mirrored)) + + // A target that names no registries permits any, so a policy opts in. + requireRules(t, imagepolicy.Check(body, imagepolicy.Policy{})) +} + +// A compliance programme supplies its label keys as data. Today every template +// that renders a deployed service image sets no label at all, so all of them +// are reported, each once, rather than the first one found. +func TestRequiredLabelsAreReportedIndividually(t *testing.T) { + required := []string{"APPID", "base.name", "Technology"} + refusals := imagepolicy.Check(pinned, imagepolicy.Policy{RequiredLabels: required}) + requireRules(t, refusals, imagepolicy.ErrMissingLabel, imagepolicy.ErrMissingLabel, imagepolicy.ErrMissingLabel) + for i, refusal := range refusals { + if refusal.Detail != required[i] { + t.Fatalf("refusal %d names %q, want %q", i, refusal.Detail, required[i]) + } + } +} + +// Labels survive continuation lines and quoted values with spaces, which is +// how the postgres bootstrap template already writes its provenance block. +func TestLabelsAreReadAcrossContinuationsAndQuotes(t *testing.T) { + body := ` +FROM alpine:3.23.5@sha256:fd791d74b68913cbb027c6546007b3f0d3bc45125f797758156952bc2d6daf40 +LABEL APPID="APP-123" \ + description="Image description and app details" \ + Technology="Go" +LABEL base.name="example.jfrog.io/virtual/alpine:3.23.5" +` + requireRules(t, imagepolicy.Check(body, imagepolicy.Policy{ + RequiredLabels: []string{"APPID", "description", "Technology", "base.name"}, + })) +} + +// A label present but empty is not set: it would satisfy a grep and tell a +// compliance reader nothing. +func TestEmptyLabelDoesNotSatisfyTheRule(t *testing.T) { + body := "FROM scratch\nLABEL APPID=\"\"\n" + requireRules(t, imagepolicy.Check(body, imagepolicy.Policy{RequiredLabels: []string{"APPID"}}), + imagepolicy.ErrMissingLabel) +} + +// A zero Policy requires nothing, so a target inherits no rule it did not ask +// for and adding a rule here cannot retroactively fail an existing target. +func TestZeroPolicyRequiresNothing(t *testing.T) { + requireRules(t, imagepolicy.Check("FROM debian:bookworm-slim\n", imagepolicy.Policy{})) +} + +// A refusal carries the line so the author is sent to the instruction. +func TestRefusalCarriesTheLine(t *testing.T) { + body := "# a comment\n\nFROM debian:bookworm-slim\n" + refusals := imagepolicy.Check(body, imagepolicy.Policy{RequireDigest: true}) + if len(refusals) != 1 || refusals[0].Line != 3 { + t.Fatalf("refusals = %v, want one on line 3", refusals) + } +} diff --git a/imagepolicy/regression_test.go b/imagepolicy/regression_test.go new file mode 100644 index 000000000..7e8b7e51a --- /dev/null +++ b/imagepolicy/regression_test.go @@ -0,0 +1,71 @@ +package imagepolicy_test + +import ( + "testing" + + "github.com/codefly-dev/core/imagepolicy" +) + +func TestRegistryBoundaries(t *testing.T) { + policy := imagepolicy.Policy{AllowedRegistries: []string{"registry.example/team"}} + for _, base := range []string{"registry.example/team-evil/app", "registry.example/team.evil/app"} { + t.Run(base, func(t *testing.T) { + requireRules(t, imagepolicy.Check("FROM "+base, policy), imagepolicy.ErrRegistryNotAllowed) + }) + } + requireRules(t, imagepolicy.Check("FROM registry.example.evil/team/app", imagepolicy.Policy{ + AllowedRegistries: []string{"registry.example"}, + }), imagepolicy.ErrRegistryNotAllowed) + requireRules(t, imagepolicy.Check("FROM registry.example/team/app", policy)) + requireRules(t, imagepolicy.Check("FROM alpine", imagepolicy.Policy{AllowedRegistries: []string{"docker.io/library"}})) +} + +func TestStageScope(t *testing.T) { + for _, body := range []string{ + "FROM alpine AS alpine", + "ARG BASE=alpine\nFROM ${BASE} AS alpine", + } { + requireRules(t, imagepolicy.Check(body, imagepolicy.Policy{RequireDigest: true}), imagepolicy.ErrUnpinnedBase) + } + requireRules(t, imagepolicy.Check("FROM scratch AS build\nARG BASE=scratch\nFROM ${BASE}", imagepolicy.Policy{RequireDigest: true}), imagepolicy.ErrUnresolvedBase) +} + +func TestFinalImageLabels(t *testing.T) { + policy := imagepolicy.Policy{RequiredLabels: []string{"APPID"}} + for _, body := range []string{ + "FROM scratch AS build\nLABEL APPID=build-only\nFROM scratch", + "FROM scratch\nLABEL APPID=present\nLABEL APPID=\"\"", + "FROM scratch\nLABEL APPID=$UNDEFINED", + "FROM scratch\nARG ID=app\nENV ID=$UNDEFINED\nLABEL APPID=prefix-$ID", + "FROM scratch\nRUN < Date: Wed, 28 Oct 2026 11:50:43 -0400 Subject: [PATCH 02/13] fix(companions): refresh runtime bases and qualify unpublished companions --- .github/workflows/companions-build.yml | 28 +++++++---- companions/codefly/Dockerfile | 14 +++--- companions/codefly/info.codefly.yaml | 2 +- companions/go/Dockerfile | 2 +- companions/go/info.codefly.yaml | 2 +- companions/imagepolicy_test.go | 2 - companions/node/Dockerfile | 2 +- companions/node/info.codefly.yaml | 2 +- companions/proto/Dockerfile | 2 +- companions/proto/info.codefly.yaml | 2 +- companions/python/Dockerfile | 13 +++-- companions/python/info.codefly.yaml | 2 +- docs/image-policy.md | 2 +- docs/runbooks/publish-companions.md | 21 ++++++++ internal/buildspecs/main.go | 66 ++++++++++++++++++++------ internal/buildspecs/main_test.go | 20 ++++++++ version/info.codefly.yaml | 2 +- 17 files changed, 132 insertions(+), 52 deletions(-) diff --git a/.github/workflows/companions-build.yml b/.github/workflows/companions-build.yml index f8369d4c3..25675a46c 100644 --- a/.github/workflows/companions-build.yml +++ b/.github/workflows/companions-build.yml @@ -32,6 +32,7 @@ jobs: plan: runs-on: ubuntu-latest outputs: + bake: ${{ steps.specs.outputs.bake }} companions: ${{ steps.specs.outputs.companions }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -40,7 +41,9 @@ jobs: go-version-file: go.mod cache: false - id: specs - run: echo "companions=$(go run ./internal/buildspecs)" >> "$GITHUB_OUTPUT" + run: | + echo "companions=$(go run ./internal/buildspecs)" >> "$GITHUB_OUTPUT" + echo "bake=$(go run ./internal/buildspecs --bake)" >> "$GITHUB_OUTPUT" build: needs: plan @@ -64,10 +67,10 @@ jobs: # Dockerfile that runs it during the build is exercised here too. # Publishing stages a binary cross-compiled from the CLI checkout # instead; this only has to be a codefly binary, not that one. - if: matrix.cli_binary != '' + if: matrix.cli_binary != '' || matrix.base_cli_binary != '' env: GH_TOKEN: ${{ github.token }} - CLI_BINARY: ${{ matrix.cli_binary }} + CLI_BINARY: ${{ matrix.cli_binary || matrix.base_cli_binary }} PLATFORMS: ${{ matrix.platforms }} run: | set -euo pipefail @@ -86,12 +89,17 @@ jobs: - uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + - name: Write the build graph + env: + BAKE: ${{ needs.plan.outputs.bake }} + run: printf '%s\n' "$BAKE" > "${{ runner.temp }}/companions.json" + - name: build - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + uses: docker/bake-action@59cb1203cd42bcefd40a1b6d73b589e48c86040e # v7.5.0 with: - context: ${{ matrix.context }} - file: ${{ matrix.dockerfile }} - platforms: ${{ matrix.platforms }} - push: false - cache-from: type=gha,scope=companion-${{ matrix.companion }} - cache-to: type=gha,mode=max,scope=companion-${{ matrix.companion }} + source: . + files: ${{ runner.temp }}/companions.json + targets: ${{ matrix.companion }} + set: | + *.cache-from=type=gha,scope=companion-${{ matrix.companion }} + *.cache-to=type=gha,mode=max,scope=companion-${{ matrix.companion }} diff --git a/companions/codefly/Dockerfile b/companions/codefly/Dockerfile index 543eec5cf..b2d65e37f 100644 --- a/companions/codefly/Dockerfile +++ b/companions/codefly/Dockerfile @@ -1,17 +1,15 @@ -FROM alpine:3.24.2 +FROM alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6 # Base dev toolbox shared across every codefly-built image. Language # companions either FROM this image directly (when alpine suffices) or # `COPY --from=ghcr.io/codefly-dev/codefly` the codefly binary (when they need -# a language-specific base like python:3.13.1-alpine3.21). +# a language-specific base like Python on Alpine). # # Version pinning strategy: -# - Base image: pinned to alpine:3.21.3 (specific patch, not :latest). -# - apk packages: not individually pinned. Alpine point releases -# freeze their package repo, so `apk add git` from alpine:3.21.3 -# always yields the same git build. Pinning every apk line is -# brittle (versions shift between minor releases) and delivers no -# reproducibility benefit beyond what the base tag gives us. +# - Base image: Alpine 3.24.2, pinned by digest. +# - apk packages: the Alpine repository supplies current fixes at build +# time. Its packages are not frozen by the base tag; the published +# image digest identifies the resulting bytes. # - codefly CLI: copied from a locally-built binary, not a tag. # # Keep minimal. Heavy tools (language runtimes, LSP servers, proto diff --git a/companions/codefly/info.codefly.yaml b/companions/codefly/info.codefly.yaml index b1f07a6b4..888ec6e45 100644 --- a/companions/codefly/info.codefly.yaml +++ b/companions/codefly/info.codefly.yaml @@ -1 +1 @@ -version: 0.0.4 +version: 0.0.5 diff --git a/companions/go/Dockerfile b/companions/go/Dockerfile index c91bb927e..1d1a9a963 100644 --- a/companions/go/Dockerfile +++ b/companions/go/Dockerfile @@ -2,7 +2,7 @@ # companion image it builds on, and the builder resolves that to a registry. # The default keeps a plain `docker build` working and is pinned to the version # in companions/codefly/info.codefly.yaml. -ARG CODEFLY_BASE_IMAGE=ghcr.io/codefly-dev/codefly:0.0.4 +ARG CODEFLY_BASE_IMAGE=ghcr.io/codefly-dev/codefly:0.0.5 FROM ${CODEFLY_BASE_IMAGE} AS codeflybase FROM golang:1.27-alpine diff --git a/companions/go/info.codefly.yaml b/companions/go/info.codefly.yaml index 9555a667b..f9a0e57b9 100644 --- a/companions/go/info.codefly.yaml +++ b/companions/go/info.codefly.yaml @@ -1 +1 @@ -version: 0.0.11 +version: 0.0.12 diff --git a/companions/imagepolicy_test.go b/companions/imagepolicy_test.go index d44342ea1..8b26d8517 100644 --- a/companions/imagepolicy_test.go +++ b/companions/imagepolicy_test.go @@ -31,11 +31,9 @@ import ( // core release flow — so moving a base means republishing the companion. See // docs/runbooks/publish-companions.md. var unpinnedUpstreamBases = []string{ - "alpine:3.24.2", "docker:29-dind", "golang:1.27-alpine", "node:26.3.0-alpine3.22", - "python:3.14.1-alpine3.21", "rust:1.99-alpine", } diff --git a/companions/node/Dockerfile b/companions/node/Dockerfile index 646504387..0c8868c1b 100644 --- a/companions/node/Dockerfile +++ b/companions/node/Dockerfile @@ -10,7 +10,7 @@ # companion image it builds on, and the builder resolves that to a registry. # The default keeps a plain `docker build` working and is pinned to the version # in companions/codefly/info.codefly.yaml. -ARG CODEFLY_BASE_IMAGE=ghcr.io/codefly-dev/codefly:0.0.4 +ARG CODEFLY_BASE_IMAGE=ghcr.io/codefly-dev/codefly:0.0.5 FROM ${CODEFLY_BASE_IMAGE} AS codeflybase FROM node:26.3.0-alpine3.22 diff --git a/companions/node/info.codefly.yaml b/companions/node/info.codefly.yaml index d6e816cf6..282b91ba4 100644 --- a/companions/node/info.codefly.yaml +++ b/companions/node/info.codefly.yaml @@ -1 +1 @@ -version: 0.0.13 +version: 0.0.14 diff --git a/companions/proto/Dockerfile b/companions/proto/Dockerfile index ec64d162b..98ad57a61 100644 --- a/companions/proto/Dockerfile +++ b/companions/proto/Dockerfile @@ -16,7 +16,7 @@ # companion image it builds on, and the builder resolves that to a registry. # The default keeps a plain `docker build` working and is pinned to the version # in companions/codefly/info.codefly.yaml. -ARG CODEFLY_BASE_IMAGE=ghcr.io/codefly-dev/codefly:0.0.4 +ARG CODEFLY_BASE_IMAGE=ghcr.io/codefly-dev/codefly:0.0.5 # --- Builder stage: compile every Go plugin from source -------------- diff --git a/companions/proto/info.codefly.yaml b/companions/proto/info.codefly.yaml index 8c04ba083..1be3e0519 100644 --- a/companions/proto/info.codefly.yaml +++ b/companions/proto/info.codefly.yaml @@ -1 +1 @@ -version: 0.0.18 +version: 0.0.19 diff --git a/companions/python/Dockerfile b/companions/python/Dockerfile index 490fad1cf..2e3593ee7 100644 --- a/companions/python/Dockerfile +++ b/companions/python/Dockerfile @@ -1,11 +1,10 @@ # Python runtime companion — opinionated, uv-based (no poetry). # # Version pinning strategy (matches codefly base): -# - Base image: python:3.13.1-alpine3.21 — pins both Python patch and -# Alpine minor release. -# - apk packages: not individually pinned — Alpine point release -# freezes its repo, so `apk add git` yields the same version at -# every build. +# - Base image: Python 3.14.10 on Alpine 3.24, pinned by digest. +# - apk packages: not individually pinned — the Alpine repository +# supplies current fixes at build time. The published image digest, +# rather than the package repository, identifies the resulting bytes. # - uv: pinned via the install-script URL (version is part of the # path), bumping = editing this line + bumping info.codefly.yaml. # - codefly CLI: copied from the pinned codefly companion base. @@ -14,10 +13,10 @@ # companion image it builds on, and the builder resolves that to a registry. # The default keeps a plain `docker build` working and is pinned to the version # in companions/codefly/info.codefly.yaml. -ARG CODEFLY_BASE_IMAGE=ghcr.io/codefly-dev/codefly:0.0.4 +ARG CODEFLY_BASE_IMAGE=ghcr.io/codefly-dev/codefly:0.0.5 FROM ${CODEFLY_BASE_IMAGE} AS codeflybase -FROM python:3.14.1-alpine3.21 +FROM python:3.14.10-alpine3.24@sha256:cfe4b5079e5852da965ae14f74a6e2b137ece05b918d27c575fb70de3c85db2b # Re-install alpine dev tools so the python companion's runtime carries # the same toolbox as the codefly base. Dropped from the list: diff --git a/companions/python/info.codefly.yaml b/companions/python/info.codefly.yaml index 888ec6e45..5c0a9fa32 100644 --- a/companions/python/info.codefly.yaml +++ b/companions/python/info.codefly.yaml @@ -1 +1 @@ -version: 0.0.5 +version: 0.0.6 diff --git a/docs/image-policy.md b/docs/image-policy.md index 1be48cc36..f2807b5e5 100644 --- a/docs/image-policy.md +++ b/docs/image-policy.md @@ -72,7 +72,7 @@ The companion base-image argument's tag default is exempted only from the unpinned-base refusal: the publisher overrides it with the digest it just published. Existing companion build-spec tests enforce that default's shape. -Six distinct floating upstream bases remain explicitly listed in +Four distinct floating upstream bases remain explicitly listed in `unpinnedUpstreamBases`. This is a **temporary stopgap**, not completed pinning: new references fail and stale exceptions fail once no companion uses them. Pinning them requires the companion build/publish contract in diff --git a/docs/runbooks/publish-companions.md b/docs/runbooks/publish-companions.md index bb5d4fc20..102964b49 100644 --- a/docs/runbooks/publish-companions.md +++ b/docs/runbooks/publish-companions.md @@ -121,3 +121,24 @@ curl -sI -H "Authorization: Bearer $token" \ Checklist — do this once per package: `codefly`, `execution`, `go`, `node`, `proto`, `python`. + +## October 2026 image refresh + +Core 0.19.11 selects codefly 0.0.5 and Python 0.0.6. Python now uses +Python 3.14.10 on Alpine 3.24; both it and the codefly Alpine 3.24.2 base +are digest-pinned. The go 0.0.12, node 0.0.14 and proto 0.0.19 companions +also move to the refreshed codefly base. Published tags are not overwritten. +Alpine package repositories supply current fixes during the build; a base +version does not freeze the packages installed by `apk`. + +The PR build derives a Buildx Bake graph from `companions.BuildSpecs()`. +Each dependent uses a `target:` context for its declared base, so it tests +the base from the same checkout before that new tag is published. CI stages +the released CLI executable for this check; the CLI publishing workflow +builds its own binary from source, as described above. + +After publishing, record the actual image digests and scan those images. +Consumers must adopt the new Core/agent selections and rebuild their service +images; a source merge or a successful tag-existence check does not refresh +an already published service image. NYL acceptance requires its re-scan of +those resulting images, including vulnerability and secret policies. diff --git a/internal/buildspecs/main.go b/internal/buildspecs/main.go index 9468dadc0..8def18c4c 100644 --- a/internal/buildspecs/main.go +++ b/internal/buildspecs/main.go @@ -6,6 +6,7 @@ package main import ( "encoding/json" + "flag" "fmt" "os" "strings" @@ -16,41 +17,76 @@ import ( // entry is one companion rendered as the flat, string-valued shape a GitHub // Actions matrix entry and a reusable workflow's inputs can carry. type entry struct { - Companion string `json:"companion"` - Version string `json:"version"` - Dockerfile string `json:"dockerfile"` - Context string `json:"context"` - Platforms string `json:"platforms"` - CLIBinary string `json:"cli_binary"` - Base string `json:"base"` + Companion string `json:"companion"` + Version string `json:"version"` + Dockerfile string `json:"dockerfile"` + Context string `json:"context"` + Platforms string `json:"platforms"` + CLIBinary string `json:"cli_binary"` + Base string `json:"base"` + BaseCLIBinary string `json:"base_cli_binary"` } func entries(specs []companions.BuildSpec) []entry { + cliBinaries := make(map[string]string, len(specs)) + for _, spec := range specs { + cliBinaries[spec.Name] = spec.CLIBinary + } out := make([]entry, 0, len(specs)) for _, spec := range specs { out = append(out, entry{ - Companion: spec.Name, - Version: spec.Version, - Dockerfile: spec.Dockerfile, - Context: spec.Context, - Platforms: strings.Join(spec.Platforms, ","), - CLIBinary: spec.CLIBinary, - Base: spec.Base, + Companion: spec.Name, + Version: spec.Version, + Dockerfile: spec.Dockerfile, + Context: spec.Context, + Platforms: strings.Join(spec.Platforms, ","), + CLIBinary: spec.CLIBinary, + Base: spec.Base, + BaseCLIBinary: cliBinaries[spec.Base], }) } return out } func main() { + bake := flag.Bool("bake", false, "emit a Buildx Bake graph with companion bases built from this checkout") + flag.Parse() specs, err := companions.BuildSpecs() if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } - encoded, err := json.Marshal(entries(specs)) + var output any = entries(specs) + if *bake { + output = bakeFile(specs) + } + encoded, err := json.Marshal(output) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } fmt.Println(string(encoded)) } + +type bakeTarget struct { + Context string `json:"context"` + Dockerfile string `json:"dockerfile"` + Platforms []string `json:"platforms"` + Contexts map[string]string `json:"contexts,omitempty"` + Args map[string]string `json:"args,omitempty"` +} + +// A target context builds the declared base from the same checkout. A release +// PR can therefore qualify a new base before its tag exists in the registry. +func bakeFile(specs []companions.BuildSpec) map[string]map[string]bakeTarget { + targets := make(map[string]bakeTarget, len(specs)) + for _, spec := range specs { + target := bakeTarget{Context: spec.Context, Dockerfile: spec.Dockerfile, Platforms: spec.Platforms} + if spec.Base != "" { + target.Contexts = map[string]string{spec.Base: "target:" + spec.Base} + target.Args = map[string]string{companions.BaseImageArg: spec.Base} + } + targets[spec.Name] = target + } + return map[string]map[string]bakeTarget{"target": targets} +} diff --git a/internal/buildspecs/main_test.go b/internal/buildspecs/main_test.go index cb1362742..113f4d80e 100644 --- a/internal/buildspecs/main_test.go +++ b/internal/buildspecs/main_test.go @@ -37,3 +37,23 @@ func TestPlatformsJoinAsBuildxAcceptsThem(t *testing.T) { require.NotContains(t, e.Platforms, " ") } } + +// A dependent must build its checkout's base, not pull the unpublished tag. +// The matrix also needs that base's real CLI executable staged before baking. +func TestBakeDependenciesHaveLocalTargetsAndStagedBinaries(t *testing.T) { + specs, err := companions.BuildSpecs() + require.NoError(t, err) + targets := bakeFile(specs)["target"] + for _, entry := range entries(specs) { + target := targets[entry.Companion] + if entry.Base == "" { + require.Empty(t, target.Contexts) + continue + } + base, ok := targets[entry.Base] + require.True(t, ok, "base must be in the same build graph") + require.Equal(t, base.Platforms, target.Platforms) + require.Equal(t, "target:"+entry.Base, target.Contexts[target.Args[companions.BaseImageArg]]) + require.NotEmpty(t, entry.BaseCLIBinary) + } +} diff --git a/version/info.codefly.yaml b/version/info.codefly.yaml index 578f5f326..319d44d91 100644 --- a/version/info.codefly.yaml +++ b/version/info.codefly.yaml @@ -1 +1 @@ -version: 0.19.10 +version: 0.19.11 From 5c2bc78edb803e3d955e58e1ea8063b61ffb446a Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:01:35 -0400 Subject: [PATCH 03/13] fix(proto): preserve generator compatibility on the refreshed runtime --- companions/proto/Dockerfile | 14 ++++++++++---- companions/proto/README.md | 5 ++++- companions/proto/companion_plugins_test.go | 8 ++++---- docs/runbooks/bump-protobuf-es.md | 2 +- docs/runbooks/publish-companions.md | 3 ++- 5 files changed, 21 insertions(+), 11 deletions(-) diff --git a/companions/proto/Dockerfile b/companions/proto/Dockerfile index 98ad57a61..f4f4b6277 100644 --- a/companions/proto/Dockerfile +++ b/companions/proto/Dockerfile @@ -17,6 +17,7 @@ # The default keeps a plain `docker build` working and is pinned to the version # in companions/codefly/info.codefly.yaml. ARG CODEFLY_BASE_IMAGE=ghcr.io/codefly-dev/codefly:0.0.5 +FROM ${CODEFLY_BASE_IMAGE} AS codeflybase # --- Builder stage: compile every Go plugin from source -------------- @@ -56,7 +57,12 @@ RUN apk add --no-cache musl-dev && \ cargo install --locked protoc-gen-prost --version 0.5.0 && \ cargo install --locked protoc-gen-tonic --version 0.5.0 -FROM ${CODEFLY_BASE_IMAGE} +# Keep the interpreter compatible with the pinned generator's wheels and +# gencode contract. Alpine's python3 moves independently; grpcio-tools 1.71 +# has Python 3.13 wheels, not 3.14 wheels. As in the Python companion, copy +# the freshly built CLI into a language-specific runtime base. +FROM python:3.13.18-alpine3.24@sha256:bbc0290146ebe0a8b9a8ed07d3f2c7bdeb4d917fc93300a4e47490c8b5a11b48 +COPY --from=codeflybase /bin/codefly /bin/codefly # Runtime dependencies: # - nodejs + npm: for the TypeScript generators below (unavoidable). @@ -87,11 +93,11 @@ ENV HOME=/tmp # python3 + a dedicated /venv host the Python facade + strip plugins. The venv # is built here (not copied from the builder) so it is bound to this stage's -# python — the builder's alpine ships a different python minor version, and a +# Python 3.13 — the builder may ship a different Python minor version, and a # venv is not portable across them. -RUN apk add --no-cache nodejs npm protobuf python3 py3-pip && \ +RUN apk add --no-cache bash ca-certificates curl git jq procps tzdata nodejs npm protobuf && \ python3 -m venv /venv && \ - /venv/bin/pip install --no-cache-dir protobuf==5.29.3 grpcio-tools==1.71.0 && \ + /venv/bin/pip install --no-cache-dir --only-binary=:all: protobuf==5.29.3 grpcio-tools==1.71.0 && \ npm install -g \ openapi-typescript@7.13.0 \ swagger2openapi@7.0.8 \ diff --git a/companions/proto/README.md b/companions/proto/README.md index 9886df824..94858a07f 100644 --- a/companions/proto/README.md +++ b/companions/proto/README.md @@ -120,7 +120,10 @@ Python bindings come from the companion's own plugin, `protoc-gen-codefly-python (`facades/python/protoc_gen_codefly_python.py`), as Go's and Rust's come from local plugins: it compiles the `*_pb2` messages and `*_pb2_grpc` stubs with the `grpc_tools` pinned in the image's venv (`grpcio-tools==1.71.0`, -`protobuf==5.29.3`), so the gencode is versioned with the image. buf's remote +`protobuf==5.29.3`), so the gencode is versioned with the image. The Docker +runtime uses digest-pinned Python 3.13.18 on Alpine 3.24: these generator pins +have Python 3.13 wheels, and moving the generator to support a newer interpreter +would also change the generated-code runtime requirements. buf's remote `protocolbuffers/python` and `grpc/python` plugins it replaces moved on their own, and their gencode outran the protobuf runtime a generated library declares. It ships in companion version 0.0.17. diff --git a/companions/proto/companion_plugins_test.go b/companions/proto/companion_plugins_test.go index a18add35d..c39abc045 100644 --- a/companions/proto/companion_plugins_test.go +++ b/companions/proto/companion_plugins_test.go @@ -326,10 +326,10 @@ func TestTsFacadeLockfileMatchesTheRuntime(t *testing.T) { // neighbour — the reader then checks the wrong line, finds something plausible, // and moves on. var runbookCitations = map[string]string{ - "Dockerfile:98": "@bufbuild/protoc-gen-es@", - "flake.nix:51": "protocGenEsVersion =", - "flake.nix:59": "hash =", - "flake.nix:61": "npmDepsHash =", + "Dockerfile:104": "@bufbuild/protoc-gen-es@", + "flake.nix:51": "protocGenEsVersion =", + "flake.nix:59": "hash =", + "flake.nix:61": "npmDepsHash =", } func TestRunbookCitationsPointAtWhatTheyName(t *testing.T) { diff --git a/docs/runbooks/bump-protobuf-es.md b/docs/runbooks/bump-protobuf-es.md index d91e05d34..3bdc03dfe 100644 --- a/docs/runbooks/bump-protobuf-es.md +++ b/docs/runbooks/bump-protobuf-es.md @@ -103,7 +103,7 @@ Six places, and they must agree: |---|---|---| | `companions/proto/facades/ts/package.json` | `@bufbuild/protoplugin` | yes | | `companions/proto/facades/ts/package-lock.json` | the resolved `protoplugin` + `protobuf` closure | yes | -| `companions/proto/Dockerfile:98` | `@bufbuild/protoc-gen-es@` installed via npm | yes | +| `companions/proto/Dockerfile:104` | `@bufbuild/protoc-gen-es@` installed via npm | yes | | `companions/proto/flake.nix:51` | `protocGenEsVersion`, built from source | yes | | `companions/proto/flake.nix:59` | `hash` — the protobuf-es source tarball | **no** | | `companions/proto/flake.nix:61` | `npmDepsHash` — its npm dependency closure | **no** | diff --git a/docs/runbooks/publish-companions.md b/docs/runbooks/publish-companions.md index 102964b49..0387e1c75 100644 --- a/docs/runbooks/publish-companions.md +++ b/docs/runbooks/publish-companions.md @@ -127,7 +127,8 @@ Checklist — do this once per package: `codefly`, `execution`, `go`, `node`, Core 0.19.11 selects codefly 0.0.5 and Python 0.0.6. Python now uses Python 3.14.10 on Alpine 3.24; both it and the codefly Alpine 3.24.2 base are digest-pinned. The go 0.0.12, node 0.0.14 and proto 0.0.19 companions -also move to the refreshed codefly base. Published tags are not overwritten. +also carry the refreshed codefly CLI. Proto retains its generator versions +on a digest-pinned Python 3.13.18 runtime compatible with their wheels. Published tags are not overwritten. Alpine package repositories supply current fixes during the build; a base version does not freeze the packages installed by `apk`. From cdb7f459e9cc568d6f8784a598f9fffedebaf424 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:42:06 -0400 Subject: [PATCH 04/13] fix(deps): refresh vulnerable Go dependencies and companion versions --- companions/codefly/info.codefly.yaml | 2 +- companions/execution/info.codefly.yaml | 2 +- companions/go/Dockerfile | 2 +- companions/go/info.codefly.yaml | 2 +- companions/node/Dockerfile | 2 +- companions/node/info.codefly.yaml | 2 +- companions/proto/Dockerfile | 2 +- companions/proto/info.codefly.yaml | 2 +- companions/python/Dockerfile | 2 +- companions/python/info.codefly.yaml | 2 +- docs/runbooks/publish-companions.md | 15 +++++++ go.mod | 30 ++++++------- go.sum | 58 +++++++++++--------------- version/info.codefly.yaml | 2 +- 14 files changed, 62 insertions(+), 63 deletions(-) diff --git a/companions/codefly/info.codefly.yaml b/companions/codefly/info.codefly.yaml index 888ec6e45..5c0a9fa32 100644 --- a/companions/codefly/info.codefly.yaml +++ b/companions/codefly/info.codefly.yaml @@ -1 +1 @@ -version: 0.0.5 +version: 0.0.6 diff --git a/companions/execution/info.codefly.yaml b/companions/execution/info.codefly.yaml index b1465aaf8..af0337af5 100644 --- a/companions/execution/info.codefly.yaml +++ b/companions/execution/info.codefly.yaml @@ -1 +1 @@ -version: 0.0.1 +version: 0.0.2 diff --git a/companions/go/Dockerfile b/companions/go/Dockerfile index 1d1a9a963..bd40d6b59 100644 --- a/companions/go/Dockerfile +++ b/companions/go/Dockerfile @@ -2,7 +2,7 @@ # companion image it builds on, and the builder resolves that to a registry. # The default keeps a plain `docker build` working and is pinned to the version # in companions/codefly/info.codefly.yaml. -ARG CODEFLY_BASE_IMAGE=ghcr.io/codefly-dev/codefly:0.0.5 +ARG CODEFLY_BASE_IMAGE=ghcr.io/codefly-dev/codefly:0.0.6 FROM ${CODEFLY_BASE_IMAGE} AS codeflybase FROM golang:1.27-alpine diff --git a/companions/go/info.codefly.yaml b/companions/go/info.codefly.yaml index f9a0e57b9..d6e816cf6 100644 --- a/companions/go/info.codefly.yaml +++ b/companions/go/info.codefly.yaml @@ -1 +1 @@ -version: 0.0.12 +version: 0.0.13 diff --git a/companions/node/Dockerfile b/companions/node/Dockerfile index 0c8868c1b..3364f8cfc 100644 --- a/companions/node/Dockerfile +++ b/companions/node/Dockerfile @@ -10,7 +10,7 @@ # companion image it builds on, and the builder resolves that to a registry. # The default keeps a plain `docker build` working and is pinned to the version # in companions/codefly/info.codefly.yaml. -ARG CODEFLY_BASE_IMAGE=ghcr.io/codefly-dev/codefly:0.0.5 +ARG CODEFLY_BASE_IMAGE=ghcr.io/codefly-dev/codefly:0.0.6 FROM ${CODEFLY_BASE_IMAGE} AS codeflybase FROM node:26.3.0-alpine3.22 diff --git a/companions/node/info.codefly.yaml b/companions/node/info.codefly.yaml index 282b91ba4..8ffeae728 100644 --- a/companions/node/info.codefly.yaml +++ b/companions/node/info.codefly.yaml @@ -1 +1 @@ -version: 0.0.14 +version: 0.0.15 diff --git a/companions/proto/Dockerfile b/companions/proto/Dockerfile index f4f4b6277..3ab37c516 100644 --- a/companions/proto/Dockerfile +++ b/companions/proto/Dockerfile @@ -16,7 +16,7 @@ # companion image it builds on, and the builder resolves that to a registry. # The default keeps a plain `docker build` working and is pinned to the version # in companions/codefly/info.codefly.yaml. -ARG CODEFLY_BASE_IMAGE=ghcr.io/codefly-dev/codefly:0.0.5 +ARG CODEFLY_BASE_IMAGE=ghcr.io/codefly-dev/codefly:0.0.6 FROM ${CODEFLY_BASE_IMAGE} AS codeflybase # --- Builder stage: compile every Go plugin from source -------------- diff --git a/companions/proto/info.codefly.yaml b/companions/proto/info.codefly.yaml index 1be3e0519..89acbc43c 100644 --- a/companions/proto/info.codefly.yaml +++ b/companions/proto/info.codefly.yaml @@ -1 +1 @@ -version: 0.0.19 +version: 0.0.20 diff --git a/companions/python/Dockerfile b/companions/python/Dockerfile index 2e3593ee7..a19e82afc 100644 --- a/companions/python/Dockerfile +++ b/companions/python/Dockerfile @@ -13,7 +13,7 @@ # companion image it builds on, and the builder resolves that to a registry. # The default keeps a plain `docker build` working and is pinned to the version # in companions/codefly/info.codefly.yaml. -ARG CODEFLY_BASE_IMAGE=ghcr.io/codefly-dev/codefly:0.0.5 +ARG CODEFLY_BASE_IMAGE=ghcr.io/codefly-dev/codefly:0.0.6 FROM ${CODEFLY_BASE_IMAGE} AS codeflybase FROM python:3.14.10-alpine3.24@sha256:cfe4b5079e5852da965ae14f74a6e2b137ece05b918d27c575fb70de3c85db2b diff --git a/companions/python/info.codefly.yaml b/companions/python/info.codefly.yaml index 5c0a9fa32..87fb744f1 100644 --- a/companions/python/info.codefly.yaml +++ b/companions/python/info.codefly.yaml @@ -1 +1 @@ -version: 0.0.6 +version: 0.0.7 diff --git a/docs/runbooks/publish-companions.md b/docs/runbooks/publish-companions.md index 0387e1c75..72f09af8b 100644 --- a/docs/runbooks/publish-companions.md +++ b/docs/runbooks/publish-companions.md @@ -143,3 +143,18 @@ Consumers must adopt the new Core/agent selections and rebuild their service images; a source merge or a successful tag-existence check does not refresh an already published service image. NYL acceptance requires its re-scan of those resulting images, including vulnerability and secret policies. + +## Follow-up dependency refresh + +The scan of the published codefly 0.0.5 binary found GO-2026-6860 and +GO-2026-6866 in `golang.org/x/net` 0.61.0; both are fixed in 0.62.0. +Core 0.19.12 raises that dependency and selects new companion versions: +codefly 0.0.6, Python 0.0.7, Go 0.0.13, Node 0.0.15, proto 0.0.20 and +execution 0.0.2. Every companion carries the CLI, so every image needs a +new version even where its Dockerfile otherwise stays the same. + +Publish this set only after the CLI dependency refresh is merged: the +publisher compiles the CLI checkout, and a Core dependency bump alone does +not change that executable. Verify the published executable's module +versions and scan it again before recording the fixes as delivered. Keep +previous tags immutable, and adopt the new Python index digest in FastAPI. diff --git a/go.mod b/go.mod index a54a269ff..aee98297d 100644 --- a/go.mod +++ b/go.mod @@ -34,9 +34,8 @@ require ( github.com/google/go-cmp v0.7.0 github.com/google/go-github/v89 v89.0.0 github.com/google/uuid v1.6.0 - github.com/grpc-ecosystem/grpc-gateway/v2 v2.32.0 + github.com/grpc-ecosystem/grpc-gateway/v2 v2.33.0 github.com/hashicorp/go-multierror v1.1.1 - github.com/mitchellh/mapstructure v1.5.0 github.com/moby/buildkit v0.34.1 github.com/moby/patternmatcher v0.6.1 github.com/muesli/termenv v0.16.0 @@ -69,18 +68,18 @@ require ( go.opentelemetry.io/otel/trace v1.47.0 go.uber.org/goleak v1.3.0 golang.org/x/mod v0.42.0 - golang.org/x/net v0.61.0 + golang.org/x/net v0.62.0 golang.org/x/sys v0.49.0 golang.org/x/term v0.47.0 golang.org/x/text v0.43.0 - google.golang.org/genproto/googleapis/api v0.0.0-20261012220534-4341edf3f941 - google.golang.org/genproto/googleapis/rpc v0.0.0-20261005182115-fad411399dd8 - google.golang.org/grpc v1.84.0 + google.golang.org/genproto/googleapis/api v0.0.0-20261026165753-2ffab32bce7e + google.golang.org/genproto/googleapis/rpc v0.0.0-20261026165753-2ffab32bce7e + google.golang.org/grpc v1.85.0 google.golang.org/protobuf v1.36.12 gopkg.in/yaml.v3 v3.0.1 mvdan.cc/sh/v3 v3.14.1 - sigs.k8s.io/kustomize/api v0.21.1 - sigs.k8s.io/kustomize/kyaml v0.21.1 + sigs.k8s.io/kustomize/api v0.21.3 + sigs.k8s.io/kustomize/kyaml v0.21.3 ) require ( @@ -109,7 +108,6 @@ require ( github.com/cloudflare/circl v1.6.3 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/typeurl/v2 v2.3.0 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect github.com/davidmz/go-pageant v1.0.2 // indirect github.com/dlclark/regexp2 v1.12.0 // indirect github.com/docker/go-units v0.5.0 // indirect @@ -123,19 +121,15 @@ require ( github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect - github.com/go-openapi/analysis v1.0.0 // indirect + github.com/go-openapi/analysis v1.0.1 // indirect github.com/go-openapi/errors v0.22.9 // indirect - github.com/go-openapi/jsonpointer v1.0.1 // indirect - github.com/go-openapi/jsonreference v1.0.2 // indirect - github.com/go-openapi/strfmt v0.27.3 // indirect - github.com/go-openapi/swag v0.29.2 // indirect - github.com/go-openapi/swag/cmdutils v0.29.2 // indirect + github.com/go-openapi/jsonpointer v1.0.2 // indirect + github.com/go-openapi/jsonreference v1.0.3 // indirect + github.com/go-openapi/strfmt v0.27.4 // indirect github.com/go-openapi/swag/conv v0.29.2 // indirect - github.com/go-openapi/swag/fileutils v0.29.2 // indirect github.com/go-openapi/swag/jsonutils v0.29.2 // indirect github.com/go-openapi/swag/loading v0.29.2 // indirect github.com/go-openapi/swag/mangling v0.29.2 // indirect - github.com/go-openapi/swag/netutils v0.29.2 // indirect github.com/go-openapi/swag/pools v0.29.2 // indirect github.com/go-openapi/swag/stringutils v0.29.2 // indirect github.com/go-openapi/swag/typeutils v0.29.2 // indirect @@ -199,7 +193,7 @@ require ( gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gotest.tools/v3 v3.5.1 // indirect - k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad // indirect + k8s.io/kube-openapi v0.0.0-20261001230523-97fa35140926 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index 3d4d0084f..2dd7d9bce 100644 --- a/go.sum +++ b/go.sum @@ -102,7 +102,6 @@ github.com/creativeprojects/go-selfupdate v1.7.0/go.mod h1:RVszRkQ0kVQB2hbqrsgrE github.com/cyphar/filepath-securejoin v0.7.0 h1:s0Y3ITPy6sQn5xt54DuYvTF8hu134ooYLUb58DX/HjE= github.com/cyphar/filepath-securejoin v0.7.0/go.mod h1:ymLGms/u3BYaviIiuKFnUx8EkQEZeK6cInNoAPJA3o4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -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/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0= github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= @@ -152,28 +151,23 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= -github.com/go-openapi/analysis v1.0.0 h1:sNvbAGCJqUTqIAodr9IVqJMmuZas3YS9ms1dGK9yiJ4= -github.com/go-openapi/analysis v1.0.0/go.mod h1:NhYjJ57fnE+bcE7UwrJyMkhWA3Dfz7TdiBfTVAnos4Y= +github.com/go-openapi/analysis v1.0.1 h1:JTEyujgl4GyFj8pbMUY2yZx5ak1Ozt/DaqzQjQBnBWM= +github.com/go-openapi/analysis v1.0.1/go.mod h1:f3gYbXmnXSQIZB02DqE4f8JX969PdOEnaY7gDBLeJjI= github.com/go-openapi/errors v0.22.9 h1:HI9+SyVYiRzyeBQGv0CbWbQa+u2S0nny7G7AhY+UMiM= github.com/go-openapi/errors v0.22.9/go.mod h1:R73q66sXP2smzAGgFOI4nqtusRK2xN5pYCjsij+KX58= -github.com/go-openapi/jsonpointer v1.0.1 h1:2KxywRmNwJkT/FMBa3iRNHEaAxSJvjqoufQZy3au1Mg= -github.com/go-openapi/jsonpointer v1.0.1/go.mod h1:wI7ZYsFmbIi9nBXOZqgDaS/bqOchRGZjqxFli7FBYxY= -github.com/go-openapi/jsonreference v1.0.2 h1:oS4et8FOf3p3UQxEo4Xt0esijmBUM+F259Xl72OSZsc= -github.com/go-openapi/jsonreference v1.0.2/go.mod h1:TbUNSOo+fcorZjFaNoiSDSoaNnnZtqJtLGR1PuvE/Cs= +github.com/go-openapi/jsonpointer v1.0.2 h1:NbW7xT2b+l7x+JefiPGDsZWsrLk1ogrz4AHK1TTzMt8= +github.com/go-openapi/jsonpointer v1.0.2/go.mod h1:N0rI4MbNKD7xAFTiHtB+5Nhrg2j/wwYg7m7Fmb4/jk8= +github.com/go-openapi/jsonreference v1.0.3 h1:5rDbq+JsjCv6/nUDDJt83nLZoADI4w03uvHXer+4aYQ= +github.com/go-openapi/jsonreference v1.0.3/go.mod h1:OJSlMXHysgH272D9z2tiy2MGe5A9vQLHaCPb6KbYdE4= github.com/go-openapi/loads v0.25.3 h1:V+jKy/thXWdLJUuYC8sZX2dICyAa8M3DokF70jj34P0= github.com/go-openapi/loads v0.25.3/go.mod h1:LgLyCSOLBL2Qnj0Ps1oo2YH8jZoEKsMeVZCc+ALIxFo= github.com/go-openapi/spec v1.0.1 h1:lj2vdGpNDcVgwRc6qXdw6qt/KQpCtSa9tnUH6vpDPDk= github.com/go-openapi/spec v1.0.1/go.mod h1:M//GWQGtDUAjnP37gE6fInLgaczB+FatoipV3H1fYw8= -github.com/go-openapi/strfmt v0.27.3 h1:cyf4J5Wjpd6bFpAs44MF8PsbWF/mWXhn82Ck/hgQZgw= -github.com/go-openapi/strfmt v0.27.3/go.mod h1:Bl+xadPLa3nhBiaCAxNND1vrQfzyEjF0vBrLx96n/4E= +github.com/go-openapi/strfmt v0.27.4 h1:E1xsvHb8R9vsuYru4TP2PVVTv9AIgSFAUACUZrFQfCI= +github.com/go-openapi/strfmt v0.27.4/go.mod h1:SCOff81PPhU26acBiW96jHaFycEbT2nkQRvojM+V2z0= github.com/go-openapi/swag v0.29.2 h1:9n8frkcsuQRA0INU31VbxNxt1zBaHxrHYGEf5BUjFi4= -github.com/go-openapi/swag v0.29.2/go.mod h1:RkCiX1gCVXWgLOavDXE3ALJrQ/VO2ziiIzOUs3z10xo= -github.com/go-openapi/swag/cmdutils v0.29.2 h1:cXEzX/nWCODon251f+1HgL75/R4XXML5IZxgLCWMoro= -github.com/go-openapi/swag/cmdutils v0.29.2/go.mod h1:XziaSVzYqkDvpWXzASfWajw2YvJrLscq610O2XiyqJw= github.com/go-openapi/swag/conv v0.29.2 h1:8c9shoB8l0QRSR6ymq1llHdBWDqpIgJfjTGHx3Vuhm0= github.com/go-openapi/swag/conv v0.29.2/go.mod h1:AZS0YigTNf8qNtD6WqJz/N4RUNmpr76SyjFGkq4+p6Q= -github.com/go-openapi/swag/fileutils v0.29.2 h1:mdUL+Vw5ah1fO1AvFCoHeIyv7YZsCz2MN3KNqd/lLVI= -github.com/go-openapi/swag/fileutils v0.29.2/go.mod h1:7QKmmodjAebaq61lGVJa8ldLdGeMF4DSgYv8tXOGGo0= github.com/go-openapi/swag/jsonutils v0.29.2 h1:uZNSD2/rJDYAfvsLYkykTzXuyxM3QpDKV9jhRHrMu6k= github.com/go-openapi/swag/jsonutils v0.29.2/go.mod h1:ONTdNvj3Y+IXRzfa17E+Rgt2ns/qiSOYjIo2K7v2yDs= github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.2 h1:w+Fd6EBOMGlC74GYGHqCLGyb3Bpams8TtNrgM/SG4jo= @@ -182,8 +176,6 @@ github.com/go-openapi/swag/loading v0.29.2 h1:QU1ry24e6r6Shoxe380Nx0X5iKkpRO+hJf github.com/go-openapi/swag/loading v0.29.2/go.mod h1:DqilDjuiJKETecsS4TRopWG1acUDsfqN39ZmQhPu6uI= github.com/go-openapi/swag/mangling v0.29.2 h1:ltdo3K0tTP4P6zWhVVWDU9D37/YYBuMJRdZxiS2Dtog= github.com/go-openapi/swag/mangling v0.29.2/go.mod h1:RjDi4TnItAenS3cgCXSpIl1kKbjG7il6pnmQVl/8bp8= -github.com/go-openapi/swag/netutils v0.29.2 h1:dBli6jyUa93sDS/XYWShJzf4+nnVfFKfvY9SLUaUzB8= -github.com/go-openapi/swag/netutils v0.29.2/go.mod h1:1pM6Xg/Um8E1eps3umWl8Hme3ByWXOtHsvFC0CULHUE= github.com/go-openapi/swag/pools v0.29.2 h1:PlGoDRF8WtSyXkedZZkpW3f9wDhFf3u8KtaBcmgmh8c= github.com/go-openapi/swag/pools v0.29.2/go.mod h1:V3lhxVT4qDYRqc2MRSSbLsTYIYV/2HlHafhyEkmzLIc= github.com/go-openapi/swag/stringutils v0.29.2 h1:lcnBxwAaysT3bMUVBfn9V/PkfVqkurpqufKU6rTUMGk= @@ -223,8 +215,8 @@ github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/graph-gophers/graphql-go v1.9.0 h1:yu0ucKHLc5qGpRwLYKIWtr9bOoxovkWasuBrPQwlHls= github.com/graph-gophers/graphql-go v1.9.0/go.mod h1:23olKZ7duEvHlF/2ELEoSZaY1aNPfShjP782SOoNTyM= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.32.0 h1:QMILzF8BA7xftE4zVORkRrlv08m12St3csK4RsyRgRs= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.32.0/go.mod h1:+ZQ6iUL8+ATyxGDQNuRKpNwJFdGDUi0e7E1QL9MvI7U= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.33.0 h1:5T5Ns3kMpWr+z3s5ZA0e7ljVyWNVNxK76sHPAxi5D1k= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.33.0/go.mod h1:oWNYw09cH8wl8fFgC5hKlBLxR4XfKy2KQpP8MJjeCcc= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -268,8 +260,6 @@ github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhV github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= -github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= -github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/moby/buildkit v0.34.1 h1:jVdxsXX0UpU0q1Et+9lFezPmh9sGE6vF0TrKZaGlfy4= github.com/moby/buildkit v0.34.1/go.mod h1:e+Za70y2a3nL1U2Y0P8TvhPyKp2Ok2NS+Q4siSArDiM= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= @@ -450,8 +440,8 @@ golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= -golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= +golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY= +golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= 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.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= @@ -483,12 +473,12 @@ golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 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-20261012220534-4341edf3f941 h1:DS/w3N8rsfrwxwgGSj6WhMsPo7aNQGmDx74J81zd0NQ= -google.golang.org/genproto/googleapis/api v0.0.0-20261012220534-4341edf3f941/go.mod h1:AuGAdi9KnXss7xbLxIKhCvk+Ro/SxpET/iS45DNYN+M= -google.golang.org/genproto/googleapis/rpc v0.0.0-20261005182115-fad411399dd8 h1:0SXBqli5dnuCKMjI/X3FM226zEaEa0GUcSCJtN1vL2o= -google.golang.org/genproto/googleapis/rpc v0.0.0-20261005182115-fad411399dd8/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= -google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0= -google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0= +google.golang.org/genproto/googleapis/api v0.0.0-20261026165753-2ffab32bce7e h1:e789sLZjeTdc02ZCQGTSzHOu0fR73+dwPQVKUp67Z88= +google.golang.org/genproto/googleapis/api v0.0.0-20261026165753-2ffab32bce7e/go.mod h1:vHV3wJM9WZyfV7GyB+OQGq5dWVrFwCDxapPK4WlRD88= +google.golang.org/genproto/googleapis/rpc v0.0.0-20261026165753-2ffab32bce7e h1:w3fy/G8OrJHYexn+u5y7hFyc8YRRIr7DCevtIOwAcGY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20261026165753-2ffab32bce7e/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= +google.golang.org/grpc v1.85.0 h1:BPBCfuVRQ1qvBhKDOq9uvQllMvO59rKsYhXpp7Dd8vw= +google.golang.org/grpc v1.85.0/go.mod h1:0rQrOD58yjal/0EaVbGBANindixsCZNQPDV3VKtaKuQ= 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= @@ -506,16 +496,16 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= -k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad h1:oXImqH8mQNk7PmvzKhmN3ddJoY6OnyM225MXwGHPm0A= -k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad/go.mod h1:0/mqHCVhlumdJ3BhCfnjSZQE037nAhNodh1/hK0T8/I= +k8s.io/kube-openapi v0.0.0-20261001230523-97fa35140926 h1:Fe0G8t8FaMDn2lpat0IHu3X9lHrVyGzDxhhCwKXKp28= +k8s.io/kube-openapi v0.0.0-20261001230523-97fa35140926/go.mod h1:3e2VxsK2svhjvkNik92kYU1a5kgau5tZeAuEWwoN4q0= mvdan.cc/sh/v3 v3.14.1 h1:bXkhQWNHCs0KZEChF8hYS6FC+T2N9mUZLbQv9blditI= mvdan.cc/sh/v3 v3.14.1/go.mod h1:syYCoFET8w9tvevxiXUtY8/ICrU+l26jHmhJDra3Vwo= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= -sigs.k8s.io/kustomize/api v0.21.1 h1:lzqbzvz2CSvsjIUZUBNFKtIMsEw7hVLJp0JeSIVmuJs= -sigs.k8s.io/kustomize/api v0.21.1/go.mod h1:f3wkKByTrgpgltLgySCntrYoq5d3q7aaxveSagwTlwI= -sigs.k8s.io/kustomize/kyaml v0.21.1 h1:IVlbmhC076nf6foyL6Taw4BkrLuEsXUXNpsE+ScX7fI= -sigs.k8s.io/kustomize/kyaml v0.21.1/go.mod h1:hmxADesM3yUN2vbA5z1/YTBnzLJ1dajdqpQonwBL1FQ= +sigs.k8s.io/kustomize/api v0.21.3 h1:DXGrbifGrAd+7AQ75Kx/tVhr+RLMCZFfAYl61HOR3T0= +sigs.k8s.io/kustomize/api v0.21.3/go.mod h1:JKlHjHXGh54cdoWVdKNqDscFzPb9z+sBk9upy9PBh6k= +sigs.k8s.io/kustomize/kyaml v0.21.3 h1:4ygzLsc8B1ep9dFuEivrJ2lFIAglL19dhPrWp1ioceY= +sigs.k8s.io/kustomize/kyaml v0.21.3/go.mod h1:HrAVNlYyEphOlRCcaSIQziGR+0ehwx6Xp1ucUrpC6hs= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= diff --git a/version/info.codefly.yaml b/version/info.codefly.yaml index 319d44d91..9469d9d18 100644 --- a/version/info.codefly.yaml +++ b/version/info.codefly.yaml @@ -1 +1 @@ -version: 0.19.11 +version: 0.19.12 From 8f998a38563ee4375ab9922c6e2bb4924424c0f8 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:49:10 -0400 Subject: [PATCH 05/13] fix(deps): retain the Kubernetes module compatibility pins --- go.mod | 11 ++++++++--- go.sum | 20 ++++++++++++++------ 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index aee98297d..0231cda58 100644 --- a/go.mod +++ b/go.mod @@ -78,8 +78,8 @@ require ( google.golang.org/protobuf v1.36.12 gopkg.in/yaml.v3 v3.0.1 mvdan.cc/sh/v3 v3.14.1 - sigs.k8s.io/kustomize/api v0.21.3 - sigs.k8s.io/kustomize/kyaml v0.21.3 + sigs.k8s.io/kustomize/api v0.21.1 + sigs.k8s.io/kustomize/kyaml v0.21.1 ) require ( @@ -108,6 +108,7 @@ require ( github.com/cloudflare/circl v1.6.3 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/typeurl/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/davidmz/go-pageant v1.0.2 // indirect github.com/dlclark/regexp2 v1.12.0 // indirect github.com/docker/go-units v0.5.0 // indirect @@ -126,10 +127,14 @@ require ( github.com/go-openapi/jsonpointer v1.0.2 // indirect github.com/go-openapi/jsonreference v1.0.3 // indirect github.com/go-openapi/strfmt v0.27.4 // indirect + github.com/go-openapi/swag v0.29.2 // indirect + github.com/go-openapi/swag/cmdutils v0.29.2 // indirect github.com/go-openapi/swag/conv v0.29.2 // indirect + github.com/go-openapi/swag/fileutils v0.29.2 // indirect github.com/go-openapi/swag/jsonutils v0.29.2 // indirect github.com/go-openapi/swag/loading v0.29.2 // indirect github.com/go-openapi/swag/mangling v0.29.2 // indirect + github.com/go-openapi/swag/netutils v0.29.2 // indirect github.com/go-openapi/swag/pools v0.29.2 // indirect github.com/go-openapi/swag/stringutils v0.29.2 // indirect github.com/go-openapi/swag/typeutils v0.29.2 // indirect @@ -193,7 +198,7 @@ require ( gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gotest.tools/v3 v3.5.1 // indirect - k8s.io/kube-openapi v0.0.0-20261001230523-97fa35140926 // indirect + k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index 2dd7d9bce..1225fc57e 100644 --- a/go.sum +++ b/go.sum @@ -102,6 +102,7 @@ github.com/creativeprojects/go-selfupdate v1.7.0/go.mod h1:RVszRkQ0kVQB2hbqrsgrE github.com/cyphar/filepath-securejoin v0.7.0 h1:s0Y3ITPy6sQn5xt54DuYvTF8hu134ooYLUb58DX/HjE= github.com/cyphar/filepath-securejoin v0.7.0/go.mod h1:ymLGms/u3BYaviIiuKFnUx8EkQEZeK6cInNoAPJA3o4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0= github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= @@ -166,8 +167,13 @@ github.com/go-openapi/spec v1.0.1/go.mod h1:M//GWQGtDUAjnP37gE6fInLgaczB+FatoipV github.com/go-openapi/strfmt v0.27.4 h1:E1xsvHb8R9vsuYru4TP2PVVTv9AIgSFAUACUZrFQfCI= github.com/go-openapi/strfmt v0.27.4/go.mod h1:SCOff81PPhU26acBiW96jHaFycEbT2nkQRvojM+V2z0= github.com/go-openapi/swag v0.29.2 h1:9n8frkcsuQRA0INU31VbxNxt1zBaHxrHYGEf5BUjFi4= +github.com/go-openapi/swag v0.29.2/go.mod h1:RkCiX1gCVXWgLOavDXE3ALJrQ/VO2ziiIzOUs3z10xo= +github.com/go-openapi/swag/cmdutils v0.29.2 h1:cXEzX/nWCODon251f+1HgL75/R4XXML5IZxgLCWMoro= +github.com/go-openapi/swag/cmdutils v0.29.2/go.mod h1:XziaSVzYqkDvpWXzASfWajw2YvJrLscq610O2XiyqJw= github.com/go-openapi/swag/conv v0.29.2 h1:8c9shoB8l0QRSR6ymq1llHdBWDqpIgJfjTGHx3Vuhm0= github.com/go-openapi/swag/conv v0.29.2/go.mod h1:AZS0YigTNf8qNtD6WqJz/N4RUNmpr76SyjFGkq4+p6Q= +github.com/go-openapi/swag/fileutils v0.29.2 h1:mdUL+Vw5ah1fO1AvFCoHeIyv7YZsCz2MN3KNqd/lLVI= +github.com/go-openapi/swag/fileutils v0.29.2/go.mod h1:7QKmmodjAebaq61lGVJa8ldLdGeMF4DSgYv8tXOGGo0= github.com/go-openapi/swag/jsonutils v0.29.2 h1:uZNSD2/rJDYAfvsLYkykTzXuyxM3QpDKV9jhRHrMu6k= github.com/go-openapi/swag/jsonutils v0.29.2/go.mod h1:ONTdNvj3Y+IXRzfa17E+Rgt2ns/qiSOYjIo2K7v2yDs= github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.2 h1:w+Fd6EBOMGlC74GYGHqCLGyb3Bpams8TtNrgM/SG4jo= @@ -176,6 +182,8 @@ github.com/go-openapi/swag/loading v0.29.2 h1:QU1ry24e6r6Shoxe380Nx0X5iKkpRO+hJf github.com/go-openapi/swag/loading v0.29.2/go.mod h1:DqilDjuiJKETecsS4TRopWG1acUDsfqN39ZmQhPu6uI= github.com/go-openapi/swag/mangling v0.29.2 h1:ltdo3K0tTP4P6zWhVVWDU9D37/YYBuMJRdZxiS2Dtog= github.com/go-openapi/swag/mangling v0.29.2/go.mod h1:RjDi4TnItAenS3cgCXSpIl1kKbjG7il6pnmQVl/8bp8= +github.com/go-openapi/swag/netutils v0.29.2 h1:dBli6jyUa93sDS/XYWShJzf4+nnVfFKfvY9SLUaUzB8= +github.com/go-openapi/swag/netutils v0.29.2/go.mod h1:1pM6Xg/Um8E1eps3umWl8Hme3ByWXOtHsvFC0CULHUE= github.com/go-openapi/swag/pools v0.29.2 h1:PlGoDRF8WtSyXkedZZkpW3f9wDhFf3u8KtaBcmgmh8c= github.com/go-openapi/swag/pools v0.29.2/go.mod h1:V3lhxVT4qDYRqc2MRSSbLsTYIYV/2HlHafhyEkmzLIc= github.com/go-openapi/swag/stringutils v0.29.2 h1:lcnBxwAaysT3bMUVBfn9V/PkfVqkurpqufKU6rTUMGk= @@ -496,16 +504,16 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= -k8s.io/kube-openapi v0.0.0-20261001230523-97fa35140926 h1:Fe0G8t8FaMDn2lpat0IHu3X9lHrVyGzDxhhCwKXKp28= -k8s.io/kube-openapi v0.0.0-20261001230523-97fa35140926/go.mod h1:3e2VxsK2svhjvkNik92kYU1a5kgau5tZeAuEWwoN4q0= +k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad h1:oXImqH8mQNk7PmvzKhmN3ddJoY6OnyM225MXwGHPm0A= +k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad/go.mod h1:0/mqHCVhlumdJ3BhCfnjSZQE037nAhNodh1/hK0T8/I= mvdan.cc/sh/v3 v3.14.1 h1:bXkhQWNHCs0KZEChF8hYS6FC+T2N9mUZLbQv9blditI= mvdan.cc/sh/v3 v3.14.1/go.mod h1:syYCoFET8w9tvevxiXUtY8/ICrU+l26jHmhJDra3Vwo= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= -sigs.k8s.io/kustomize/api v0.21.3 h1:DXGrbifGrAd+7AQ75Kx/tVhr+RLMCZFfAYl61HOR3T0= -sigs.k8s.io/kustomize/api v0.21.3/go.mod h1:JKlHjHXGh54cdoWVdKNqDscFzPb9z+sBk9upy9PBh6k= -sigs.k8s.io/kustomize/kyaml v0.21.3 h1:4ygzLsc8B1ep9dFuEivrJ2lFIAglL19dhPrWp1ioceY= -sigs.k8s.io/kustomize/kyaml v0.21.3/go.mod h1:HrAVNlYyEphOlRCcaSIQziGR+0ehwx6Xp1ucUrpC6hs= +sigs.k8s.io/kustomize/api v0.21.1 h1:lzqbzvz2CSvsjIUZUBNFKtIMsEw7hVLJp0JeSIVmuJs= +sigs.k8s.io/kustomize/api v0.21.1/go.mod h1:f3wkKByTrgpgltLgySCntrYoq5d3q7aaxveSagwTlwI= +sigs.k8s.io/kustomize/kyaml v0.21.1 h1:IVlbmhC076nf6foyL6Taw4BkrLuEsXUXNpsE+ScX7fI= +sigs.k8s.io/kustomize/kyaml v0.21.1/go.mod h1:hmxADesM3yUN2vbA5z1/YTBnzLJ1dajdqpQonwBL1FQ= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= From 96fc6d02f49360e4f8bf9c0185e0d9b7a8c2c1ca Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:52:59 -0400 Subject: [PATCH 06/13] fix(proto): remove Rust generation and companion build tooling --- companions/proto/Dockerfile | 7 - companions/proto/README.md | 45 ++--- companions/proto/client.go | 16 +- companions/proto/client_internal_test.go | 156 ++-------------- companions/proto/companion_plugins_test.go | 29 +-- companions/proto/descriptorset_test.go | 81 -------- companions/proto/flake.nix | 2 - companions/proto/grpc.go | 30 +-- companions/proto/grpc_test.go | 24 --- companions/proto/image.go | 16 +- companions/proto/image_test.go | 25 --- companions/proto/info.codefly.yaml | 2 +- companions/proto/rust_output.go | 173 ------------------ .../proto/templates/rust/buf.gen.yaml.tmpl | 21 --- companions/proto/templates/rust/buf.yaml.tmpl | 11 -- version/info.codefly.yaml | 2 +- 16 files changed, 56 insertions(+), 584 deletions(-) delete mode 100644 companions/proto/rust_output.go delete mode 100644 companions/proto/templates/rust/buf.gen.yaml.tmpl delete mode 100644 companions/proto/templates/rust/buf.yaml.tmpl diff --git a/companions/proto/Dockerfile b/companions/proto/Dockerfile index 3ab37c516..a66bccdce 100644 --- a/companions/proto/Dockerfile +++ b/companions/proto/Dockerfile @@ -52,11 +52,6 @@ RUN python3 -m venv /venv && /venv/bin/pip install --no-cache-dir grpcio-tools # --- Runtime stage: minimal alpine + the codefly base --------------- -FROM rust:1.99-alpine AS rust-builder -RUN apk add --no-cache musl-dev && \ - cargo install --locked protoc-gen-prost --version 0.5.0 && \ - cargo install --locked protoc-gen-tonic --version 0.5.0 - # Keep the interpreter compatible with the pinned generator's wheels and # gencode contract. Alpine's python3 moves independently; grpcio-tools 1.71 # has Python 3.13 wheels, not 3.14 wheels. As in the Python companion, copy @@ -124,8 +119,6 @@ COPY --from=builder /go/bin/swagger /usr/local/bin/ # the plugins — a formatter that moves with @latest moves the committed shape. COPY --from=builder /go/bin/goimports /usr/local/bin/ COPY --from=builder /usr/bin/grpc_* /usr/local/bin/ -COPY --from=rust-builder /usr/local/cargo/bin/protoc-gen-prost /usr/local/bin/ -COPY --from=rust-builder /usr/local/cargo/bin/protoc-gen-tonic /usr/local/bin/ # Facade plugin sources (Python scripts + the TS npm package). COPY companions/proto/facades/python /opt/codefly-facades/python diff --git a/companions/proto/README.md b/companions/proto/README.md index 94858a07f..2f06a98da 100644 --- a/companions/proto/README.md +++ b/companions/proto/README.md @@ -2,8 +2,11 @@ The codefly proto companion bundles `buf`, `protoc`, `protoc-gen-*`, `swagger`, `grpcio-tools`, and the JS/TS generators into one OCI -image. Used by `codefly generate proto` and the -`codefly generate openAPI` pipeline. +image. All proto generation runs through Codefly (`codefly generate proto` +and `codefly generate client`), which owns the pinned companion invocation. +Supported client languages are Go, Python, and TypeScript. The image and its +build contain no Rust toolchain or Rust generation plugins; unsupported +language requests fail before writing output. ## Build @@ -92,32 +95,16 @@ may also contain handwritten Go, so only files carrying Go's standard ## Versioning -`info.codefly.yaml` carries the image version (`0.0.10` at time of -writing). Bump via `scripts/tag.sh` — same convention as every other -companion. Both the Dockerfile build and the Nix build read this -value so the tag stays consistent. -## Rust client imports +`info.codefly.yaml` carries the image version. Version 0.0.21 removes Rust +generation and its build dependencies. Publish through `codefly companion +publish` using the [publication procedure](../../docs/runbooks/publish-companions.md); +changing the manifest alone does not publish an image. Core 0.19.13 selects +this companion. -The proto companion supplies local `protoc-gen-prost` and `protoc-gen-tonic` -0.5.0, targeting prost/tonic 0.14. Rust clients retain googleapis and -protovalidate message bindings beside the application's bindings. Standard -`google.protobuf` types continue to resolve through `prost-types`. - -Client generation validates descriptor-set type references before invoking the -plugins. Both source and descriptor-set generation are covered by a Cargo -compile and encode/decode regression containing `google.rpc.Status` and -`buf.validate.Violations`: - -```sh -go test ./companions/proto -tags=proto_companion_required -run TestRustClientRetainsImportedMessageFields -timeout=180s -``` - -This requires the built proto companion and Cargo. The updated plugins ship in -companion version 0.0.16; bumping the manifest alone does not publish that image. -Use the [companion publication procedure](../../docs/runbooks/publish-companions.md). +## Python generation Python bindings come from the companion's own plugin, `protoc-gen-codefly-python` -(`facades/python/protoc_gen_codefly_python.py`), as Go's and Rust's come from +(`facades/python/protoc_gen_codefly_python.py`), as Go's come from local plugins: it compiles the `*_pb2` messages and `*_pb2_grpc` stubs with the `grpc_tools` pinned in the image's venv (`grpcio-tools==1.71.0`, `protobuf==5.29.3`), so the gencode is versioned with the image. The Docker @@ -139,11 +126,3 @@ A change to any of a companion's image inputs (its Dockerfile and every build-context `COPY` source, `BuildSpec.Inputs`) must bump its `info.codefly.yaml`: `internal/companionbump`, the required `Companion version` check on every pull request, refuses one that does not. - -Rust generation stages output before publishing it. `.codefly-rust-output.json` -records the generated files and their hashes; only unchanged, tracked files may -be replaced or removed. Unrelated files are preserved. A collision with an -untracked file or an edited binding is an error, including when adopting output -created before ownership tracking. Generate into an empty output directory in -that case; do not discard local edits. Failed generation leaves existing output -untouched, and publication errors roll back changed files. diff --git a/companions/proto/client.go b/companions/proto/client.go index 829e64bb9..fa92f86f0 100644 --- a/companions/proto/client.go +++ b/companions/proto/client.go @@ -113,6 +113,12 @@ type clientSpec struct { func generateClient(ctx context.Context, spec clientSpec) error { w := wool.Get(ctx).In("generateClient", wool.DirField(spec.destination)) + switch spec.language { + case languages.GO, languages.PYTHON, languages.TYPESCRIPT: + default: + return w.NewError("unsupported proto generation language %q; supported languages: go, python, typescript", spec.language) + } + image, err := CompanionImage(ctx) if err != nil { return w.Wrapf(err, "cannot get companion image") @@ -152,9 +158,7 @@ func generateClient(ctx context.Context, spec clientSpec) error { // Sources only: a descriptor set carries its foreign files already marked as // buf image imports, and re-targeting them would put a second Timestamp next // to the consumer's runtime one. - // Rust also needs imported bindings; prost keeps google.protobuf external - // through its default prost-types mapping. - includeImports := len(spec.sources) > 0 && (spec.language == languages.TYPESCRIPT || spec.language == languages.RUST) + includeImports := len(spec.sources) > 0 && spec.language == languages.TYPESCRIPT if err = CreateBufConfiguration(ctx, tmpDir, spec.service, spec.language, spec.facade, WithGoPackageOverrides(goPackageOverrides), WithIncludeImports(includeImports)); err != nil { @@ -210,12 +214,6 @@ func generateClient(ctx context.Context, spec clientSpec) error { if _, err = shared.CheckDirectoryOrCreate(ctx, spec.destination); err != nil { return w.Wrapf(err, "cannot create destination") } - if spec.language == languages.RUST { - return generateRustOutput(ctx, spec.destination, func(output string) error { - name := fmt.Sprintf("proto-%s-%d-%s", spec.service, time.Now().UnixMilli(), spec.language) - return runBuf(ctx, name, image, tmpDir, output, false, depUpdate, generateArgs, before) - }) - } if includeImports { // The run writes a tree for every namespace the sources import, which is diff --git a/companions/proto/client_internal_test.go b/companions/proto/client_internal_test.go index 37a66c16a..2e9f08c85 100644 --- a/companions/proto/client_internal_test.go +++ b/companions/proto/client_internal_test.go @@ -2,154 +2,14 @@ package proto import ( "context" - "fmt" "os" "path/filepath" "testing" - "time" "github.com/codefly-dev/core/languages" - "github.com/gofrs/flock" "github.com/stretchr/testify/require" ) -func writeRustOutput(t *testing.T, dir, name, content string) { - t.Helper() - file := filepath.Join(dir, name) - require.NoError(t, os.MkdirAll(filepath.Dir(file), 0o750)) - require.NoError(t, os.WriteFile(file, []byte(content), 0o600)) -} - -func TestCanceledRustGenerationPreservesDestination(t *testing.T) { - dest := t.TempDir() - for _, name := range []string{"api/api.rs", "src/user.rs", "google/rpc/google.rpc.rs"} { - writeRustOutput(t, dest, name, "existing content") - } - ctx, cancel := context.WithCancel(t.Context()) - cancel() - err := GenerateClient(ctx, ClientRequest{Language: languages.RUST, Destination: dest, - Sources: []Source{{Path: "api.proto", Content: []byte(`syntax = "proto3"; package api; message Response { string value = 1; }`)}}}) - require.Error(t, err) - for _, name := range []string{"api/api.rs", "src/user.rs", "google/rpc/google.rpc.rs"} { - data, err := os.ReadFile(filepath.Join(dest, name)) - require.NoError(t, err) - require.Equal(t, "existing content", string(data)) - } -} - -func TestRustPublicationOwnsFilesNotDirectories(t *testing.T) { - dest := t.TempDir() - writeRustOutput(t, dest, "src/user.rs", "user content") - first := t.TempDir() - writeRustOutput(t, first, "api/api.rs", "old generated") - writeRustOutput(t, first, "google/rpc/google.rpc.rs", "old import") - require.NoError(t, publishRustOutput(dest, first)) - writeRustOutput(t, dest, "google/rpc/user.rs", "user content") - second := t.TempDir() - writeRustOutput(t, second, "api/api.rs", "new generated") - require.NoError(t, publishRustOutput(dest, second)) - require.NoFileExists(t, filepath.Join(dest, "google/rpc/google.rpc.rs")) - for _, name := range []string{"src/user.rs", "google/rpc/user.rs"} { - data, err := os.ReadFile(filepath.Join(dest, name)) - require.NoError(t, err) - require.Equal(t, "user content", string(data)) - } - data, err := os.ReadFile(filepath.Join(dest, "api/api.rs")) - require.NoError(t, err) - require.Equal(t, "new generated", string(data)) -} - -func TestRustPublicationRejectsEditsAndUnownedCollisions(t *testing.T) { - for _, owned := range []bool{false, true} { - t.Run(fmt.Sprint(owned), func(t *testing.T) { - dest := t.TempDir() - if owned { - first := t.TempDir() - writeRustOutput(t, first, "api/api.rs", "generated") - require.NoError(t, publishRustOutput(dest, first)) - } - writeRustOutput(t, dest, "api/api.rs", "user edit") - stage := t.TempDir() - writeRustOutput(t, stage, "api/api.rs", "replacement") - writeRustOutput(t, stage, "aaa/new.rs", "new output") - require.ErrorContains(t, publishRustOutput(dest, stage), "unowned or edited") - data, err := os.ReadFile(filepath.Join(dest, "api/api.rs")) - require.NoError(t, err) - require.Equal(t, "user edit", string(data)) - require.NoFileExists(t, filepath.Join(dest, "aaa/new.rs")) - }) - } -} - -func TestRustPublicationDoesNotPruneEditedImports(t *testing.T) { - dest, first := t.TempDir(), t.TempDir() - writeRustOutput(t, first, "google/rpc/google.rpc.rs", "generated import") - require.NoError(t, publishRustOutput(dest, first)) - writeRustOutput(t, dest, "google/rpc/google.rpc.rs", "edited import") - require.ErrorContains(t, publishRustOutput(dest, t.TempDir()), "unowned or edited") - data, err := os.ReadFile(filepath.Join(dest, "google/rpc/google.rpc.rs")) - require.NoError(t, err) - require.Equal(t, "edited import", string(data)) -} - -func TestRustPublicationRollsBackPartialFailure(t *testing.T) { - dest := t.TempDir() - first := t.TempDir() - writeRustOutput(t, first, "aaa/old.rs", "old output") - require.NoError(t, publishRustOutput(dest, first)) - manifest, err := os.ReadFile(filepath.Join(dest, rustOutputManifest)) - require.NoError(t, err) - require.NoError(t, os.Symlink("missing-target", filepath.Join(dest, "zzz"))) - stage := t.TempDir() - writeRustOutput(t, stage, "aaa/old.rs", "replacement") - writeRustOutput(t, stage, "zzz/new.rs", "cannot publish") - require.Error(t, publishRustOutput(dest, stage)) - data, err := os.ReadFile(filepath.Join(dest, "aaa/old.rs")) - require.NoError(t, err) - require.Equal(t, "old output", string(data)) - data, err = os.ReadFile(filepath.Join(dest, rustOutputManifest)) - require.NoError(t, err) - require.Equal(t, manifest, data) - require.NoFileExists(t, filepath.Join(dest, "zzz/new.rs")) -} - -func TestRustPublicationRejectsEscapingSymlink(t *testing.T) { - dest, stage, outside := t.TempDir(), t.TempDir(), t.TempDir() - require.NoError(t, os.Symlink(outside, filepath.Join(dest, "api"))) - writeRustOutput(t, stage, "api/api.rs", "generated") - require.Error(t, publishRustOutput(dest, stage)) - require.NoFileExists(t, filepath.Join(outside, "api.rs")) -} - -func TestRustGenerationHonorsDestinationLockThroughSymlink(t *testing.T) { - dest, err := filepath.EvalSymlinks(t.TempDir()) - require.NoError(t, err) - writeRustOutput(t, dest, "api/api.rs", "existing output") - alias := filepath.Join(t.TempDir(), "alias") - require.NoError(t, os.Symlink(dest, alias)) - lock := flock.New(filepath.Join(dest, rustOutputLock)) - require.NoError(t, lock.Lock()) - t.Cleanup(func() { require.NoError(t, lock.Close()) }) - ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond) - defer cancel() - err = GenerateClient(ctx, ClientRequest{Language: languages.RUST, Destination: alias, - Sources: []Source{{Path: "api.proto", Content: []byte(`syntax = "proto3"; package api; message Response {}`)}}}) - require.ErrorIs(t, err, context.DeadlineExceeded) - data, err := os.ReadFile(filepath.Join(dest, "api/api.rs")) - require.NoError(t, err) - require.Equal(t, "existing output", string(data)) -} - -func TestRustPublicationRejectsInvalidOwnership(t *testing.T) { - for _, body := range []string{`{`, `{"../outside":"hash"}`, `{"/outside":"hash"}`, `{".codefly-rust-output.json":"hash"}`, `{".codefly-rust-output.lock":"hash"}`} { - dest, stage := t.TempDir(), t.TempDir() - writeRustOutput(t, dest, rustOutputManifest, body) - writeRustOutput(t, stage, "api/api.rs", "generated") - require.Error(t, publishRustOutput(dest, stage)) - require.NoFileExists(t, filepath.Join(dest, "api/api.rs")) - } -} - // TestRemoveUnownedOutput pins what a TypeScript run is allowed to keep in its // destination. Asking buf for the imports means the run writes a tree per // imported namespace, which is not a set known ahead of time — so the @@ -213,3 +73,19 @@ func TestRemoveUnownedOutputKeepsRootLevelOutput(t *testing.T) { _, err := os.Stat(filepath.Join(dest, "google")) require.True(t, os.IsNotExist(err)) } + +func TestUnsupportedClientLanguagePreservesDestination(t *testing.T) { + dest := t.TempDir() + file := filepath.Join(dest, "existing.txt") + require.NoError(t, os.WriteFile(file, []byte("keep"), 0600)) + err := GenerateClient(t.Context(), ClientRequest{Language: languages.RUST, Destination: dest, + Sources: []Source{{Path: "api.proto", Content: []byte(`syntax = "proto3"; package api; message Request {}`)}}}) + require.ErrorContains(t, err, "unsupported proto generation language") + entries, err := os.ReadDir(dest) + require.NoError(t, err) + require.Len(t, entries, 1) + data, err := os.ReadFile(file) + require.NoError(t, err) + require.Equal(t, "keep", string(data)) + require.ErrorContains(t, CreateBufConfiguration(t.Context(), dest, "api", languages.RUST, FacadeOptions{}), "unsupported proto generation language") +} diff --git a/companions/proto/companion_plugins_test.go b/companions/proto/companion_plugins_test.go index c39abc045..67ed0dd4d 100644 --- a/companions/proto/companion_plugins_test.go +++ b/companions/proto/companion_plugins_test.go @@ -21,19 +21,6 @@ import ( // content hashes — fail the Nix build rather than any test here. const protocGenEsRuntimeVersion = "2.11.0" -func TestCompanionInstallsLocalRustPlugins(t *testing.T) { - for _, file := range []string{"Dockerfile", "flake.nix", "templates/rust/buf.gen.yaml.tmpl"} { - assertMentionsAll(t, file, []string{"protoc-gen-prost", "protoc-gen-tonic"}) - } - content, err := os.ReadFile("templates/rust/buf.gen.yaml.tmpl") - if err != nil { - t.Fatal(err) - } - if strings.Contains(string(content), "buf.build/") { - t.Fatal("Rust generation must use the companion's local plugins") - } -} - // bumpRunbook records why the pin sits where it does and what moving it costs. // The guards below keep it honest, because a stale runbook is worse than none: // it still reads as current. @@ -402,7 +389,7 @@ func TestTypeScriptTemplateUsesTheBakedProtocGenEs(t *testing.T) { } } -// Python's bindings come from the companion's own plugin, as Go's and Rust's do: +// Python's bindings come from the companion's own plugin, as Go's do: // buf's remote python plugins moved on their own, and their gencode outran the // protobuf runtime a generated library declares. Both build definitions ship // the plugin, and the image pins the grpc_tools it runs. @@ -419,3 +406,17 @@ func TestCompanionGeneratesPythonWithItsLocalPlugin(t *testing.T) { } assertMentionsAll(t, "Dockerfile", []string{"protobuf==5.29.3", "grpcio-tools==1.71.0"}) } + +func TestCompanionBuildsWithoutRust(t *testing.T) { + for _, file := range []string{"Dockerfile", "flake.nix"} { + data, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + for _, tool := range []string{"rust:", "rust-builder", "cargo", "protoc-gen-prost", "protoc-gen-tonic"} { + if strings.Contains(string(data), tool) { + t.Errorf("%s includes removed Rust tooling %q", file, tool) + } + } + } +} diff --git a/companions/proto/descriptorset_test.go b/companions/proto/descriptorset_test.go index 5df7a83c4..37f27a24b 100644 --- a/companions/proto/descriptorset_test.go +++ b/companions/proto/descriptorset_test.go @@ -78,87 +78,6 @@ func plainDescriptorSetFor(t *testing.T, ctx context.Context, source string) []b return image } -func TestRustClientRetainsImportedMessageFields(t *testing.T) { - ctx := t.Context() - testutil.RequireProtoImage(t, ctx) - source := strings.Replace(restProto, `import "google/protobuf/timestamp.proto";`, `import "google/protobuf/timestamp.proto"; -import "google/rpc/status.proto";`, 1) - source = strings.Replace(source, "google.protobuf.Timestamp created_at = 1;", `google.protobuf.Timestamp created_at = 1; - google.rpc.Status status = 2; - buf.validate.Violations violations = 3;`, 1) - for _, descriptors := range []bool{false, true} { - name := "sources" - if descriptors { - name = "descriptor-set" - } - t.Run(name, func(t *testing.T) { - parent := t.TempDir() - dest := filepath.Join(parent, "output") - require.NoError(t, os.Mkdir(dest, 0o750)) - require.NoError(t, os.Chmod(parent, 0o550)) - t.Cleanup(func() { require.NoError(t, os.Chmod(parent, 0o750)) }) - request := proto.ClientRequest{Language: languages.RUST, Destination: dest, Module: "rest"} - if descriptors { - request.DescriptorSet = plainDescriptorSetFor(t, ctx, source) - } else { - request.Sources = []proto.Source{{Path: restProtoPath, Content: []byte(source)}} - } - require.NoError(t, proto.GenerateClient(ctx, request)) - bindings, err := os.ReadFile(filepath.Join(dest, "saas/rest/v1/saas.rest.v1.rs")) - require.NoError(t, err) - require.Contains(t, string(bindings), "pub status:") - require.Contains(t, string(bindings), "pub violations:") - require.Contains(t, string(bindings), "::prost_types::Timestamp") - require.NoDirExists(t, filepath.Join(dest, "google/protobuf")) - unrelated := filepath.Join(dest, "src", "user.rs") - require.NoError(t, os.MkdirAll(filepath.Dir(unrelated), 0o750)) - require.NoError(t, os.WriteFile(unrelated, []byte("user source"), 0o600)) - broken := proto.ClientRequest{Language: languages.RUST, Destination: dest, - Sources: []proto.Source{{Path: "api.proto", Content: []byte("invalid proto")}}} - require.Error(t, proto.GenerateClient(ctx, broken)) - unchanged, err := os.ReadFile(filepath.Join(dest, "saas/rest/v1/saas.rest.v1.rs")) - require.NoError(t, err) - require.Equal(t, bindings, unchanged) - require.NoError(t, proto.GenerateClient(ctx, request)) - unchanged, err = os.ReadFile(unrelated) - require.NoError(t, err) - require.Equal(t, "user source", string(unchanged)) - require.NoError(t, os.WriteFile(filepath.Join(dest, "Cargo.toml"), []byte(`[package] -name = "generated-imports-test" -version = "0.0.0" -edition = "2021" -[dependencies] -prost = "=0.14.1" -prost-types = "=0.14.1" -tonic = "=0.14.2" -tonic-prost = "=0.14.2" -[lib] -path = "lib.rs" -`), 0o600)) - require.NoError(t, os.WriteFile(filepath.Join(dest, "lib.rs"), []byte(`pub mod google { pub mod rpc { include!("google/rpc/google.rpc.rs"); } } -pub mod buf { pub mod validate { include!("buf/validate/buf.validate.rs"); } } -pub mod saas { pub mod rest { pub mod v1 { include!("saas/rest/v1/saas.rest.v1.rs"); } } } -#[test] -fn imported_messages_round_trip() { - use prost::Message; - let value = saas::rest::v1::GetResponse { - created_at: Some(prost_types::Timestamp { seconds: 42, nanos: 7 }), - status: Some(google::rpc::Status { code: 9, message: "retained".into(), details: vec![] }), - violations: Some(buf::validate::Violations { violations: vec![] }), - }; - let decoded = saas::rest::v1::GetResponse::decode(value.encode_to_vec().as_slice()).unwrap(); - assert_eq!(decoded, value); - assert_eq!(decoded.status.unwrap().message, "retained"); -} -`), 0o600)) - command := exec.CommandContext(ctx, "cargo", "test", "--quiet") - command.Dir = dest - out, err := command.CombinedOutput() - require.NoError(t, err, "%s", out) - }) - } -} - // TestGoClientFromPlainDescriptorSetOwnsOnlyItsOwnProtos is the test that fails // without the marking, in both of the ways the marking has to fix. // diff --git a/companions/proto/flake.nix b/companions/proto/flake.nix index 6a47fc956..247340b73 100644 --- a/companions/proto/flake.nix +++ b/companions/proto/flake.nix @@ -221,8 +221,6 @@ # `go install`). protoc-gen-go protoc-gen-go-grpc - protoc-gen-prost - protoc-gen-tonic # grpc-gateway ships both protoc-gen-grpc-gateway and # protoc-gen-openapiv2 binaries; there are no separate # top-level attrs for them (referencing them directly is an diff --git a/companions/proto/grpc.go b/companions/proto/grpc.go index 31dce0c91..c80a0fd76 100644 --- a/companions/proto/grpc.go +++ b/companions/proto/grpc.go @@ -89,11 +89,6 @@ type TypeScriptConfiguration struct { IncludeImports bool } -type RustConfiguration struct { - Destination string - IncludeImports bool -} - // BufConfigurationOption tunes the rendered buf configuration. It is variadic so // existing callers keep compiling unchanged. type BufConfigurationOption func(*bufConfigurationOptions) @@ -143,16 +138,10 @@ func CreateBufConfiguration(ctx context.Context, bufDir string, service string, return w.Wrapf(err, "cannot templatize") } return nil - case languages.RUST: - err := templateRustConfiguration(ctx, bufDir, options.includeImports) - if err != nil { - return w.Wrapf(err, "cannot templatize") - } - return nil case languages.NotSupported: return w.NewError("language not supported") } - return w.NewError("unknown language") + return w.NewError("unsupported proto generation language %q; supported languages: go, python, typescript", language) } func templateGoConfiguration(ctx context.Context, bufDir string, goPackagePrefix string, facade FacadeOptions, goPackageOverrides map[string]string) error { @@ -206,20 +195,6 @@ func templateTypeScriptConfiguration(ctx context.Context, bufDir string, facade return nil } -func templateRustConfiguration(ctx context.Context, bufDir string, includeImports bool) error { - w := wool.Get(ctx).In("templateRustConfiguration", wool.Field("bufDir", bufDir)) - templator := &templates.Templator{NameReplacer: templates.CutTemplateSuffix{}} - conf := RustConfiguration{ - Destination: outputDir, - IncludeImports: includeImports, - } - err := templator.CopyAndApply(ctx, rustFS, "templates/rust", bufDir, conf) - if err != nil { - return w.Wrapf(err, "cannot copy and apply template") - } - return nil -} - // runBuf sets up the proto companion over a prepared temp dir and runs buf. // before, when set, runs after the runner is initialized but before buf // generate (used by the Python facade path to strip the descriptor image). @@ -294,6 +269,3 @@ var pythonFS embed.FS //go:embed templates/typescript var typescriptFS embed.FS - -//go:embed templates/rust -var rustFS embed.FS diff --git a/companions/proto/grpc_test.go b/companions/proto/grpc_test.go index c2d79c181..e6246347c 100644 --- a/companions/proto/grpc_test.go +++ b/companions/proto/grpc_test.go @@ -142,30 +142,6 @@ func TestGeneratePythonGRPC(t *testing.T) { } } -func TestGenerateRustGRPC(t *testing.T) { - wool.SetGlobalLogLevel(wool.DEBUG) - ctx := context.Background() - - testutil.RequireProtoImage(t, ctx) - - apiProto := filepath.Join(testdataDir(t), "api.proto") - ep := &resources.Endpoint{Module: "app", Service: "svc", Name: "api", Visibility: "private"} - api, err := resources.LoadGrpcAPI(ctx, shared.Pointer(apiProto)) - require.NoError(t, err) - grpc, err := resources.NewAPI(ctx, ep, resources.ToGrpcAPI(api)) - require.NoError(t, err) - destination := t.TempDir() - - err = proto.GenerateGRPC(ctx, languages.RUST, destination, "app/svc", grpc) - require.NoError(t, err, "proto companion image not built: %s", testutil.BuildCompanionsHint) - - // prost + tonic emit per proto package ("api/"): messages in api.rs, - // the tonic client in api.tonic.rs. - for _, name := range []string{"api/api.rs", "api/api.tonic.rs"} { - require.FileExists(t, filepath.Join(destination, name)) - } -} - // TestGenerateTypeScriptGRPC covers the namespaces protoc-gen-es imports by a // path relative to the bindings rather than from @bufbuild/protobuf/wkt. buf // generates nothing for a dependency module, so without being told otherwise it diff --git a/companions/proto/image.go b/companions/proto/image.go index dea2573b9..4feb11c73 100644 --- a/companions/proto/image.go +++ b/companions/proto/image.go @@ -1,14 +1,12 @@ package proto import ( - "fmt" "path" "strings" "github.com/codefly-dev/core/languages" "google.golang.org/protobuf/encoding/protowire" googleproto "google.golang.org/protobuf/proto" - "google.golang.org/protobuf/reflect/protodesc" "google.golang.org/protobuf/reflect/protoreflect" "google.golang.org/protobuf/types/descriptorpb" ) @@ -165,13 +163,10 @@ func isForeign(file *descriptorpb.FileDescriptorProto, language languages.Langua // Go and Python name a dropped file's package absolutely — a rewritten // go_package, an untouched `from buf.validate import validate_pb2` — so // every foreign namespace resolves to whatever the consumer installed. - // TypeScript and Rust do so only for their runtime-supplied protobuf types. + // TypeScript does so only for its runtime-supplied protobuf types. if language == languages.TYPESCRIPT { return protobufESRuntimeFiles[file.GetName()] } - if language == languages.RUST { - return file.GetPackage() == "google.protobuf" || strings.HasPrefix(file.GetPackage(), "google.protobuf.") - } return true } @@ -182,7 +177,7 @@ func isForeign(file *descriptorpb.FileDescriptorProto, language languages.Langua // // Which namespaces those are depends on language, because dropping one only // works if the generator refers to it by package name: Go and Python do that for -// all of them, TypeScript and Rust only for runtime-supplied protobuf types. +// all of them, TypeScript only for runtime-supplied protobuf types. // See foreignNamespace. // // A descriptor set handed to GenerateClient is a *plain* FileDescriptorSet — @@ -198,7 +193,7 @@ func isForeign(file *descriptorpb.FileDescriptorProto, language languages.Langua // bindings importing a package nothing generated. The returned map is fed back // into buf.gen.yaml as managed.override.GO_PACKAGE so each marked file keeps // the go_package it declares, and the module's bindings reference the canonical -// upstream package (google.golang.org/genproto/..., prost-types, ...) exactly +// upstream package (google.golang.org/genproto/..., ...) exactly // as they do on the Sources path. // // `buf generate --exclude-path google/protobuf` would drop the well-known types @@ -225,11 +220,6 @@ func MarkForeignImports(descriptorSet []byte, language languages.Language, goReq if err := googleproto.Unmarshal(descriptorSet, &set); err != nil { return nil, nil, err } - if language == languages.RUST { - if _, err := protodesc.NewFiles(&set); err != nil { - return nil, nil, fmt.Errorf("resolve Rust client descriptor types: %w", err) - } - } isImport := protowire.AppendVarint(protowire.AppendTag(nil, bufImageFileIsImportField, protowire.VarintType), 1) extension := protowire.AppendBytes(protowire.AppendTag(nil, bufImageFileExtensionField, protowire.BytesType), isImport) goPackages := make(map[string]string) diff --git a/companions/proto/image_test.go b/companions/proto/image_test.go index 5ddd01646..02f470d16 100644 --- a/companions/proto/image_test.go +++ b/companions/proto/image_test.go @@ -83,31 +83,6 @@ func marshal(t *testing.T, set *descriptorpb.FileDescriptorSet) []byte { return data } -func TestRustForeignBoundary(t *testing.T) { - for _, file := range foreignSet().File { - want := strings.HasPrefix(file.GetName(), "google/") && (file.GetPackage() == "google.protobuf" || strings.HasPrefix(file.GetPackage(), "google.protobuf.")) - require.Equal(t, want, isForeign(file, languages.RUST, nil), file.GetName()) - } - file := &descriptorpb.FileDescriptorProto{Name: googleproto.String("google/rpc/status.proto"), Package: googleproto.String("google.rpc")} - require.False(t, isForeign(file, languages.RUST, nil)) -} - -func TestRustRejectsUnresolvedMessageTypes(t *testing.T) { - set := &descriptorpb.FileDescriptorSet{File: []*descriptorpb.FileDescriptorProto{{ - Name: googleproto.String("response.proto"), Package: googleproto.String("example"), Syntax: googleproto.String("proto3"), - MessageType: []*descriptorpb.DescriptorProto{{ - Name: googleproto.String("Response"), - Field: []*descriptorpb.FieldDescriptorProto{{ - Name: googleproto.String("status"), Number: googleproto.Int32(1), - Type: descriptorpb.FieldDescriptorProto_TYPE_MESSAGE.Enum(), - TypeName: googleproto.String(".google.rpc.Status"), - }}, - }}, - }}} - _, _, err := MarkForeignImports(marshal(t, set), languages.RUST) - require.ErrorContains(t, err, "google.rpc.Status") -} - // TestMarkForeignImports pins which files of a descriptor set buf is told not to // generate for: every namespace whose bindings someone else publishes. Vendoring // any of them into the generated library puts a second registration of the same diff --git a/companions/proto/info.codefly.yaml b/companions/proto/info.codefly.yaml index 89acbc43c..fb15e7160 100644 --- a/companions/proto/info.codefly.yaml +++ b/companions/proto/info.codefly.yaml @@ -1 +1 @@ -version: 0.0.20 +version: 0.0.21 diff --git a/companions/proto/rust_output.go b/companions/proto/rust_output.go deleted file mode 100644 index 35429314e..000000000 --- a/companions/proto/rust_output.go +++ /dev/null @@ -1,173 +0,0 @@ -package proto - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io/fs" - "os" - "path/filepath" - "sort" - "time" - - "github.com/gofrs/flock" -) - -const rustOutputManifest = ".codefly-rust-output.json" -const rustOutputLock = ".codefly-rust-output.lock" - -func generateRustOutput(ctx context.Context, destination string, generate func(string) error) error { - destination, err := filepath.Abs(destination) - if err != nil { - return err - } - destination, err = filepath.EvalSymlinks(destination) - if err != nil { - return err - } - lock := flock.New(filepath.Join(destination, rustOutputLock), flock.SetPermissions(0o600)) - locked, err := lock.TryLockContext(ctx, 50*time.Millisecond) - if err != nil { - return err - } - if !locked { - return ctx.Err() - } - defer lock.Close() - stage, err := os.MkdirTemp(destination, ".rust-output-*") - if err != nil { - return err - } - defer os.RemoveAll(stage) - if err := generate(stage); err != nil { - return err - } - if err := ctx.Err(); err != nil { - return err - } - return publishRustOutput(destination, stage) -} - -func publishRustOutput(destination, stage string) error { - root, err := os.OpenRoot(destination) - if err != nil { - return err - } - defer root.Close() - previous := map[string]string{} - data, err := root.ReadFile(rustOutputManifest) - if err == nil { - if err := json.Unmarshal(data, &previous); err != nil { - return fmt.Errorf("read Rust output ownership: %w", err) - } - } else if !errors.Is(err, fs.ErrNotExist) { - return err - } - next := map[string]string{} - if err := filepath.WalkDir(stage, func(name string, entry fs.DirEntry, err error) error { - if err != nil || entry.IsDir() { - return err - } - if !entry.Type().IsRegular() { - return fmt.Errorf("non-regular Rust output: %s", name) - } - rel, err := filepath.Rel(stage, name) - if err != nil { - return err - } - data, err := os.ReadFile(name) - if err != nil { - return err - } - next[filepath.ToSlash(rel)] = rustOutputDigest(data) - return nil - }); err != nil { - return err - } - paths := map[string]bool{} - for name := range previous { - paths[name] = true - } - for name := range next { - paths[name] = true - } - names := make([]string, 0, len(paths)) - for name := range paths { - if !fs.ValidPath(name) || name == "." || name == rustOutputManifest || name == rustOutputLock { - return fmt.Errorf("invalid Rust output path: %q", name) - } - info, err := root.Lstat(name) - if errors.Is(err, fs.ErrNotExist) { - names = append(names, name) - continue - } - if err != nil { - return err - } - if !info.Mode().IsRegular() { - return fmt.Errorf("Rust output conflicts with non-regular file: %s", name) - } - data, err := root.ReadFile(name) - if err != nil { - return err - } - if previous[name] == "" || rustOutputDigest(data) != previous[name] { - return fmt.Errorf("Rust output conflicts with unowned or edited file: %s", name) - } - names = append(names, name) - } - sort.Strings(names) - data, err = json.Marshal(next) - if err != nil { - return err - } - if err := os.WriteFile(filepath.Join(stage, rustOutputManifest), data, 0o600); err != nil { - return err - } - names = append(names, rustOutputManifest) - backup, err := os.MkdirTemp(destination, ".rust-backup-*") - if err != nil { - return err - } - defer os.Remove(backup) - var restore []func() error - rollback := func(cause error) error { - for i := len(restore) - 1; i >= 0; i-- { - cause = errors.Join(cause, restore[i]()) - } - return cause - } - for index, name := range names { - old := filepath.Join(filepath.Base(backup), fmt.Sprint(index)) - if err := root.Rename(name, old); err == nil { - restore = append(restore, func() error { return root.Rename(old, name) }) - } else if !errors.Is(err, fs.ErrNotExist) { - return rollback(err) - } - if _, exists := next[name]; !exists && name != rustOutputManifest { - continue - } - if err := root.MkdirAll(filepath.Dir(name), 0o750); err != nil { - return rollback(err) - } - // Stage and destination share a filesystem; root confines publication even - // when a destination ancestor is a symlink. - staged := filepath.Join(filepath.Base(backup), "next") - if err := os.Rename(filepath.Join(stage, filepath.FromSlash(name)), filepath.Join(destination, staged)); err != nil { - return rollback(err) - } - if err := root.Rename(staged, name); err != nil { - return rollback(err) - } - restore = append(restore, func() error { return root.Remove(name) }) - } - return os.RemoveAll(backup) -} - -func rustOutputDigest(data []byte) string { - digest := sha256.Sum256(data) - return hex.EncodeToString(digest[:]) -} diff --git a/companions/proto/templates/rust/buf.gen.yaml.tmpl b/companions/proto/templates/rust/buf.gen.yaml.tmpl deleted file mode 100644 index feb7d3f89..000000000 --- a/companions/proto/templates/rust/buf.gen.yaml.tmpl +++ /dev/null @@ -1,21 +0,0 @@ -version: v2 -plugins: - # The companion pins both local plugins to 0.5.0, targeting tonic/prost 0.14 - # (codec via the separate `tonic-prost` crate). Local execution preserves - # descriptor-set target selection for imported message types. - # - # NOT compile_well_known_types: that makes prost emit RELATIVE paths for - # google.protobuf.* (`super::super::google::…`) which assume one exact module - # nesting and break under any include layout. The default (extern paths to the - # `prost-types` crate — `::prost_types::Timestamp`, and `Empty` → `()`) is - # layout-independent, so the consuming crate just depends on `prost-types`. - - local: protoc-gen-prost - out: {{.Destination}} - strategy: all - include_imports: {{.IncludeImports}} - - local: protoc-gen-tonic - out: {{.Destination}} - strategy: all - include_imports: {{.IncludeImports}} - opt: - - no_server diff --git a/companions/proto/templates/rust/buf.yaml.tmpl b/companions/proto/templates/rust/buf.yaml.tmpl deleted file mode 100644 index 9403b2c6e..000000000 --- a/companions/proto/templates/rust/buf.yaml.tmpl +++ /dev/null @@ -1,11 +0,0 @@ -version: v1 -deps: - - buf.build/googleapis/googleapis - - buf.build/grpc-ecosystem/grpc-gateway - - buf.build/grpc/grpc - - buf.build/bufbuild/protovalidate -lint: - use: - - DEFAULT - ignore: - - PACKAGE_UNUSED diff --git a/version/info.codefly.yaml b/version/info.codefly.yaml index 9469d9d18..ea8113a3e 100644 --- a/version/info.codefly.yaml +++ b/version/info.codefly.yaml @@ -1 +1 @@ -version: 0.19.12 +version: 0.19.13 From 09b1811b53644990a5966940b034cdb87fb61f1c Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:56:30 -0400 Subject: [PATCH 07/13] test(proto): remove Rust from generation and base inventories --- companions/imagepolicy_test.go | 1 - companions/proto/companion_plugins_test.go | 8 ++++---- companions/proto/grpc_configuration_test.go | 2 +- docs/runbooks/bump-protobuf-es.md | 2 +- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/companions/imagepolicy_test.go b/companions/imagepolicy_test.go index 8b26d8517..d1b7b0cd4 100644 --- a/companions/imagepolicy_test.go +++ b/companions/imagepolicy_test.go @@ -34,7 +34,6 @@ var unpinnedUpstreamBases = []string{ "docker:29-dind", "golang:1.27-alpine", "node:26.3.0-alpine3.22", - "rust:1.99-alpine", } // corePolicy is what core requires of its own images. It is an ordinary diff --git a/companions/proto/companion_plugins_test.go b/companions/proto/companion_plugins_test.go index 67ed0dd4d..02170157d 100644 --- a/companions/proto/companion_plugins_test.go +++ b/companions/proto/companion_plugins_test.go @@ -313,10 +313,10 @@ func TestTsFacadeLockfileMatchesTheRuntime(t *testing.T) { // neighbour — the reader then checks the wrong line, finds something plausible, // and moves on. var runbookCitations = map[string]string{ - "Dockerfile:104": "@bufbuild/protoc-gen-es@", - "flake.nix:51": "protocGenEsVersion =", - "flake.nix:59": "hash =", - "flake.nix:61": "npmDepsHash =", + "Dockerfile:99": "@bufbuild/protoc-gen-es@", + "flake.nix:51": "protocGenEsVersion =", + "flake.nix:59": "hash =", + "flake.nix:61": "npmDepsHash =", } func TestRunbookCitationsPointAtWhatTheyName(t *testing.T) { diff --git a/companions/proto/grpc_configuration_test.go b/companions/proto/grpc_configuration_test.go index 12e82fb97..945051534 100644 --- a/companions/proto/grpc_configuration_test.go +++ b/companions/proto/grpc_configuration_test.go @@ -283,7 +283,7 @@ func TestEveryLanguageResolvesTheSameSharedProtos(t *testing.T) { t.Fatalf("Go resolves no %s", module) } } - for _, language := range []languages.Language{languages.PYTHON, languages.TYPESCRIPT, languages.RUST} { + for _, language := range []languages.Language{languages.PYTHON, languages.TYPESCRIPT} { if got := deps(language); got != want { t.Errorf("%s resolves\n%s\nwhere Go resolves\n%s", language, got, want) } diff --git a/docs/runbooks/bump-protobuf-es.md b/docs/runbooks/bump-protobuf-es.md index 3bdc03dfe..64ee3e3da 100644 --- a/docs/runbooks/bump-protobuf-es.md +++ b/docs/runbooks/bump-protobuf-es.md @@ -103,7 +103,7 @@ Six places, and they must agree: |---|---|---| | `companions/proto/facades/ts/package.json` | `@bufbuild/protoplugin` | yes | | `companions/proto/facades/ts/package-lock.json` | the resolved `protoplugin` + `protobuf` closure | yes | -| `companions/proto/Dockerfile:104` | `@bufbuild/protoc-gen-es@` installed via npm | yes | +| `companions/proto/Dockerfile:99` | `@bufbuild/protoc-gen-es@` installed via npm | yes | | `companions/proto/flake.nix:51` | `protocGenEsVersion`, built from source | yes | | `companions/proto/flake.nix:59` | `hash` — the protobuf-es source tarball | **no** | | `companions/proto/flake.nix:61` | `npmDepsHash` — its npm dependency closure | **no** | From 26fea99498ed6c06be76b00537b6c6d16cda7763 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:00:33 -0400 Subject: [PATCH 08/13] fix(proto): keep the Swagger companion alive during generation --- companions/proto/README.md | 2 ++ companions/proto/openapi.go | 1 + 2 files changed, 3 insertions(+) diff --git a/companions/proto/README.md b/companions/proto/README.md index 2f06a98da..c50ec029e 100644 --- a/companions/proto/README.md +++ b/companions/proto/README.md @@ -7,6 +7,8 @@ and `codefly generate client`), which owns the pinned companion invocation. Supported client languages are Go, Python, and TypeScript. The image and its build contain no Rust toolchain or Rust generation plugins; unsupported language requests fail before writing output. +The generation runner keeps the companion alive while executing its tools, +including Swagger client generation; it does not rely on the base image's default command. ## Build diff --git a/companions/proto/openapi.go b/companions/proto/openapi.go index a768ec2bb..d8b4ef9bb 100644 --- a/companions/proto/openapi.go +++ b/companions/proto/openapi.go @@ -101,6 +101,7 @@ func generateOpenAPIGo(ctx context.Context, unique string, image *resources.Dock runner.WithMount(root, "/workspace") runner.WithWorkDir("/workspace") runner.WithUser(hostUserSpec()) + runner.WithPause() defer func() { if shutErr := runner.Shutdown(ctx); shutErr != nil { w.Warn("cannot shutdown runner", wool.ErrField(shutErr)) From f1e8e83c8664e959f89aeb5e661393dab00f082c Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:09:38 -0400 Subject: [PATCH 09/13] fix(proto): cross-compile tools without target emulation --- companions/proto/Dockerfile | 54 ++++++++++------------ companions/proto/README.md | 20 ++++++-- companions/proto/companion_plugins_test.go | 12 ++--- companions/proto/info.codefly.yaml | 2 +- docs/runbooks/bump-protobuf-es.md | 2 +- version/info.codefly.yaml | 2 +- 6 files changed, 48 insertions(+), 44 deletions(-) diff --git a/companions/proto/Dockerfile b/companions/proto/Dockerfile index a66bccdce..f16a63207 100644 --- a/companions/proto/Dockerfile +++ b/companions/proto/Dockerfile @@ -1,16 +1,9 @@ # Proto compilation companion for `codefly generate proto`. # -# Size strategy: -# 1. Multi-stage: build all the Go plugins in a golang:1.27-alpine -# builder stage, copy only the static binaries into the runtime. -# 2. Strip Go binaries with -ldflags="-s -w" during install — drops -# DWARF + symbol tables, typically 20-30% smaller per binary. -# 3. Clean $GOMODCACHE + $GOCACHE after the last install — those -# caches are scaffolding the runtime doesn't need. -# 4. Runtime FROM the codefly companion base instead of golang:1.26- -# alpine — saves ~170 MB of Go stdlib we don't need since the -# binaries are statically compiled. Still need apk-installed -# nodejs+npm for TypeScript generation. +# Compile pinned Go tools on the build host and cross-compile static binaries +# for the target. Target-platform emulation is only needed by runtime package +# installation, never by the Go compiler. Upstream prebuilt plugins often carry +# older Go runtimes and dependencies, so downloading them is not equivalent. # The codefly base image is a build argument: this repository declares which # companion image it builds on, and the builder resolves that to a registry. @@ -19,16 +12,18 @@ ARG CODEFLY_BASE_IMAGE=ghcr.io/codefly-dev/codefly:0.0.6 FROM ${CODEFLY_BASE_IMAGE} AS codeflybase -# --- Builder stage: compile every Go plugin from source -------------- +# The runtime needs the TARGET architecture's Go distribution for goimports +# and Swagger. This stage only supplies files; it executes no target compiler. +FROM golang:1.27-alpine AS toolchain -FROM golang:1.27-alpine AS builder +# --- Native builder: cross-compile the pinned plugins ---------------- +FROM --platform=$BUILDPLATFORM golang:1.27-alpine AS builder +ARG TARGETARCH +ENV GOOS=linux GOARCH=${TARGETARCH} CGO_ENABLED=0 +RUN apk add --no-cache git -RUN apk add --no-cache git python3 py3-pip nodejs npm protobuf - -# Install the Go-based plugins, stripped, in one RUN so the cache -# cleanup at the end actually takes effect in the final image layer. -# Each binary is copied out individually in the runtime stage; the -# module cache is not. +# Install stripped plugins in one layer. Only their binaries are copied +# into the runtime; the source and build caches remain in this stage. RUN go install -ldflags="-s -w" github.com/bufbuild/buf/cmd/buf@v1.73.0 && \ go install -ldflags="-s -w" google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.11 && \ go install -ldflags="-s -w" google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.6.1 && \ @@ -39,17 +34,17 @@ RUN go install -ldflags="-s -w" github.com/bufbuild/buf/cmd/buf@v1.73.0 && \ go install -ldflags="-s -w" github.com/envoyproxy/protoc-gen-validate@v1.2.1 && \ go install -ldflags="-s -w" golang.org/x/tools/cmd/goimports@v0.50.0 +# go install puts cross-compiled commands in $GOPATH/bin/linux_. +# Normalize the output location for the runtime COPY instructions below. +RUN if [ "$TARGETARCH" != "$(go env GOHOSTARCH)" ]; then \ + mv /go/bin/linux_"$TARGETARCH"/* /go/bin/; \ + fi + # Go facade plugin (protoc-gen-codefly-facade-go), built from in-tree source. COPY companions/proto/facades/go /src/facade-go RUN cd /src/facade-go && \ go build -ldflags="-s -w" -o /go/bin/protoc-gen-codefly-facade-go . -# Python grpcio-tools for the builder's own grpc_* helper binaries, copied -# out of /usr/bin below. This venv is NOT copied into the runtime stage — the -# runtime builds its own /venv, which is where grpcio-tools has to be -# installed for `python -m grpc_tools.protoc` to exist in the image. -RUN python3 -m venv /venv && /venv/bin/pip install --no-cache-dir grpcio-tools - # --- Runtime stage: minimal alpine + the codefly base --------------- # Keep the interpreter compatible with the pinned generator's wheels and @@ -75,11 +70,11 @@ COPY --from=codeflybase /bin/codefly /bin/codefly # committed shape, so the companion's output drifts from every # consumer by exactly those lines. The apk go is whatever the base # alpine ships (1.23 on 3.21); the toolchain is COPYed from the -# builder below instead, so the runtime Go is the ecosystem's Go +# target toolchain below instead, so the runtime Go is the ecosystem's Go # by construction. The Go-built plugins (buf, protoc-gen-*) are # static and never need it. Adds ~270 MB; unavoidable. # - Go-built plugins (buf, protoc-gen-*) ARE static (CGO_ENABLED=0 -# by default for `go install`), so we COPY just those binaries +# explicitly in the builder), so we COPY just those binaries # without bringing the Go runtime. # HOME=/tmp so the companion works when run as an arbitrary non-root UID # (codefly runs it as the host user on Linux CI). buf and npm/npx write @@ -99,10 +94,10 @@ RUN apk add --no-cache bash ca-certificates curl git jq procps tzdata nodejs npm @bufbuild/protoc-gen-es@2.11.0 && \ npm cache clean --force -# The builder's toolchain, not apk's: goimports and swagger both shell out +# The target toolchain, not apk's: goimports and swagger both shell out # to `go`, and a Go older than what consumers' go.mod files require makes # goimports degrade silently (see the runtime-dependencies note above). -COPY --from=builder /usr/local/go /usr/local/go +COPY --from=toolchain /usr/local/go /usr/local/go ENV PATH=/usr/local/go/bin:$PATH COPY --from=builder /go/bin/buf /usr/local/bin/ COPY --from=builder /go/bin/protoc-gen-go /usr/local/bin/ @@ -118,7 +113,6 @@ COPY --from=builder /go/bin/swagger /usr/local/bin/ # companion formats what buf wrote before it hands the tree back. Pinned like # the plugins — a formatter that moves with @latest moves the committed shape. COPY --from=builder /go/bin/goimports /usr/local/bin/ -COPY --from=builder /usr/bin/grpc_* /usr/local/bin/ # Facade plugin sources (Python scripts + the TS npm package). COPY companions/proto/facades/python /opt/codefly-facades/python diff --git a/companions/proto/README.md b/companions/proto/README.md index c50ec029e..cf0242820 100644 --- a/companions/proto/README.md +++ b/companions/proto/README.md @@ -57,8 +57,18 @@ the existing `tag.sh` version-bump flow continues to work. codefly companion build proto ``` -The Dockerfile assembles the same set via apk + `go install`. Less -reproducible than Nix (apk packages vary across Alpine releases) but +The Dockerfile assembles the same set via apk + `go install`. The Go builder +runs on `BUILDPLATFORM` and cross-compiles static plugins for `TARGETARCH`; +it never compiles under ARM emulation. A separate target-platform Go image +supplies the runtime `go`/`gofmt`, so cross-building cannot accidentally ship +the build host's compiler. Upstream downloads were checked: Buf 1.73.0's +prebuilt binary carries Go 1.26.8 and x/net 0.58.0, including fixed findings, +and several other plugin downloads carry still older Go toolchains. Retaining +source builds preserves the current Go compiler instead of importing those +additional findings. Plugin dependency versions are unchanged by this build +speed fix; it does not claim a clean vulnerability scan of every plugin. + +This path is less reproducible than Nix (apk packages vary across Alpine releases) but needs no Linux builder VM, which is why the publish workflow uses it. ## What's in the image @@ -98,9 +108,9 @@ may also contain handwritten Go, so only files carrying Go's standard ## Versioning `info.codefly.yaml` carries the image version. Version 0.0.21 removes Rust -generation and its build dependencies. Publish through `codefly companion -publish` using the [publication procedure](../../docs/runbooks/publish-companions.md); -changing the manifest alone does not publish an image. Core 0.19.13 selects +generation and its build dependencies; 0.0.22 removes emulated Go compilation. +Publish through `codefly companion publish` using the [publication procedure](../../docs/runbooks/publish-companions.md); +changing the manifest alone does not publish an image. Core 0.19.14 selects this companion. ## Python generation diff --git a/companions/proto/companion_plugins_test.go b/companions/proto/companion_plugins_test.go index 02170157d..2576d5e34 100644 --- a/companions/proto/companion_plugins_test.go +++ b/companions/proto/companion_plugins_test.go @@ -79,9 +79,9 @@ func TestDockerfileShipsGoimportsInRuntime(t *testing.T) { // go.mod requirement makes it refuse — after which goimports quietly formats // syntax only and emits no alias where a package's name is not its path's // last element. That is the committed shape, so every consumer drifts by -// exactly those lines, and nothing errors. The builder stage's Go is the -// ecosystem's; the runtime must carry that one, not whatever apk ships. -func TestDockerfileRuntimeGoIsTheBuilderToolchain(t *testing.T) { +// exactly those lines, and nothing errors. The target toolchain stage supplies +// the ecosystem's Go for the runtime architecture, not the native builder's. +func TestDockerfileRuntimeGoIsTheTargetToolchain(t *testing.T) { dockerfile, err := os.ReadFile("Dockerfile") if err != nil { t.Fatalf("read Dockerfile: %v", err) @@ -94,8 +94,8 @@ func TestDockerfileRuntimeGoIsTheBuilderToolchain(t *testing.T) { if regexp.MustCompile(`(?m)^RUN apk add[^\n]*\bgo\b`).MatchString(runtimeStage) { t.Error("Dockerfile installs go from apk in the runtime stage; that is the base alpine's Go, older than consumers' go.mod require, and goimports then degrades silently (see the runtime-dependencies note)") } - if !regexp.MustCompile(`(?m)^COPY --from=builder /usr/local/go /usr/local/go`).MatchString(runtimeStage) { - t.Error("Dockerfile does not COPY the builder's Go toolchain into the runtime stage") + if !regexp.MustCompile(`(?m)^COPY --from=toolchain /usr/local/go /usr/local/go`).MatchString(runtimeStage) { + t.Error("Dockerfile does not COPY the target platform's Go toolchain into the runtime stage") } if !regexp.MustCompile(`(?m)^FROM golang:1\.27`).MatchString(string(dockerfile)) { t.Error("the builder stage is not golang:1.27; the runtime Go it hands the formatter would not satisfy modules that require go 1.27") @@ -313,7 +313,7 @@ func TestTsFacadeLockfileMatchesTheRuntime(t *testing.T) { // neighbour — the reader then checks the wrong line, finds something plausible, // and moves on. var runbookCitations = map[string]string{ - "Dockerfile:99": "@bufbuild/protoc-gen-es@", + "Dockerfile:94": "@bufbuild/protoc-gen-es@", "flake.nix:51": "protocGenEsVersion =", "flake.nix:59": "hash =", "flake.nix:61": "npmDepsHash =", diff --git a/companions/proto/info.codefly.yaml b/companions/proto/info.codefly.yaml index fb15e7160..0a9d1e316 100644 --- a/companions/proto/info.codefly.yaml +++ b/companions/proto/info.codefly.yaml @@ -1 +1 @@ -version: 0.0.21 +version: 0.0.22 diff --git a/docs/runbooks/bump-protobuf-es.md b/docs/runbooks/bump-protobuf-es.md index 64ee3e3da..7273b0917 100644 --- a/docs/runbooks/bump-protobuf-es.md +++ b/docs/runbooks/bump-protobuf-es.md @@ -103,7 +103,7 @@ Six places, and they must agree: |---|---|---| | `companions/proto/facades/ts/package.json` | `@bufbuild/protoplugin` | yes | | `companions/proto/facades/ts/package-lock.json` | the resolved `protoplugin` + `protobuf` closure | yes | -| `companions/proto/Dockerfile:99` | `@bufbuild/protoc-gen-es@` installed via npm | yes | +| `companions/proto/Dockerfile:94` | `@bufbuild/protoc-gen-es@` installed via npm | yes | | `companions/proto/flake.nix:51` | `protocGenEsVersion`, built from source | yes | | `companions/proto/flake.nix:59` | `hash` — the protobuf-es source tarball | **no** | | `companions/proto/flake.nix:61` | `npmDepsHash` — its npm dependency closure | **no** | diff --git a/version/info.codefly.yaml b/version/info.codefly.yaml index ea8113a3e..309a17ffe 100644 --- a/version/info.codefly.yaml +++ b/version/info.codefly.yaml @@ -1 +1 @@ -version: 0.19.13 +version: 0.19.14 From 4df9af649a171b42d8817eef1219bddef09db193 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:11:59 -0400 Subject: [PATCH 10/13] test(proto): execute target tools in each architecture build --- .github/workflows/companions-build.yml | 4 ++-- companions/proto/Dockerfile | 9 +++++++++ companions/proto/README.md | 4 +++- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/workflows/companions-build.yml b/.github/workflows/companions-build.yml index 25675a46c..c62f60fac 100644 --- a/.github/workflows/companions-build.yml +++ b/.github/workflows/companions-build.yml @@ -48,8 +48,8 @@ jobs: build: needs: plan runs-on: ubuntu-latest - # The proto companion compiles ~10 Go plugins from source and its arm64 - # half runs under QEMU emulation. + # Go tools cross-compile natively. Target runtime package installation + # and executable smoke checks still use QEMU for the arm64 half. timeout-minutes: 180 strategy: # One companion failing to build must not hide whether the others do. diff --git a/companions/proto/Dockerfile b/companions/proto/Dockerfile index f16a63207..baf8f559c 100644 --- a/companions/proto/Dockerfile +++ b/companions/proto/Dockerfile @@ -144,4 +144,13 @@ RUN printf '#!/bin/sh\nexec /venv/bin/python /opt/codefly-facades/python/protoc_ chmod +x /usr/local/bin/protoc-gen-codefly-python /usr/local/bin/protoc-gen-codefly-facade-python /usr/local/bin/codefly-proto-strip-options && \ ln -sf protoc-gen-codefly-facade-python /usr/local/bin/protoc-gen-solution_facade +# Exercise target executables in both architecture builds. A successful native +# compile alone would not catch copying the host Go distribution into runtime. +ARG TARGETARCH +RUN test "$(go env GOARCH)" = "$TARGETARCH" && \ + buf --version && \ + protoc-gen-go --version && \ + protoc-gen-go-grpc --version && \ + swagger version + WORKDIR /workspace diff --git a/companions/proto/README.md b/companions/proto/README.md index cf0242820..09c831544 100644 --- a/companions/proto/README.md +++ b/companions/proto/README.md @@ -61,7 +61,9 @@ The Dockerfile assembles the same set via apk + `go install`. The Go builder runs on `BUILDPLATFORM` and cross-compiles static plugins for `TARGETARCH`; it never compiles under ARM emulation. A separate target-platform Go image supplies the runtime `go`/`gofmt`, so cross-building cannot accidentally ship -the build host's compiler. Upstream downloads were checked: Buf 1.73.0's +the build host's compiler. Each image build executes Buf, the Go generators, +Swagger and the runtime Go architecture check on its target platform. +Upstream downloads were checked: Buf 1.73.0's prebuilt binary carries Go 1.26.8 and x/net 0.58.0, including fixed findings, and several other plugin downloads carry still older Go toolchains. Retaining source builds preserves the current Go compiler instead of importing those From aa60b354a72c9aca182b71a21160c57144220155 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:07:25 -0400 Subject: [PATCH 11/13] fix(proto): keep runtime caches writable for host users --- companions/proto/Dockerfile | 18 +++++++++++------- companions/proto/README.md | 7 ++++++- companions/proto/info.codefly.yaml | 2 +- internal/ciguard/proto_companion_test.go | 13 +++++++++++++ version/info.codefly.yaml | 2 +- 5 files changed, 32 insertions(+), 10 deletions(-) diff --git a/companions/proto/Dockerfile b/companions/proto/Dockerfile index baf8f559c..bbd6ebf29 100644 --- a/companions/proto/Dockerfile +++ b/companions/proto/Dockerfile @@ -76,11 +76,6 @@ COPY --from=codeflybase /bin/codefly /bin/codefly # - Go-built plugins (buf, protoc-gen-*) ARE static (CGO_ENABLED=0 # explicitly in the builder), so we COPY just those binaries # without bringing the Go runtime. -# HOME=/tmp so the companion works when run as an arbitrary non-root UID -# (codefly runs it as the host user on Linux CI). buf and npm/npx write -# their caches under $HOME; the default /root isn't writable by that user. -ENV HOME=/tmp - # python3 + a dedicated /venv host the Python facade + strip plugins. The venv # is built here (not copied from the builder) so it is bound to this stage's # Python 3.13 — the builder may ship a different Python minor version, and a @@ -146,11 +141,20 @@ RUN printf '#!/bin/sh\nexec /venv/bin/python /opt/codefly-facades/python/protoc_ # Exercise target executables in both architecture builds. A successful native # compile alone would not catch copying the host Go distribution into runtime. +# Keep installation caches under /root, then use the runtime home as a non-root +# UID. The temporary mount prevents smoke checks from baking owned caches into +# /tmp, where a different host UID must create its own Buf/Go/npm caches. +ENV HOME=/tmp +USER 65532:65532 ARG TARGETARCH -RUN test "$(go env GOARCH)" = "$TARGETARCH" && \ +RUN --mount=type=tmpfs,target=/tmp \ + test "$(go env GOARCH)" = "$TARGETARCH" && \ buf --version && \ + buf ls-files /tmp && \ protoc-gen-go --version && \ protoc-gen-go-grpc --version && \ - swagger version + swagger version && \ + npm cache verify +USER 0:0 WORKDIR /workspace diff --git a/companions/proto/README.md b/companions/proto/README.md index 09c831544..af198f6b8 100644 --- a/companions/proto/README.md +++ b/companions/proto/README.md @@ -63,6 +63,10 @@ it never compiles under ARM emulation. A separate target-platform Go image supplies the runtime `go`/`gofmt`, so cross-building cannot accidentally ship the build host's compiler. Each image build executes Buf, the Go generators, Swagger and the runtime Go architecture check on its target platform. +These checks run as a non-root user with temporary caches that never enter the +image. Installation caches stay under `/root`; runtime tools use `/tmp` so +Linux callers with arbitrary host UIDs can create their own Buf, Go, and npm +caches. The tagged companion suite verifies this with a different non-root UID. Upstream downloads were checked: Buf 1.73.0's prebuilt binary carries Go 1.26.8 and x/net 0.58.0, including fixed findings, and several other plugin downloads carry still older Go toolchains. Retaining @@ -111,8 +115,9 @@ may also contain handwritten Go, so only files carrying Go's standard `info.codefly.yaml` carries the image version. Version 0.0.21 removes Rust generation and its build dependencies; 0.0.22 removes emulated Go compilation. +Version 0.0.23 fixes non-root runtime caches left owned by root in 0.0.22. Publish through `codefly companion publish` using the [publication procedure](../../docs/runbooks/publish-companions.md); -changing the manifest alone does not publish an image. Core 0.19.14 selects +changing the manifest alone does not publish an image. Core 0.19.15 selects this companion. ## Python generation diff --git a/companions/proto/info.codefly.yaml b/companions/proto/info.codefly.yaml index 0a9d1e316..074a5d62e 100644 --- a/companions/proto/info.codefly.yaml +++ b/companions/proto/info.codefly.yaml @@ -1 +1 @@ -version: 0.0.22 +version: 0.0.23 diff --git a/internal/ciguard/proto_companion_test.go b/internal/ciguard/proto_companion_test.go index cff222d58..71e0c7ca5 100644 --- a/internal/ciguard/proto_companion_test.go +++ b/internal/ciguard/proto_companion_test.go @@ -47,6 +47,19 @@ func TestSelectedProtoCompanionShipsGoimports(t *testing.T) { require.Contains(t, string(output), "goimports") } +// Linux runs companions as the host UID. Build-time checks must not leave +// root-owned caches that pass on macOS (root in Docker) and fail on Linux. +func TestSelectedProtoCompanionCachesWorkAsNonRoot(t *testing.T) { + ctx := t.Context() + testutil.RequireProtoImage(t, ctx) + img, err := proto.CompanionImage(ctx) + require.NoError(t, err) + output, err := exec.CommandContext(ctx, "docker", "run", "--rm", "--network", "none", + "--user", "1001:1001", "--entrypoint", "sh", img.FullName(), "-ec", + "buf ls-files /workspace && swagger version && npm cache verify").CombinedOutput() + require.NoError(t, err, "the selected companion must create its caches as an arbitrary host UID: %s", output) +} + // goimports names a package through `go list` in the consumer's module and // refuses a module whose go.mod requires a newer Go than the image carries, // then formats syntax only. Ask the selected image which Go it runs and hold diff --git a/version/info.codefly.yaml b/version/info.codefly.yaml index 309a17ffe..aee574164 100644 --- a/version/info.codefly.yaml +++ b/version/info.codefly.yaml @@ -1 +1 @@ -version: 0.19.14 +version: 0.19.15 From fdb6025683006019cb8eb95e1cbe692c8b8a811f Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:08:43 -0400 Subject: [PATCH 12/13] docs(proto): keep generator source citation current --- companions/proto/companion_plugins_test.go | 2 +- docs/runbooks/bump-protobuf-es.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/companions/proto/companion_plugins_test.go b/companions/proto/companion_plugins_test.go index 2576d5e34..cbc006c77 100644 --- a/companions/proto/companion_plugins_test.go +++ b/companions/proto/companion_plugins_test.go @@ -313,7 +313,7 @@ func TestTsFacadeLockfileMatchesTheRuntime(t *testing.T) { // neighbour — the reader then checks the wrong line, finds something plausible, // and moves on. var runbookCitations = map[string]string{ - "Dockerfile:94": "@bufbuild/protoc-gen-es@", + "Dockerfile:89": "@bufbuild/protoc-gen-es@", "flake.nix:51": "protocGenEsVersion =", "flake.nix:59": "hash =", "flake.nix:61": "npmDepsHash =", diff --git a/docs/runbooks/bump-protobuf-es.md b/docs/runbooks/bump-protobuf-es.md index 7273b0917..f46111b30 100644 --- a/docs/runbooks/bump-protobuf-es.md +++ b/docs/runbooks/bump-protobuf-es.md @@ -103,7 +103,7 @@ Six places, and they must agree: |---|---|---| | `companions/proto/facades/ts/package.json` | `@bufbuild/protoplugin` | yes | | `companions/proto/facades/ts/package-lock.json` | the resolved `protoplugin` + `protobuf` closure | yes | -| `companions/proto/Dockerfile:94` | `@bufbuild/protoc-gen-es@` installed via npm | yes | +| `companions/proto/Dockerfile:89` | `@bufbuild/protoc-gen-es@` installed via npm | yes | | `companions/proto/flake.nix:51` | `protocGenEsVersion`, built from source | yes | | `companions/proto/flake.nix:59` | `hash` — the protobuf-es source tarball | **no** | | `companions/proto/flake.nix:61` | `npmDepsHash` — its npm dependency closure | **no** | From 8a28370c1890f20de489e6fbbe5767a5f9400912 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:09:44 -0400 Subject: [PATCH 13/13] test(proto): compile a real schema in non-root cache checks --- companions/proto/Dockerfile | 4 +++- internal/ciguard/proto_companion_test.go | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/companions/proto/Dockerfile b/companions/proto/Dockerfile index bbd6ebf29..1d757a567 100644 --- a/companions/proto/Dockerfile +++ b/companions/proto/Dockerfile @@ -150,7 +150,9 @@ ARG TARGETARCH RUN --mount=type=tmpfs,target=/tmp \ test "$(go env GOARCH)" = "$TARGETARCH" && \ buf --version && \ - buf ls-files /tmp && \ + mkdir /tmp/proto-check && \ + printf 'syntax = "proto3";\npackage cachecheck;\nmessage Check {}\n' > /tmp/proto-check/check.proto && \ + buf build /tmp/proto-check --output /tmp/proto-check.bin && \ protoc-gen-go --version && \ protoc-gen-go-grpc --version && \ swagger version && \ diff --git a/internal/ciguard/proto_companion_test.go b/internal/ciguard/proto_companion_test.go index 71e0c7ca5..f52984ae9 100644 --- a/internal/ciguard/proto_companion_test.go +++ b/internal/ciguard/proto_companion_test.go @@ -54,9 +54,10 @@ func TestSelectedProtoCompanionCachesWorkAsNonRoot(t *testing.T) { testutil.RequireProtoImage(t, ctx) img, err := proto.CompanionImage(ctx) require.NoError(t, err) + fixture := filepath.Join(repoRoot(t), "companions", "proto", "testdata", "facade", "streaming") output, err := exec.CommandContext(ctx, "docker", "run", "--rm", "--network", "none", - "--user", "1001:1001", "--entrypoint", "sh", img.FullName(), "-ec", - "buf ls-files /workspace && swagger version && npm cache verify").CombinedOutput() + "--user", "1001:1001", "-v", fixture+":/proto-check:ro", "--entrypoint", "sh", img.FullName(), "-ec", + "buf build /proto-check --output /tmp/proto-check.bin && swagger version && npm cache verify").CombinedOutput() require.NoError(t, err, "the selected companion must create its caches as an arbitrary host UID: %s", output) }