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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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