From d693563b23333bb316f30160be0b8ea2dd117af8 Mon Sep 17 00:00:00 2001 From: Saurabh Maheshwari <242053479+saurabhmaheshwari-obin@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:43:32 -0400 Subject: [PATCH 1/5] feat(builder): a recipe declares the locked git sources it fetches (contract v6) A build whose lockfile pins a dependency to a commit in a private git repository fetches it inside BuildKit, where the credentials of the machine running the build do not reach. DockerBuildRecipe.git_source_downloads lets a recipe declare each locked repository URL and commit, and the named build context it reads them from, so the caller can fetch every commit once, before any image build, into a bare mirror the Dockerfile maps the URL onto. The build then needs no credential, and the lockfile is unchanged. GitSourceMirrorPath names each mirror inside the context (the hex SHA-256 of the URL plus ".git"), so the caller that fills the context and the template that reads it share one layout. Validation accepts https, ssh and git+ssh URLs with no password, token, query or fragment (an ssh login name is allowed), a full 40- or 64-character lowercase commit, and a build-context name that is not also a Go module proxy context. One repository may be declared at several commits. A recipe that declares git sources emits the v6 contract; v6 digests the declaration on top of v5's Go module downloads and v4's explicit context root. A host that predates the field expects v3 to v5 for the same recipes and refuses the plan rather than building without supplying the mirrors. Recipes without git sources keep v3 to v5. SingleImageBuildResponse takes the same variadic recipe options as SingleImageBuildPlan; existing callers emit exactly what they did. Regenerated with the pinned generators (Go and Python). Co-Authored-By: Claude Opus 5.5 --- agents/services/base_builder.go | 7 +- agents/services/docker_recipe.go | 115 ++++++++++- .../docker_recipe_git_sources_test.go | 182 ++++++++++++++++++ .../codefly/services/builder/v0/docker.pb.go | 156 ++++++++++++--- .../codefly/services/builder/v0/docker_pb2.py | 30 +-- .../codefly/services/builder/v0/docker.proto | 30 +++ 6 files changed, 473 insertions(+), 47 deletions(-) create mode 100644 agents/services/docker_recipe_git_sources_test.go diff --git a/agents/services/base_builder.go b/agents/services/base_builder.go index 5cdb0062a..9bfce13b2 100644 --- a/agents/services/base_builder.go +++ b/agents/services/base_builder.go @@ -263,9 +263,10 @@ func (s *BuilderWrapper) WithBuildPlan(plan *builderv0.DockerBuildPlan) { // output_directory and returns the build response. The CLI executes the plan. // emitted is the path set the caller just rendered, from PrepareRecipeDestination: // it scopes the plan's inventory to the files this build wrote and decides whether -// the recipe references a dockerignore at all. -func (s *BuilderWrapper) SingleImageBuildResponse(req *builderv0.BuildRequest, image string, emitted []string) (*builderv0.BuildResponse, error) { - plan, err := SingleImageBuildPlan(req.GetOutputDirectory(), image, RecipeBuildPlatforms(), emitted) +// the recipe references a dockerignore at all. options add declarations to the +// recipe, as for SingleImageBuildPlan. +func (s *BuilderWrapper) SingleImageBuildResponse(req *builderv0.BuildRequest, image string, emitted []string, options ...RecipeOption) (*builderv0.BuildResponse, error) { + plan, err := SingleImageBuildPlan(req.GetOutputDirectory(), image, RecipeBuildPlatforms(), emitted, options...) if err != nil { return s.BuildError(err) } diff --git a/agents/services/docker_recipe.go b/agents/services/docker_recipe.go index feb4e1cb2..fd8572b6b 100644 --- a/agents/services/docker_recipe.go +++ b/agents/services/docker_recipe.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "io/fs" + "net/url" "os" "path" "path/filepath" @@ -44,8 +45,20 @@ const DockerBuildRecipeContextContractVersion = "codefly.dev/docker-build-recipe // emitting v3 or v4, so existing agents need no coordinated rebuild. const DockerBuildRecipeGoModulesContractVersion = "codefly.dev/docker-build-recipe/v5" +// DockerBuildRecipeGitSourcesContractVersion adds declared git source downloads +// (DockerBuildRecipe.git_source_downloads) to the recipe digest, on top of v5's +// Go module downloads and v4's explicit context root. A host that does not know +// the field would build without supplying the mirrors, so the plan carries v6 +// and such a host rejects it instead. Recipes without git sources keep v3 to v5. +const DockerBuildRecipeGitSourcesContractVersion = "codefly.dev/docker-build-recipe/v6" + func recipeContractVersion(recipes []*builderv0.DockerBuildRecipe) string { version := DockerBuildRecipeContractVersion + for _, recipe := range recipes { + if len(recipe.GetGitSourceDownloads()) > 0 { + return DockerBuildRecipeGitSourcesContractVersion + } + } for _, recipe := range recipes { if len(recipe.GetGoModuleDownloads()) > 0 { return DockerBuildRecipeGoModulesContractVersion @@ -241,6 +254,26 @@ func WithGoModuleDownloads(downloads ...*builderv0.GoModuleDownload) RecipeOptio } } +// WithGitSourceDownloads declares the locked git commits the recipe fetches +// while it builds (DockerBuildRecipe.git_source_downloads). The caller fetches +// each into a bare mirror at GitSourceMirrorPath(repository) inside the named +// mirror context before any image build, so the build needs no credential. A +// recipe carrying one emits the v6 contract. +func WithGitSourceDownloads(downloads ...*builderv0.GitSourceDownload) RecipeOption { + return func(recipe *builderv0.DockerBuildRecipe) { + recipe.GitSourceDownloads = append(recipe.GitSourceDownloads, downloads...) + } +} + +// GitSourceMirrorPath is where, inside a git source mirror context, the bare +// mirror of repository lives: the hex sha256 of the URL exactly as declared, +// plus ".git". It is the layout the caller that fills the context and the +// Dockerfile that reads it share, so neither has to escape a URL into a path. +func GitSourceMirrorPath(repository string) string { + sum := sha256.Sum256([]byte(repository)) + return hex.EncodeToString(sum[:]) + ".git" +} + func SingleImageBuildPlan(outputDirectory, image string, platforms []string, emitted []string, options ...RecipeOption) (*builderv0.DockerBuildPlan, error) { dockerignore := "" for _, name := range emitted { @@ -416,6 +449,9 @@ func validateRecipes(destination string, recipes []*builderv0.DockerBuildRecipe, if err := validateGoModuleDownloads(recipe); err != nil { return err } + if err := validateGitSourceDownloads(recipe); err != nil { + return err + } if ignore := recipe.GetDockerignore(); ignore != "" { dockerignore, err := recipeRelPath(destination, ignore) if err != nil { @@ -462,6 +498,71 @@ func validateGoModuleDownloads(recipe *builderv0.DockerBuildRecipe) error { return nil } +// gitCommitID is a full git object id: SHA-1 (40) or SHA-256 (64), lowercase. +var gitCommitID = regexp.MustCompile(`^([0-9a-f]{40}|[0-9a-f]{64})$`) + +// validateGitSourceDownloads checks a recipe's declared git sources: each names +// a credential-free https, ssh or git+ssh URL with no query or fragment, a full +// commit id, and a valid build-context name that is not also a Go module proxy +// context. The same repository may be declared at several commits; the same +// (repository, commit, context) twice is refused. +func validateGitSourceDownloads(recipe *builderv0.DockerBuildRecipe) error { + proxies := make(map[string]struct{}, len(recipe.GetGoModuleDownloads())) + for _, download := range recipe.GetGoModuleDownloads() { + proxies[download.GetProxyContext()] = struct{}{} + } + seen := make(map[[3]string]struct{}, len(recipe.GetGitSourceDownloads())) + for _, download := range recipe.GetGitSourceDownloads() { + repository := download.GetRepository() + if err := validateGitSourceRepository(repository); err != nil { + return fmt.Errorf("recipe %q git source %q: %w", recipe.GetName(), repository, err) + } + if !gitCommitID.MatchString(download.GetCommit()) { + return fmt.Errorf("recipe %q git source %q commit %q is not a full lowercase hex object id", recipe.GetName(), repository, download.GetCommit()) + } + name := download.GetMirrorContext() + if !proxyContextName.MatchString(name) { + return fmt.Errorf("recipe %q git source mirror context %q is not a valid build context name", recipe.GetName(), name) + } + if _, ok := proxies[name]; ok { + return fmt.Errorf("recipe %q uses build context %q for both Go modules and git sources", recipe.GetName(), name) + } + key := [3]string{repository, download.GetCommit(), name} + if _, ok := seen[key]; ok { + return fmt.Errorf("recipe %q declares git source %q at %s in %q twice", recipe.GetName(), repository, download.GetCommit(), name) + } + seen[key] = struct{}{} + } + return nil +} + +func validateGitSourceRepository(repository string) error { + parsed, err := url.Parse(repository) + if err != nil { + return fmt.Errorf("not a URL: %w", err) + } + switch parsed.Scheme { + case "https": + if parsed.User != nil { + return fmt.Errorf("an https URL must carry no user information") + } + case "ssh", "git+ssh": + // An ssh login name such as git@ is ordinary; a password never is. + if _, hasPassword := parsed.User.Password(); hasPassword { + return fmt.Errorf("an ssh URL must carry no password") + } + default: + return fmt.Errorf("scheme must be https, ssh or git+ssh") + } + if parsed.Host == "" || parsed.Path == "" || parsed.Path == "/" { + return fmt.Errorf("URL must name a host and a repository path") + } + if parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" || strings.ContainsAny(repository, "?#") { + return fmt.Errorf("URL must carry no query or fragment") + } + return nil +} + // recipeRelPath validates that p is a non-empty relative path that stays inside // destination, and returns it in the slash-separated form used by the inventory. // Absolute paths and paths that escape destination (via "..") are rejected so a @@ -558,8 +659,9 @@ func fileDigest(path string) (string, error) { func aggregateRecipeDigest(recipes []*builderv0.DockerBuildRecipe, files []*builderv0.RecipeFile, scope builderv0.RecipeInventoryScope) string { hasher := sha256.New() version := recipeContractVersion(recipes) - explicitRoots := version == DockerBuildRecipeContextContractVersion || version == DockerBuildRecipeGoModulesContractVersion - goModules := version == DockerBuildRecipeGoModulesContractVersion + gitSources := version == DockerBuildRecipeGitSourcesContractVersion + goModules := gitSources || version == DockerBuildRecipeGoModulesContractVersion + explicitRoots := goModules || version == DockerBuildRecipeContextContractVersion // The scope decides how strictly the inventory is verified, so it is covered // here: rewriting a TREE plan's scope to EMITTED would otherwise silently stop // added files from being detected without disturbing the digest. @@ -582,6 +684,15 @@ func aggregateRecipeDigest(recipes []*builderv0.DockerBuildRecipe, files []*buil hashField(hasher, download.GetProxyContext()) } } + if gitSources { + sources := recipe.GetGitSourceDownloads() + hashCount(hasher, len(sources)) + for _, source := range sources { + hashField(hasher, source.GetRepository()) + hashField(hasher, source.GetCommit()) + hashField(hasher, source.GetMirrorContext()) + } + } hashField(hasher, recipe.GetDockerignore()) hashField(hasher, recipe.GetImage()) hashField(hasher, recipe.GetTarget()) diff --git a/agents/services/docker_recipe_git_sources_test.go b/agents/services/docker_recipe_git_sources_test.go new file mode 100644 index 000000000..679d10d9a --- /dev/null +++ b/agents/services/docker_recipe_git_sources_test.go @@ -0,0 +1,182 @@ +package services + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + builderv0 "github.com/codefly-dev/core/generated/go/codefly/services/builder/v0" + "github.com/stretchr/testify/require" +) + +const lockedCommit = "0123456789abcdef0123456789abcdef01234567" + +func gitSource() *builderv0.GitSourceDownload { + return &builderv0.GitSourceDownload{Repository: "https://git.example.com/team/lib", Commit: lockedCommit, MirrorContext: "gitsources"} +} + +func gitSourceRecipe() *builderv0.DockerBuildRecipe { + return &builderv0.DockerBuildRecipe{ + Name: "app", Dockerfile: "Dockerfile", Context: ".", Image: "example/app:v1", + GitSourceDownloads: []*builderv0.GitSourceDownload{gitSource()}, + } +} + +func recipeDir(t *testing.T) string { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "Dockerfile"), []byte("FROM scratch\n"), 0o644)) + return dir +} + +func TestRecipeGitSourceDownloadsVersionAndIntegrity(t *testing.T) { + dir := recipeDir(t) + recipe := gitSourceRecipe() + plan, err := BuildDockerBuildPlan(dir, []*builderv0.DockerBuildRecipe{recipe}) + require.NoError(t, err) + require.Equal(t, DockerBuildRecipeGitSourcesContractVersion, plan.GetContractVersion()) + require.NoError(t, VerifyDockerBuildPlan(dir, plan)) + + // A host that predates the field expects v3 for these recipes and refuses. + plan.ContractVersion = DockerBuildRecipeContractVersion + require.ErrorContains(t, VerifyDockerBuildPlan(dir, plan), "contract") + plan.ContractVersion = DockerBuildRecipeGitSourcesContractVersion + + // Every field of the declaration is covered by the digest. + source := recipe.GitSourceDownloads[0] + for _, mutate := range []func(*builderv0.GitSourceDownload){ + func(s *builderv0.GitSourceDownload) { s.Repository = "https://git.example.com/team/other" }, + func(s *builderv0.GitSourceDownload) { s.Commit = strings.Repeat("a", 40) }, + func(s *builderv0.GitSourceDownload) { s.MirrorContext = "elsewhere" }, + } { + mutate(source) + require.ErrorContains(t, VerifyDockerBuildPlan(dir, plan), "digest") + recipe.GitSourceDownloads[0] = gitSource() + source = recipe.GitSourceDownloads[0] + } + require.NoError(t, VerifyDockerBuildPlan(dir, plan)) + + // Adding a second source, or dropping the declaration and the version, is detected. + recipe.GitSourceDownloads = append(recipe.GitSourceDownloads, &builderv0.GitSourceDownload{ + Repository: "ssh://git@git.example.com/team/tool.git", Commit: lockedCommit, MirrorContext: "gitsources"}) + require.ErrorContains(t, VerifyDockerBuildPlan(dir, plan), "digest") + recipe.GitSourceDownloads = nil + plan.ContractVersion = DockerBuildRecipeContractVersion + require.ErrorContains(t, VerifyDockerBuildPlan(dir, plan), "digest") +} + +func TestRecipeGitSourceDownloadsWithGoModules(t *testing.T) { + dir := recipeDir(t) + recipe := gitSourceRecipe() + recipe.GoModuleDownloads = []*builderv0.GoModuleDownload{{ModuleRoot: "code", ProxyContext: "gomodproxy"}} + recipe.ContextRoot = builderv0.RecipeContextRoot_RECIPE_CONTEXT_ROOT_OUTPUT + plan, err := BuildDockerBuildPlan(dir, []*builderv0.DockerBuildRecipe{recipe}) + require.NoError(t, err) + require.Equal(t, DockerBuildRecipeGitSourcesContractVersion, plan.GetContractVersion()) + + // v6 still digests what v4 and v5 introduced. + recipe.GoModuleDownloads[0].ModuleRoot = "other" + require.ErrorContains(t, VerifyDockerBuildPlan(dir, plan), "digest") + recipe.GoModuleDownloads[0].ModuleRoot = "code" + recipe.ContextRoot = builderv0.RecipeContextRoot_RECIPE_CONTEXT_ROOT_SERVICE + require.ErrorContains(t, VerifyDockerBuildPlan(dir, plan), "digest") + + // One build context cannot be both a module proxy and a mirror set. + recipe.ContextRoot = builderv0.RecipeContextRoot_RECIPE_CONTEXT_ROOT_OUTPUT + recipe.GitSourceDownloads[0].MirrorContext = "gomodproxy" + _, err = BuildDockerBuildPlan(dir, []*builderv0.DockerBuildRecipe{recipe}) + require.ErrorContains(t, err, "both") +} + +func TestRecipeGitSourceDownloadsAreValidated(t *testing.T) { + dir := recipeDir(t) + for name, mutate := range map[string]func(*builderv0.GitSourceDownload){ + "empty repository": func(s *builderv0.GitSourceDownload) { s.Repository = "" }, + "http scheme": func(s *builderv0.GitSourceDownload) { s.Repository = "http://git.example.com/team/lib" }, + "file scheme": func(s *builderv0.GitSourceDownload) { s.Repository = "file:///srv/lib" }, + "ext scheme": func(s *builderv0.GitSourceDownload) { s.Repository = "ext::sh -c touch% /tmp/x" }, + "scp-like": func(s *builderv0.GitSourceDownload) { s.Repository = "git@git.example.com:team/lib.git" }, + "https token": func(s *builderv0.GitSourceDownload) { s.Repository = "https://token@git.example.com/team/lib" }, + "https password": func(s *builderv0.GitSourceDownload) { s.Repository = "https://user:secret@git.example.com/team/lib" }, + "ssh password": func(s *builderv0.GitSourceDownload) { s.Repository = "ssh://git:secret@git.example.com/team/lib" }, + "no host": func(s *builderv0.GitSourceDownload) { s.Repository = "https:///team/lib" }, + "no path": func(s *builderv0.GitSourceDownload) { s.Repository = "https://git.example.com/" }, + "query": func(s *builderv0.GitSourceDownload) { s.Repository = "https://git.example.com/team/lib?rev=main" }, + "empty query": func(s *builderv0.GitSourceDownload) { s.Repository = "https://git.example.com/team/lib?" }, + "fragment": func(s *builderv0.GitSourceDownload) { + s.Repository = "https://git.example.com/team/lib#" + lockedCommit + }, + "short commit": func(s *builderv0.GitSourceDownload) { s.Commit = lockedCommit[:12] }, + "uppercase commit": func(s *builderv0.GitSourceDownload) { s.Commit = strings.ToUpper(lockedCommit) }, + "41-char commit": func(s *builderv0.GitSourceDownload) { s.Commit = lockedCommit + "0" }, + "branch as commit": func(s *builderv0.GitSourceDownload) { s.Commit = "main" }, + "empty context": func(s *builderv0.GitSourceDownload) { s.MirrorContext = "" }, + "uppercase context": func(s *builderv0.GitSourceDownload) { s.MirrorContext = "GitSources" }, + "context with a path": func(s *builderv0.GitSourceDownload) { s.MirrorContext = "git/sources" }, + } { + t.Run(name, func(t *testing.T) { + recipe := gitSourceRecipe() + mutate(recipe.GitSourceDownloads[0]) + _, err := BuildDockerBuildPlan(dir, []*builderv0.DockerBuildRecipe{recipe}) + require.Error(t, err) + }) + } + + // Accepted forms: ssh with a login name, git+ssh, a SHA-256 commit, and the + // same repository locked at two commits. + recipe := gitSourceRecipe() + recipe.GitSourceDownloads = append(recipe.GitSourceDownloads, + &builderv0.GitSourceDownload{Repository: "ssh://git@git.example.com/team/lib.git", Commit: lockedCommit, MirrorContext: "gitsources"}, + &builderv0.GitSourceDownload{Repository: "git+ssh://git@git.example.com/team/lib.git", Commit: strings.Repeat("b", 64), MirrorContext: "gitsources"}, + &builderv0.GitSourceDownload{Repository: "https://git.example.com/team/lib", Commit: strings.Repeat("c", 40), MirrorContext: "gitsources"}) + _, err := BuildDockerBuildPlan(dir, []*builderv0.DockerBuildRecipe{recipe}) + require.NoError(t, err) + + recipe.GitSourceDownloads = append(recipe.GitSourceDownloads, gitSource()) + _, err = BuildDockerBuildPlan(dir, []*builderv0.DockerBuildRecipe{recipe}) + require.ErrorContains(t, err, "twice") +} + +func TestGitSourceMirrorPath(t *testing.T) { + path := GitSourceMirrorPath("https://git.example.com/team/lib") + require.Regexp(t, regexp.MustCompile(`^[0-9a-f]{64}\.git$`), path) + // The layout the proto documents, so a caller in any language agrees. + sum := sha256.Sum256([]byte("https://git.example.com/team/lib")) + require.Equal(t, hex.EncodeToString(sum[:])+".git", path) + require.Equal(t, path, GitSourceMirrorPath("https://git.example.com/team/lib")) + // The URL as declared is the key: near-identical URLs never share a mirror. + seen := map[string]string{} + for _, repository := range []string{ + "https://git.example.com/team/lib", + "https://git.example.com/team/lib.git", + "https://git.example.com/team/lib/", + "https://git.example.com/Team/lib", + "ssh://git@git.example.com/team/lib", + "git+ssh://git@git.example.com/team/lib", + } { + mirror := GitSourceMirrorPath(repository) + require.NotContains(t, seen, mirror, "%s collides with %s", repository, seen[mirror]) + seen[mirror] = repository + } +} + +func TestSingleImageBuildPlanDeclaresGitSourceDownloads(t *testing.T) { + dir := recipeDir(t) + plan, err := SingleImageBuildPlan(dir, "example/app:v1", RecipeBuildPlatforms(), []string{"Dockerfile"}, + WithGitSourceDownloads(gitSource())) + require.NoError(t, err) + require.Equal(t, DockerBuildRecipeGitSourcesContractVersion, plan.GetContractVersion()) + require.Equal(t, lockedCommit, plan.GetRecipes()[0].GetGitSourceDownloads()[0].GetCommit()) + require.NoError(t, VerifyDockerBuildPlan(dir, plan)) + + // The builder's single-image response threads the options through. + builder := &BuilderWrapper{Base: &Base{loaded: true}} + builder.Builder = builder + resp, err := builder.SingleImageBuildResponse(&builderv0.BuildRequest{OutputDirectory: dir}, "example/app:v1", []string{"Dockerfile"}, + WithGitSourceDownloads(gitSource())) + require.NoError(t, err) + require.Equal(t, DockerBuildRecipeGitSourcesContractVersion, resp.GetResult().GetDockerBuildPlan().GetContractVersion()) +} diff --git a/generated/go/codefly/services/builder/v0/docker.pb.go b/generated/go/codefly/services/builder/v0/docker.pb.go index 3730467a6..386367e69 100644 --- a/generated/go/codefly/services/builder/v0/docker.pb.go +++ b/generated/go/codefly/services/builder/v0/docker.pb.go @@ -367,8 +367,17 @@ type DockerBuildRecipe struct { // non-empty list requires the v5 recipe contract and participates in its // digest; older hosts reject v5 rather than build without supplying it. GoModuleDownloads []*GoModuleDownload `protobuf:"bytes,10,rep,name=go_module_downloads,json=goModuleDownloads,proto3" json:"go_module_downloads,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // git_source_downloads are the git commits this recipe's dependency + // resolution fetches while it builds, as its lockfile pins them. Declaring + // them moves the fetch out of the build: the caller fetches each commit once, + // before it builds any image, with the credentials of the machine running the + // build, and supplies it to the build as a bare repository mirror. A recipe + // that declares them therefore needs no credential inside the build. A + // non-empty list requires the v6 recipe contract and participates in its + // digest; older hosts reject v6 rather than build without supplying it. + GitSourceDownloads []*GitSourceDownload `protobuf:"bytes,11,rep,name=git_source_downloads,json=gitSourceDownloads,proto3" json:"git_source_downloads,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *DockerBuildRecipe) Reset() { @@ -471,6 +480,13 @@ func (x *DockerBuildRecipe) GetGoModuleDownloads() []*GoModuleDownload { return nil } +func (x *DockerBuildRecipe) GetGitSourceDownloads() []*GitSourceDownload { + if x != nil { + return x.GitSourceDownloads + } + return nil +} + // GoModuleDownload is one Go module graph a recipe downloads. type GoModuleDownload struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -531,6 +547,81 @@ func (x *GoModuleDownload) GetProxyContext() string { return "" } +// GitSourceDownload is one locked git commit a recipe fetches. +type GitSourceDownload struct { + state protoimpl.MessageState `protogen:"open.v1"` + // repository is the URL the build fetches from, exactly as the lockfile + // records it, without query or fragment: https, ssh or git+ssh, carrying no + // password or token. The Dockerfile maps this URL onto the supplied mirror + // (git's url..insteadOf), so it must match the lockfile byte for byte. + Repository string `protobuf:"bytes,1,opt,name=repository,proto3" json:"repository,omitempty"` + // commit is the full object id the lockfile pins: 40 (SHA-1) or 64 (SHA-256) + // lowercase hexadecimal characters. + Commit string `protobuf:"bytes,2,opt,name=commit,proto3" json:"commit,omitempty"` + // mirror_context names the build context the caller supplies the fetched + // commits as: a directory holding one bare repository per declared + // repository, named by the lowercase hex SHA-256 of the repository string + // followed by ".git" (services.GitSourceMirrorPath in Go), each holding every + // declared commit under refs/commit/. Several downloads may + // share one mirror context. The Dockerfile declares a stage of the same name + // (FROM scratch) so it still builds, from the network, when no caller + // supplies one. + MirrorContext string `protobuf:"bytes,3,opt,name=mirror_context,json=mirrorContext,proto3" json:"mirror_context,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GitSourceDownload) Reset() { + *x = GitSourceDownload{} + mi := &file_codefly_services_builder_v0_docker_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GitSourceDownload) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GitSourceDownload) ProtoMessage() {} + +func (x *GitSourceDownload) ProtoReflect() protoreflect.Message { + mi := &file_codefly_services_builder_v0_docker_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GitSourceDownload.ProtoReflect.Descriptor instead. +func (*GitSourceDownload) Descriptor() ([]byte, []int) { + return file_codefly_services_builder_v0_docker_proto_rawDescGZIP(), []int{5} +} + +func (x *GitSourceDownload) GetRepository() string { + if x != nil { + return x.Repository + } + return "" +} + +func (x *GitSourceDownload) GetCommit() string { + if x != nil { + return x.Commit + } + return "" +} + +func (x *GitSourceDownload) GetMirrorContext() string { + if x != nil { + return x.MirrorContext + } + return "" +} + // DockerBuildPlan is the reproducible recipe set a builder emits to the caller's // output_directory. The caller owns running docker buildx from the recipes, so // the recipes are a durable, first-class artifact rather than an image built @@ -559,7 +650,7 @@ type DockerBuildPlan struct { func (x *DockerBuildPlan) Reset() { *x = DockerBuildPlan{} - mi := &file_codefly_services_builder_v0_docker_proto_msgTypes[5] + mi := &file_codefly_services_builder_v0_docker_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -571,7 +662,7 @@ func (x *DockerBuildPlan) String() string { func (*DockerBuildPlan) ProtoMessage() {} func (x *DockerBuildPlan) ProtoReflect() protoreflect.Message { - mi := &file_codefly_services_builder_v0_docker_proto_msgTypes[5] + mi := &file_codefly_services_builder_v0_docker_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -584,7 +675,7 @@ func (x *DockerBuildPlan) ProtoReflect() protoreflect.Message { // Deprecated: Use DockerBuildPlan.ProtoReflect.Descriptor instead. func (*DockerBuildPlan) Descriptor() ([]byte, []int) { - return file_codefly_services_builder_v0_docker_proto_rawDescGZIP(), []int{5} + return file_codefly_services_builder_v0_docker_proto_rawDescGZIP(), []int{6} } func (x *DockerBuildPlan) GetRecipes() []*DockerBuildRecipe { @@ -643,7 +734,7 @@ type BuildCacheOptions struct { func (x *BuildCacheOptions) Reset() { *x = BuildCacheOptions{} - mi := &file_codefly_services_builder_v0_docker_proto_msgTypes[6] + mi := &file_codefly_services_builder_v0_docker_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -655,7 +746,7 @@ func (x *BuildCacheOptions) String() string { func (*BuildCacheOptions) ProtoMessage() {} func (x *BuildCacheOptions) ProtoReflect() protoreflect.Message { - mi := &file_codefly_services_builder_v0_docker_proto_msgTypes[6] + mi := &file_codefly_services_builder_v0_docker_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -668,7 +759,7 @@ func (x *BuildCacheOptions) ProtoReflect() protoreflect.Message { // Deprecated: Use BuildCacheOptions.ProtoReflect.Descriptor instead. func (*BuildCacheOptions) Descriptor() ([]byte, []int) { - return file_codefly_services_builder_v0_docker_proto_rawDescGZIP(), []int{6} + return file_codefly_services_builder_v0_docker_proto_rawDescGZIP(), []int{7} } func (x *BuildCacheOptions) GetImports() []string { @@ -722,7 +813,7 @@ const file_codefly_services_builder_v0_docker_proto_rawDesc = "" + "RecipeFile\x12\x12\n" + "\x04path\x18\x01 \x01(\tR\x04path\x12\x16\n" + "\x06digest\x18\x02 \x01(\tR\x06digest\x12\x12\n" + - "\x04mode\x18\x03 \x01(\rR\x04mode\"\x9f\x04\n" + + "\x04mode\x18\x03 \x01(\rR\x04mode\"\x81\x05\n" + "\x11DockerBuildRecipe\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x1e\n" + "\n" + @@ -737,14 +828,21 @@ const file_codefly_services_builder_v0_docker_proto_rawDesc = "" + "\x06target\x18\b \x01(\tR\x06target\x12Q\n" + "\fcontext_root\x18\t \x01(\x0e2..codefly.services.builder.v0.RecipeContextRootR\vcontextRoot\x12]\n" + "\x13go_module_downloads\x18\n" + - " \x03(\v2-.codefly.services.builder.v0.GoModuleDownloadR\x11goModuleDownloads\x1a<\n" + + " \x03(\v2-.codefly.services.builder.v0.GoModuleDownloadR\x11goModuleDownloads\x12`\n" + + "\x14git_source_downloads\x18\v \x03(\v2..codefly.services.builder.v0.GitSourceDownloadR\x12gitSourceDownloads\x1a<\n" + "\x0eBuildArgsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"X\n" + "\x10GoModuleDownload\x12\x1f\n" + "\vmodule_root\x18\x01 \x01(\tR\n" + "moduleRoot\x12#\n" + - "\rproxy_context\x18\x02 \x01(\tR\fproxyContext\"\xa6\x02\n" + + "\rproxy_context\x18\x02 \x01(\tR\fproxyContext\"r\n" + + "\x11GitSourceDownload\x12\x1e\n" + + "\n" + + "repository\x18\x01 \x01(\tR\n" + + "repository\x12\x16\n" + + "\x06commit\x18\x02 \x01(\tR\x06commit\x12%\n" + + "\x0emirror_context\x18\x03 \x01(\tR\rmirrorContext\"\xa6\x02\n" + "\x0fDockerBuildPlan\x12H\n" + "\arecipes\x18\x01 \x03(\v2..codefly.services.builder.v0.DockerBuildRecipeR\arecipes\x12=\n" + "\x05files\x18\x02 \x03(\v2'.codefly.services.builder.v0.RecipeFileR\x05files\x12\x16\n" + @@ -780,7 +878,7 @@ func file_codefly_services_builder_v0_docker_proto_rawDescGZIP() []byte { } var file_codefly_services_builder_v0_docker_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_codefly_services_builder_v0_docker_proto_msgTypes = make([]protoimpl.MessageInfo, 8) +var file_codefly_services_builder_v0_docker_proto_msgTypes = make([]protoimpl.MessageInfo, 9) var file_codefly_services_builder_v0_docker_proto_goTypes = []any{ (RecipeContextRoot)(0), // 0: codefly.services.builder.v0.RecipeContextRoot (RecipeInventoryScope)(0), // 1: codefly.services.builder.v0.RecipeInventoryScope @@ -789,23 +887,25 @@ var file_codefly_services_builder_v0_docker_proto_goTypes = []any{ (*RecipeFile)(nil), // 4: codefly.services.builder.v0.RecipeFile (*DockerBuildRecipe)(nil), // 5: codefly.services.builder.v0.DockerBuildRecipe (*GoModuleDownload)(nil), // 6: codefly.services.builder.v0.GoModuleDownload - (*DockerBuildPlan)(nil), // 7: codefly.services.builder.v0.DockerBuildPlan - (*BuildCacheOptions)(nil), // 8: codefly.services.builder.v0.BuildCacheOptions - nil, // 9: codefly.services.builder.v0.DockerBuildRecipe.BuildArgsEntry + (*GitSourceDownload)(nil), // 7: codefly.services.builder.v0.GitSourceDownload + (*DockerBuildPlan)(nil), // 8: codefly.services.builder.v0.DockerBuildPlan + (*BuildCacheOptions)(nil), // 9: codefly.services.builder.v0.BuildCacheOptions + nil, // 10: codefly.services.builder.v0.DockerBuildRecipe.BuildArgsEntry } var file_codefly_services_builder_v0_docker_proto_depIdxs = []int32{ - 8, // 0: codefly.services.builder.v0.DockerBuildContext.cache:type_name -> codefly.services.builder.v0.BuildCacheOptions - 9, // 1: codefly.services.builder.v0.DockerBuildRecipe.build_args:type_name -> codefly.services.builder.v0.DockerBuildRecipe.BuildArgsEntry - 0, // 2: codefly.services.builder.v0.DockerBuildRecipe.context_root:type_name -> codefly.services.builder.v0.RecipeContextRoot - 6, // 3: codefly.services.builder.v0.DockerBuildRecipe.go_module_downloads:type_name -> codefly.services.builder.v0.GoModuleDownload - 5, // 4: codefly.services.builder.v0.DockerBuildPlan.recipes:type_name -> codefly.services.builder.v0.DockerBuildRecipe - 4, // 5: codefly.services.builder.v0.DockerBuildPlan.files:type_name -> codefly.services.builder.v0.RecipeFile - 1, // 6: codefly.services.builder.v0.DockerBuildPlan.scope:type_name -> codefly.services.builder.v0.RecipeInventoryScope - 7, // [7:7] is the sub-list for method output_type - 7, // [7:7] is the sub-list for method input_type - 7, // [7:7] is the sub-list for extension type_name - 7, // [7:7] is the sub-list for extension extendee - 0, // [0:7] is the sub-list for field type_name + 9, // 0: codefly.services.builder.v0.DockerBuildContext.cache:type_name -> codefly.services.builder.v0.BuildCacheOptions + 10, // 1: codefly.services.builder.v0.DockerBuildRecipe.build_args:type_name -> codefly.services.builder.v0.DockerBuildRecipe.BuildArgsEntry + 0, // 2: codefly.services.builder.v0.DockerBuildRecipe.context_root:type_name -> codefly.services.builder.v0.RecipeContextRoot + 6, // 3: codefly.services.builder.v0.DockerBuildRecipe.go_module_downloads:type_name -> codefly.services.builder.v0.GoModuleDownload + 7, // 4: codefly.services.builder.v0.DockerBuildRecipe.git_source_downloads:type_name -> codefly.services.builder.v0.GitSourceDownload + 5, // 5: codefly.services.builder.v0.DockerBuildPlan.recipes:type_name -> codefly.services.builder.v0.DockerBuildRecipe + 4, // 6: codefly.services.builder.v0.DockerBuildPlan.files:type_name -> codefly.services.builder.v0.RecipeFile + 1, // 7: codefly.services.builder.v0.DockerBuildPlan.scope:type_name -> codefly.services.builder.v0.RecipeInventoryScope + 8, // [8:8] is the sub-list for method output_type + 8, // [8:8] is the sub-list for method input_type + 8, // [8:8] is the sub-list for extension type_name + 8, // [8:8] is the sub-list for extension extendee + 0, // [0:8] is the sub-list for field type_name } func init() { file_codefly_services_builder_v0_docker_proto_init() } @@ -819,7 +919,7 @@ func file_codefly_services_builder_v0_docker_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_codefly_services_builder_v0_docker_proto_rawDesc), len(file_codefly_services_builder_v0_docker_proto_rawDesc)), NumEnums: 2, - NumMessages: 8, + NumMessages: 9, NumExtensions: 0, NumServices: 0, }, diff --git a/generated/python/codefly-cli/codefly_cli/codefly/services/builder/v0/docker_pb2.py b/generated/python/codefly-cli/codefly_cli/codefly/services/builder/v0/docker_pb2.py index e79602961..c90fbc7e4 100644 --- a/generated/python/codefly-cli/codefly_cli/codefly/services/builder/v0/docker_pb2.py +++ b/generated/python/codefly-cli/codefly_cli/codefly/services/builder/v0/docker_pb2.py @@ -24,7 +24,7 @@ -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n(codefly/services/builder/v0/docker.proto\x12\x1b\x63odefly.services.builder.v0\"\xd1\x01\n\x12\x44ockerBuildContext\x12+\n\x11\x64ocker_repository\x18\x01 \x01(\tR\x10\x64ockerRepository\x12!\n\x0cimage_digest\x18\x02 \x01(\tR\x0bimageDigest\x12\x44\n\x05\x63\x61\x63he\x18\x03 \x01(\x0b\x32..codefly.services.builder.v0.BuildCacheOptionsR\x05\x63\x61\x63he\x12%\n\x0e\x62uildx_builder\x18\x04 \x01(\tR\rbuildxBuilder\"+\n\x11\x44ockerBuildResult\x12\x16\n\x06images\x18\x01 \x03(\tR\x06images\"L\n\nRecipeFile\x12\x12\n\x04path\x18\x01 \x01(\tR\x04path\x12\x16\n\x06\x64igest\x18\x02 \x01(\tR\x06\x64igest\x12\x12\n\x04mode\x18\x03 \x01(\rR\x04mode\"\x9f\x04\n\x11\x44ockerBuildRecipe\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12\x1e\n\ndockerfile\x18\x02 \x01(\tR\ndockerfile\x12\x18\n\x07\x63ontext\x18\x03 \x01(\tR\x07\x63ontext\x12\"\n\x0c\x64ockerignore\x18\x04 \x01(\tR\x0c\x64ockerignore\x12\x14\n\x05image\x18\x05 \x01(\tR\x05image\x12\x1c\n\tplatforms\x18\x06 \x03(\tR\tplatforms\x12\\\n\nbuild_args\x18\x07 \x03(\x0b\x32=.codefly.services.builder.v0.DockerBuildRecipe.BuildArgsEntryR\tbuildArgs\x12\x16\n\x06target\x18\x08 \x01(\tR\x06target\x12Q\n\x0c\x63ontext_root\x18\t \x01(\x0e\x32..codefly.services.builder.v0.RecipeContextRootR\x0b\x63ontextRoot\x12]\n\x13go_module_downloads\x18\n \x03(\x0b\x32-.codefly.services.builder.v0.GoModuleDownloadR\x11goModuleDownloads\x1a<\n\x0e\x42uildArgsEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\"X\n\x10GoModuleDownload\x12\x1f\n\x0bmodule_root\x18\x01 \x01(\tR\nmoduleRoot\x12#\n\rproxy_context\x18\x02 \x01(\tR\x0cproxyContext\"\xa6\x02\n\x0f\x44ockerBuildPlan\x12H\n\x07recipes\x18\x01 \x03(\x0b\x32..codefly.services.builder.v0.DockerBuildRecipeR\x07recipes\x12=\n\x05\x66iles\x18\x02 \x03(\x0b\x32\'.codefly.services.builder.v0.RecipeFileR\x05\x66iles\x12\x16\n\x06\x64igest\x18\x03 \x01(\tR\x06\x64igest\x12)\n\x10\x63ontract_version\x18\x04 \x01(\tR\x0f\x63ontractVersion\x12G\n\x05scope\x18\x05 \x01(\x0e\x32\x31.codefly.services.builder.v0.RecipeInventoryScopeR\x05scope\"\x8b\x01\n\x11\x42uildCacheOptions\x12\x18\n\x07imports\x18\x01 \x03(\tR\x07imports\x12\x18\n\x07\x65xports\x18\x02 \x03(\tR\x07\x65xports\x12\x14\n\x05scope\x18\x03 \x01(\tR\x05scope\x12\x12\n\x04mode\x18\x04 \x01(\tR\x04mode\x12\x18\n\x07\x62\x61\x63kend\x18\x05 \x01(\tR\x07\x62\x61\x63kend*y\n\x11RecipeContextRoot\x12#\n\x1fRECIPE_CONTEXT_ROOT_UNSPECIFIED\x10\x00\x12\x1f\n\x1bRECIPE_CONTEXT_ROOT_SERVICE\x10\x01\x12\x1e\n\x1aRECIPE_CONTEXT_ROOT_OUTPUT\x10\x02*\x83\x01\n\x14RecipeInventoryScope\x12&\n\"RECIPE_INVENTORY_SCOPE_UNSPECIFIED\x10\x00\x12\"\n\x1eRECIPE_INVENTORY_SCOPE_EMITTED\x10\x01\x12\x1f\n\x1bRECIPE_INVENTORY_SCOPE_TREE\x10\x02\x62\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n(codefly/services/builder/v0/docker.proto\x12\x1b\x63odefly.services.builder.v0\"\xd1\x01\n\x12\x44ockerBuildContext\x12+\n\x11\x64ocker_repository\x18\x01 \x01(\tR\x10\x64ockerRepository\x12!\n\x0cimage_digest\x18\x02 \x01(\tR\x0bimageDigest\x12\x44\n\x05\x63\x61\x63he\x18\x03 \x01(\x0b\x32..codefly.services.builder.v0.BuildCacheOptionsR\x05\x63\x61\x63he\x12%\n\x0e\x62uildx_builder\x18\x04 \x01(\tR\rbuildxBuilder\"+\n\x11\x44ockerBuildResult\x12\x16\n\x06images\x18\x01 \x03(\tR\x06images\"L\n\nRecipeFile\x12\x12\n\x04path\x18\x01 \x01(\tR\x04path\x12\x16\n\x06\x64igest\x18\x02 \x01(\tR\x06\x64igest\x12\x12\n\x04mode\x18\x03 \x01(\rR\x04mode\"\x81\x05\n\x11\x44ockerBuildRecipe\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12\x1e\n\ndockerfile\x18\x02 \x01(\tR\ndockerfile\x12\x18\n\x07\x63ontext\x18\x03 \x01(\tR\x07\x63ontext\x12\"\n\x0c\x64ockerignore\x18\x04 \x01(\tR\x0c\x64ockerignore\x12\x14\n\x05image\x18\x05 \x01(\tR\x05image\x12\x1c\n\tplatforms\x18\x06 \x03(\tR\tplatforms\x12\\\n\nbuild_args\x18\x07 \x03(\x0b\x32=.codefly.services.builder.v0.DockerBuildRecipe.BuildArgsEntryR\tbuildArgs\x12\x16\n\x06target\x18\x08 \x01(\tR\x06target\x12Q\n\x0c\x63ontext_root\x18\t \x01(\x0e\x32..codefly.services.builder.v0.RecipeContextRootR\x0b\x63ontextRoot\x12]\n\x13go_module_downloads\x18\n \x03(\x0b\x32-.codefly.services.builder.v0.GoModuleDownloadR\x11goModuleDownloads\x12`\n\x14git_source_downloads\x18\x0b \x03(\x0b\x32..codefly.services.builder.v0.GitSourceDownloadR\x12gitSourceDownloads\x1a<\n\x0e\x42uildArgsEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\"X\n\x10GoModuleDownload\x12\x1f\n\x0bmodule_root\x18\x01 \x01(\tR\nmoduleRoot\x12#\n\rproxy_context\x18\x02 \x01(\tR\x0cproxyContext\"r\n\x11GitSourceDownload\x12\x1e\n\nrepository\x18\x01 \x01(\tR\nrepository\x12\x16\n\x06\x63ommit\x18\x02 \x01(\tR\x06\x63ommit\x12%\n\x0emirror_context\x18\x03 \x01(\tR\rmirrorContext\"\xa6\x02\n\x0f\x44ockerBuildPlan\x12H\n\x07recipes\x18\x01 \x03(\x0b\x32..codefly.services.builder.v0.DockerBuildRecipeR\x07recipes\x12=\n\x05\x66iles\x18\x02 \x03(\x0b\x32\'.codefly.services.builder.v0.RecipeFileR\x05\x66iles\x12\x16\n\x06\x64igest\x18\x03 \x01(\tR\x06\x64igest\x12)\n\x10\x63ontract_version\x18\x04 \x01(\tR\x0f\x63ontractVersion\x12G\n\x05scope\x18\x05 \x01(\x0e\x32\x31.codefly.services.builder.v0.RecipeInventoryScopeR\x05scope\"\x8b\x01\n\x11\x42uildCacheOptions\x12\x18\n\x07imports\x18\x01 \x03(\tR\x07imports\x12\x18\n\x07\x65xports\x18\x02 \x03(\tR\x07\x65xports\x12\x14\n\x05scope\x18\x03 \x01(\tR\x05scope\x12\x12\n\x04mode\x18\x04 \x01(\tR\x04mode\x12\x18\n\x07\x62\x61\x63kend\x18\x05 \x01(\tR\x07\x62\x61\x63kend*y\n\x11RecipeContextRoot\x12#\n\x1fRECIPE_CONTEXT_ROOT_UNSPECIFIED\x10\x00\x12\x1f\n\x1bRECIPE_CONTEXT_ROOT_SERVICE\x10\x01\x12\x1e\n\x1aRECIPE_CONTEXT_ROOT_OUTPUT\x10\x02*\x83\x01\n\x14RecipeInventoryScope\x12&\n\"RECIPE_INVENTORY_SCOPE_UNSPECIFIED\x10\x00\x12\"\n\x1eRECIPE_INVENTORY_SCOPE_EMITTED\x10\x01\x12\x1f\n\x1bRECIPE_INVENTORY_SCOPE_TREE\x10\x02\x62\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -33,10 +33,10 @@ DESCRIPTOR._loaded_options = None _globals['_DOCKERBUILDRECIPE_BUILDARGSENTRY']._loaded_options = None _globals['_DOCKERBUILDRECIPE_BUILDARGSENTRY']._serialized_options = b'8\001' - _globals['_RECIPECONTEXTROOT']._serialized_start=1483 - _globals['_RECIPECONTEXTROOT']._serialized_end=1604 - _globals['_RECIPEINVENTORYSCOPE']._serialized_start=1607 - _globals['_RECIPEINVENTORYSCOPE']._serialized_end=1738 + _globals['_RECIPECONTEXTROOT']._serialized_start=1697 + _globals['_RECIPECONTEXTROOT']._serialized_end=1818 + _globals['_RECIPEINVENTORYSCOPE']._serialized_start=1821 + _globals['_RECIPEINVENTORYSCOPE']._serialized_end=1952 _globals['_DOCKERBUILDCONTEXT']._serialized_start=74 _globals['_DOCKERBUILDCONTEXT']._serialized_end=283 _globals['_DOCKERBUILDRESULT']._serialized_start=285 @@ -44,13 +44,15 @@ _globals['_RECIPEFILE']._serialized_start=330 _globals['_RECIPEFILE']._serialized_end=406 _globals['_DOCKERBUILDRECIPE']._serialized_start=409 - _globals['_DOCKERBUILDRECIPE']._serialized_end=952 - _globals['_DOCKERBUILDRECIPE_BUILDARGSENTRY']._serialized_start=892 - _globals['_DOCKERBUILDRECIPE_BUILDARGSENTRY']._serialized_end=952 - _globals['_GOMODULEDOWNLOAD']._serialized_start=954 - _globals['_GOMODULEDOWNLOAD']._serialized_end=1042 - _globals['_DOCKERBUILDPLAN']._serialized_start=1045 - _globals['_DOCKERBUILDPLAN']._serialized_end=1339 - _globals['_BUILDCACHEOPTIONS']._serialized_start=1342 - _globals['_BUILDCACHEOPTIONS']._serialized_end=1481 + _globals['_DOCKERBUILDRECIPE']._serialized_end=1050 + _globals['_DOCKERBUILDRECIPE_BUILDARGSENTRY']._serialized_start=990 + _globals['_DOCKERBUILDRECIPE_BUILDARGSENTRY']._serialized_end=1050 + _globals['_GOMODULEDOWNLOAD']._serialized_start=1052 + _globals['_GOMODULEDOWNLOAD']._serialized_end=1140 + _globals['_GITSOURCEDOWNLOAD']._serialized_start=1142 + _globals['_GITSOURCEDOWNLOAD']._serialized_end=1256 + _globals['_DOCKERBUILDPLAN']._serialized_start=1259 + _globals['_DOCKERBUILDPLAN']._serialized_end=1553 + _globals['_BUILDCACHEOPTIONS']._serialized_start=1556 + _globals['_BUILDCACHEOPTIONS']._serialized_end=1695 # @@protoc_insertion_point(module_scope) diff --git a/proto/codefly/services/builder/v0/docker.proto b/proto/codefly/services/builder/v0/docker.proto index e0f918869..4796b3422 100644 --- a/proto/codefly/services/builder/v0/docker.proto +++ b/proto/codefly/services/builder/v0/docker.proto @@ -84,6 +84,15 @@ message DockerBuildRecipe { // non-empty list requires the v5 recipe contract and participates in its // digest; older hosts reject v5 rather than build without supplying it. repeated GoModuleDownload go_module_downloads = 10; + // git_source_downloads are the git commits this recipe's dependency + // resolution fetches while it builds, as its lockfile pins them. Declaring + // them moves the fetch out of the build: the caller fetches each commit once, + // before it builds any image, with the credentials of the machine running the + // build, and supplies it to the build as a bare repository mirror. A recipe + // that declares them therefore needs no credential inside the build. A + // non-empty list requires the v6 recipe contract and participates in its + // digest; older hosts reject v6 rather than build without supplying it. + repeated GitSourceDownload git_source_downloads = 11; } // GoModuleDownload is one Go module graph a recipe downloads. @@ -99,6 +108,27 @@ message GoModuleDownload { string proxy_context = 2; } +// GitSourceDownload is one locked git commit a recipe fetches. +message GitSourceDownload { + // repository is the URL the build fetches from, exactly as the lockfile + // records it, without query or fragment: https, ssh or git+ssh, carrying no + // password or token. The Dockerfile maps this URL onto the supplied mirror + // (git's url..insteadOf), so it must match the lockfile byte for byte. + string repository = 1; + // commit is the full object id the lockfile pins: 40 (SHA-1) or 64 (SHA-256) + // lowercase hexadecimal characters. + string commit = 2; + // mirror_context names the build context the caller supplies the fetched + // commits as: a directory holding one bare repository per declared + // repository, named by the lowercase hex SHA-256 of the repository string + // followed by ".git" (services.GitSourceMirrorPath in Go), each holding every + // declared commit under refs/commit/. Several downloads may + // share one mirror context. The Dockerfile declares a stage of the same name + // (FROM scratch) so it still builds, from the network, when no caller + // supplies one. + string mirror_context = 3; +} + // RecipeInventoryScope states what a plan's file inventory claims to cover, so // the verifier knows what "complete" means for that plan. Without it the // verifier has to guess, and the two answers are not interchangeable: guessing From 71f98a07a62cc4d1e73e915c3894cef64c8115bc Mon Sep 17 00:00:00 2001 From: Saurabh Maheshwari <242053479+saurabhmaheshwari-obin@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:43:32 -0400 Subject: [PATCH 2/5] fix(builder): refuse unsafe git source URLs without echoing them; record recipe v5 and v6 A refused git source repository may be a credential pasted into a URL, and a builder error reaches the caller's RPC status and logs. Errors now name the declaration by position and the reason, never the URL or any part of it. The repository must be spelled exactly https:// or ssh:// (git rejects the spellings url.Parse normalizes, such as SSH://), with a host, an optional port and a path whose every character is in [A-Za-z0-9._~/@:%+-], so it can be written into a Dockerfile or a git config value verbatim. git+ssh is no longer accepted: a contract widens more easily than it narrows. GitSourceMirrorRef names the ref a mirror holds a commit under, beside GitSourceMirrorPath, so a host does not hard-code it. The option and proto documentation state that a host fetches every declared source and refuses an unfetched declaration before it accepts a v6 plan, and that declarations are digested in order and exact duplicates refused, so they are declared sorted and de-duplicated. agents/contract/contract.json and docs/agent-contract.md now record docker-build-recipe v5, which was never recorded, and v6. Co-Authored-By: Claude Opus 5.5 --- agents/contract/contract.json | 2 +- agents/contract/contract_test.go | 4 +- agents/services/docker_recipe.go | 87 +++++++++++-------- .../docker_recipe_git_sources_test.go | 87 ++++++++++++++----- docs/agent-contract.md | 11 +++ .../codefly/services/builder/v0/docker.pb.go | 20 +++-- .../codefly/services/builder/v0/docker.proto | 20 +++-- 7 files changed, 157 insertions(+), 74 deletions(-) diff --git a/agents/contract/contract.json b/agents/contract/contract.json index 37ba73c1a..5d291b74a 100644 --- a/agents/contract/contract.json +++ b/agents/contract/contract.json @@ -2,5 +2,5 @@ "protocolVersion": 1, "capabilities": ["container-recovery-scope/v1"], "startupProtocolVersion": 2, - "operationContracts": ["artifact-execution/v1", "runtime-init-dependency-mappings/v1", "codefly.dev/docker-build-recipe/v4", "configuration-value-template/v1", "runtime-init-image/v1", "deployment-composition-provenance/v1", "runtime-started-artifact/v1"] + "operationContracts": ["artifact-execution/v1", "runtime-init-dependency-mappings/v1", "codefly.dev/docker-build-recipe/v4", "codefly.dev/docker-build-recipe/v5", "codefly.dev/docker-build-recipe/v6", "configuration-value-template/v1", "runtime-init-image/v1", "deployment-composition-provenance/v1", "runtime-started-artifact/v1"] } diff --git a/agents/contract/contract_test.go b/agents/contract/contract_test.go index 057cb5758..f968fad3c 100644 --- a/agents/contract/contract_test.go +++ b/agents/contract/contract_test.go @@ -11,7 +11,7 @@ import ( ) func TestOperationSupportDoesNotAdvertiseExecutorAdoption(t *testing.T) { - require.Equal(t, []string{artifactexecution.Contract, RuntimeInitDependencyMappings, "codefly.dev/docker-build-recipe/v4", ConfigurationValueTemplate, RuntimeInitImage, DeploymentCompositionProvenance, RuntimeStartedArtifact}, SupportedOperationContracts()) + require.Equal(t, []string{artifactexecution.Contract, RuntimeInitDependencyMappings, "codefly.dev/docker-build-recipe/v4", "codefly.dev/docker-build-recipe/v5", "codefly.dev/docker-build-recipe/v6", ConfigurationValueTemplate, RuntimeInitImage, DeploymentCompositionProvenance, RuntimeStartedArtifact}, SupportedOperationContracts()) require.NotContains(t, Current().Capabilities, artifactexecution.Contract) require.NotContains(t, Current().Capabilities, RuntimeInitDependencyMappings) require.NotContains(t, Current().Capabilities, RuntimeInitImage) @@ -25,7 +25,7 @@ func TestOperationSupportDoesNotAdvertiseExecutorAdoption(t *testing.T) { // compatible, so this must never leak into the AgentContract wire. require.NotContains(t, Current().Capabilities, ConfigurationValueTemplate) SupportedOperationContracts()[0] = "changed" - require.Equal(t, []string{artifactexecution.Contract, RuntimeInitDependencyMappings, "codefly.dev/docker-build-recipe/v4", ConfigurationValueTemplate, RuntimeInitImage, DeploymentCompositionProvenance, RuntimeStartedArtifact}, SupportedOperationContracts()) + require.Equal(t, []string{artifactexecution.Contract, RuntimeInitDependencyMappings, "codefly.dev/docker-build-recipe/v4", "codefly.dev/docker-build-recipe/v5", "codefly.dev/docker-build-recipe/v6", ConfigurationValueTemplate, RuntimeInitImage, DeploymentCompositionProvenance, RuntimeStartedArtifact}, SupportedOperationContracts()) } func TestCheck(t *testing.T) { diff --git a/agents/services/docker_recipe.go b/agents/services/docker_recipe.go index fd8572b6b..b6fd8787b 100644 --- a/agents/services/docker_recipe.go +++ b/agents/services/docker_recipe.go @@ -256,9 +256,13 @@ func WithGoModuleDownloads(downloads ...*builderv0.GoModuleDownload) RecipeOptio // WithGitSourceDownloads declares the locked git commits the recipe fetches // while it builds (DockerBuildRecipe.git_source_downloads). The caller fetches -// each into a bare mirror at GitSourceMirrorPath(repository) inside the named -// mirror context before any image build, so the build needs no credential. A -// recipe carrying one emits the v6 contract. +// each into a bare mirror at GitSourceMirrorPath(repository), under +// GitSourceMirrorRef(commit), inside the named mirror context before any image +// build, so the build needs no credential. A recipe carrying one emits the v6 +// contract, and a host must fetch every declared source, and refuse a +// declaration it did not fetch, before it accepts a v6 plan. Declarations are +// digested in the order given and an exact duplicate is refused, so declare +// them sorted and de-duplicated. func WithGitSourceDownloads(downloads ...*builderv0.GitSourceDownload) RecipeOption { return func(recipe *builderv0.DockerBuildRecipe) { recipe.GitSourceDownloads = append(recipe.GitSourceDownloads, downloads...) @@ -274,6 +278,13 @@ func GitSourceMirrorPath(repository string) string { return hex.EncodeToString(sum[:]) + ".git" } +// GitSourceMirrorRef is the ref under which a git source mirror holds commit. +// It is the ref a fetch by commit id writes, so a build fetching the locked +// commit from the mirror finds it there. +func GitSourceMirrorRef(commit string) string { + return "refs/commit/" + commit +} + func SingleImageBuildPlan(outputDirectory, image string, platforms []string, emitted []string, options ...RecipeOption) (*builderv0.DockerBuildPlan, error) { dockerignore := "" for _, name := range emitted { @@ -501,66 +512,74 @@ func validateGoModuleDownloads(recipe *builderv0.DockerBuildRecipe) error { // gitCommitID is a full git object id: SHA-1 (40) or SHA-256 (64), lowercase. var gitCommitID = regexp.MustCompile(`^([0-9a-f]{40}|[0-9a-f]{64})$`) +// gitSourceRepository is the accepted shape of a git source URL: https or ssh, +// spelled exactly so, an optional login name (ssh only, checked below), a host, +// an optional port and a non-empty path. Every character is one a Dockerfile +// and a git config value carry without quoting, so the URL can be written into +// either verbatim. +var gitSourceRepository = regexp.MustCompile(`^(https|ssh)://(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9.-]+(?::[0-9]{1,5})?/[A-Za-z0-9._~/@:%+-]*[A-Za-z0-9._~@:%+-][A-Za-z0-9._~/@:%+-]*$`) + // validateGitSourceDownloads checks a recipe's declared git sources: each names -// a credential-free https, ssh or git+ssh URL with no query or fragment, a full -// commit id, and a valid build-context name that is not also a Go module proxy +// a credential-free https or ssh URL with no query or fragment, a full commit +// id, and a valid build-context name that is not also a Go module proxy // context. The same repository may be declared at several commits; the same -// (repository, commit, context) twice is refused. +// (repository, commit, context) twice is refused. A refused repository may +// carry a credential, so errors name the declaration by position, never by URL. func validateGitSourceDownloads(recipe *builderv0.DockerBuildRecipe) error { proxies := make(map[string]struct{}, len(recipe.GetGoModuleDownloads())) for _, download := range recipe.GetGoModuleDownloads() { proxies[download.GetProxyContext()] = struct{}{} } seen := make(map[[3]string]struct{}, len(recipe.GetGitSourceDownloads())) - for _, download := range recipe.GetGitSourceDownloads() { - repository := download.GetRepository() - if err := validateGitSourceRepository(repository); err != nil { - return fmt.Errorf("recipe %q git source %q: %w", recipe.GetName(), repository, err) + for index, download := range recipe.GetGitSourceDownloads() { + if reason := gitSourceRepositoryProblem(download.GetRepository()); reason != "" { + return fmt.Errorf("recipe %q git source %d: repository %s", recipe.GetName(), index, reason) } if !gitCommitID.MatchString(download.GetCommit()) { - return fmt.Errorf("recipe %q git source %q commit %q is not a full lowercase hex object id", recipe.GetName(), repository, download.GetCommit()) + return fmt.Errorf("recipe %q git source %d: commit is not a full lowercase hex object id", recipe.GetName(), index) } name := download.GetMirrorContext() if !proxyContextName.MatchString(name) { - return fmt.Errorf("recipe %q git source mirror context %q is not a valid build context name", recipe.GetName(), name) + return fmt.Errorf("recipe %q git source %d: mirror context %q is not a valid build context name", recipe.GetName(), index, name) } if _, ok := proxies[name]; ok { - return fmt.Errorf("recipe %q uses build context %q for both Go modules and git sources", recipe.GetName(), name) + return fmt.Errorf("recipe %q git source %d: build context %q is also a Go module proxy context", recipe.GetName(), index, name) } - key := [3]string{repository, download.GetCommit(), name} + key := [3]string{download.GetRepository(), download.GetCommit(), name} if _, ok := seen[key]; ok { - return fmt.Errorf("recipe %q declares git source %q at %s in %q twice", recipe.GetName(), repository, download.GetCommit(), name) + return fmt.Errorf("recipe %q git source %d: declared twice", recipe.GetName(), index) } seen[key] = struct{}{} } return nil } -func validateGitSourceRepository(repository string) error { - parsed, err := url.Parse(repository) - if err != nil { - return fmt.Errorf("not a URL: %w", err) +// gitSourceRepositoryProblem returns why repository is refused, or "". The +// reason never quotes the URL. +func gitSourceRepositoryProblem(repository string) string { + scheme, rest, found := strings.Cut(repository, "://") + if !found || (scheme != "https" && scheme != "ssh") { + return "must start with https:// or ssh://" } - switch parsed.Scheme { - case "https": - if parsed.User != nil { - return fmt.Errorf("an https URL must carry no user information") + if strings.ContainsAny(repository, "?#") { + return "must carry no query or fragment" + } + authority, _, _ := strings.Cut(rest, "/") + if userinfo, _, hasUser := strings.Cut(authority, "@"); hasUser { + if scheme == "https" { + return "must carry no user information over https" } - case "ssh", "git+ssh": - // An ssh login name such as git@ is ordinary; a password never is. - if _, hasPassword := parsed.User.Password(); hasPassword { - return fmt.Errorf("an ssh URL must carry no password") + if strings.Contains(userinfo, ":") { + return "must carry no password" } - default: - return fmt.Errorf("scheme must be https, ssh or git+ssh") } - if parsed.Host == "" || parsed.Path == "" || parsed.Path == "/" { - return fmt.Errorf("URL must name a host and a repository path") + if !gitSourceRepository.MatchString(repository) { + return "must be a host and repository path in [A-Za-z0-9._~/@:%+-], with an optional port" } - if parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" || strings.ContainsAny(repository, "?#") { - return fmt.Errorf("URL must carry no query or fragment") + if _, err := url.Parse(repository); err != nil { + return "is not a valid URL" } - return nil + return "" } // recipeRelPath validates that p is a non-empty relative path that stays inside diff --git a/agents/services/docker_recipe_git_sources_test.go b/agents/services/docker_recipe_git_sources_test.go index 679d10d9a..563e125fb 100644 --- a/agents/services/docker_recipe_git_sources_test.go +++ b/agents/services/docker_recipe_git_sources_test.go @@ -88,27 +88,12 @@ func TestRecipeGitSourceDownloadsWithGoModules(t *testing.T) { recipe.ContextRoot = builderv0.RecipeContextRoot_RECIPE_CONTEXT_ROOT_OUTPUT recipe.GitSourceDownloads[0].MirrorContext = "gomodproxy" _, err = BuildDockerBuildPlan(dir, []*builderv0.DockerBuildRecipe{recipe}) - require.ErrorContains(t, err, "both") + require.ErrorContains(t, err, "also a Go module proxy context") } func TestRecipeGitSourceDownloadsAreValidated(t *testing.T) { dir := recipeDir(t) for name, mutate := range map[string]func(*builderv0.GitSourceDownload){ - "empty repository": func(s *builderv0.GitSourceDownload) { s.Repository = "" }, - "http scheme": func(s *builderv0.GitSourceDownload) { s.Repository = "http://git.example.com/team/lib" }, - "file scheme": func(s *builderv0.GitSourceDownload) { s.Repository = "file:///srv/lib" }, - "ext scheme": func(s *builderv0.GitSourceDownload) { s.Repository = "ext::sh -c touch% /tmp/x" }, - "scp-like": func(s *builderv0.GitSourceDownload) { s.Repository = "git@git.example.com:team/lib.git" }, - "https token": func(s *builderv0.GitSourceDownload) { s.Repository = "https://token@git.example.com/team/lib" }, - "https password": func(s *builderv0.GitSourceDownload) { s.Repository = "https://user:secret@git.example.com/team/lib" }, - "ssh password": func(s *builderv0.GitSourceDownload) { s.Repository = "ssh://git:secret@git.example.com/team/lib" }, - "no host": func(s *builderv0.GitSourceDownload) { s.Repository = "https:///team/lib" }, - "no path": func(s *builderv0.GitSourceDownload) { s.Repository = "https://git.example.com/" }, - "query": func(s *builderv0.GitSourceDownload) { s.Repository = "https://git.example.com/team/lib?rev=main" }, - "empty query": func(s *builderv0.GitSourceDownload) { s.Repository = "https://git.example.com/team/lib?" }, - "fragment": func(s *builderv0.GitSourceDownload) { - s.Repository = "https://git.example.com/team/lib#" + lockedCommit - }, "short commit": func(s *builderv0.GitSourceDownload) { s.Commit = lockedCommit[:12] }, "uppercase commit": func(s *builderv0.GitSourceDownload) { s.Commit = strings.ToUpper(lockedCommit) }, "41-char commit": func(s *builderv0.GitSourceDownload) { s.Commit = lockedCommit + "0" }, @@ -125,12 +110,12 @@ func TestRecipeGitSourceDownloadsAreValidated(t *testing.T) { }) } - // Accepted forms: ssh with a login name, git+ssh, a SHA-256 commit, and the - // same repository locked at two commits. + // Accepted forms: ssh with a login name and a port, a SHA-256 commit, a + // percent-escape, and the same repository locked at two commits. recipe := gitSourceRecipe() recipe.GitSourceDownloads = append(recipe.GitSourceDownloads, - &builderv0.GitSourceDownload{Repository: "ssh://git@git.example.com/team/lib.git", Commit: lockedCommit, MirrorContext: "gitsources"}, - &builderv0.GitSourceDownload{Repository: "git+ssh://git@git.example.com/team/lib.git", Commit: strings.Repeat("b", 64), MirrorContext: "gitsources"}, + &builderv0.GitSourceDownload{Repository: "ssh://git@git.example.com:2222/team/lib.git", Commit: lockedCommit, MirrorContext: "gitsources"}, + &builderv0.GitSourceDownload{Repository: "https://git.example.com/team/my%2Blib~v1_x-y", Commit: strings.Repeat("b", 64), MirrorContext: "gitsources"}, &builderv0.GitSourceDownload{Repository: "https://git.example.com/team/lib", Commit: strings.Repeat("c", 40), MirrorContext: "gitsources"}) _, err := BuildDockerBuildPlan(dir, []*builderv0.DockerBuildRecipe{recipe}) require.NoError(t, err) @@ -140,6 +125,63 @@ func TestRecipeGitSourceDownloadsAreValidated(t *testing.T) { require.ErrorContains(t, err, "twice") } +// A refused repository may be a credential pasted into a URL. The error names +// the declaration and the reason, and never the URL or any part of it, because +// a builder's error reaches the caller's RPC status and logs. +func TestRecipeGitSourceRepositoryRefusalsNeverEchoTheURL(t *testing.T) { + dir := recipeDir(t) + for name, repository := range map[string]string{ + "empty": "", + "http": "http://git.example.com/team/lib", + "file": "file:///srv/lib", + "ext": "ext::sh -c touch% /tmp/x", + "git+ssh": "git+ssh://git@git.example.com/team/lib", + "uppercase SSH": "SSH://git@git.example.com/team/lib", + "mixed-case https": "Https://git.example.com/team/lib", + "scp-like": "git@git.example.com:team/lib.git", + "https token": "https://s3cr3tvalue@git.example.com/team/lib", + "https password": "https://user:s3cr3tvalue@git.example.com/team/lib", + "ssh password": "ssh://git:s3cr3tvalue@git.example.com/team/lib", + "query token": "https://git.example.com/team/lib?token=s3cr3tvalue", + "empty query": "https://git.example.com/team/lib?", + "fragment": "https://git.example.com/team/lib#" + lockedCommit, + "no host": "https:///team/lib", + "no path": "https://git.example.com", + "root path": "https://git.example.com/", + "bad port": "https://git.example.com:http/team/lib", + "bad escape": "https://git.example.com/team/%zzlib", + "space": "https://git.example.com/team/my lib", + "double quote": `https://git.example.com/team/"lib`, + "single quote": "https://git.example.com/team/'lib", + "backquote": "https://git.example.com/team/`id`", + "command substitution": "https://git.example.com/team/$(id)", + "variable": "https://git.example.com/team/${HOME}", + "semicolon": "https://git.example.com/team/lib;id", + "ampersand": "https://git.example.com/team/lib&id", + "pipe": "https://git.example.com/team/lib|id", + "redirect": "https://git.example.com/team/lib>out", + "backslash": `https://git.example.com/team\lib`, + "newline": "https://git.example.com/team/lib\nRUN id", + "tab": "https://git.example.com/team/\tlib", + "glob": "https://git.example.com/team/*", + "brackets": "https://git.example.com/team/[lib]", + "equals": "https://git.example.com/team/a=b", + "comma": "https://git.example.com/team/a,b", + "non-ascii": "https://git.example.com/team/lib\u00e9", + "ssh user quote": "ssh://g'it@git.example.com/team/lib", + "host underscore": "https://git_example.com/team/lib", + } { + t.Run(name, func(t *testing.T) { + recipe := gitSourceRecipe() + recipe.GitSourceDownloads[0].Repository = repository + _, err := BuildDockerBuildPlan(dir, []*builderv0.DockerBuildRecipe{recipe}) + require.ErrorContains(t, err, "git source 0: repository") + require.NotContains(t, err.Error(), "s3cr3tvalue") + require.NotContains(t, err.Error(), "git.example.com") + }) + } +} + func TestGitSourceMirrorPath(t *testing.T) { path := GitSourceMirrorPath("https://git.example.com/team/lib") require.Regexp(t, regexp.MustCompile(`^[0-9a-f]{64}\.git$`), path) @@ -155,7 +197,6 @@ func TestGitSourceMirrorPath(t *testing.T) { "https://git.example.com/team/lib/", "https://git.example.com/Team/lib", "ssh://git@git.example.com/team/lib", - "git+ssh://git@git.example.com/team/lib", } { mirror := GitSourceMirrorPath(repository) require.NotContains(t, seen, mirror, "%s collides with %s", repository, seen[mirror]) @@ -163,6 +204,10 @@ func TestGitSourceMirrorPath(t *testing.T) { } } +func TestGitSourceMirrorRef(t *testing.T) { + require.Equal(t, "refs/commit/"+lockedCommit, GitSourceMirrorRef(lockedCommit)) +} + func TestSingleImageBuildPlanDeclaresGitSourceDownloads(t *testing.T) { dir := recipeDir(t) plan, err := SingleImageBuildPlan(dir, "example/app:v1", RecipeBuildPlatforms(), []string{"Dockerfile"}, diff --git a/docs/agent-contract.md b/docs/agent-contract.md index 8bcabb864..40cec7259 100644 --- a/docs/agent-contract.md +++ b/docs/agent-contract.md @@ -259,6 +259,17 @@ roots. Only plans selecting an explicit root use v4; default service-root plans retain v3 and its digest. The plan version, not the agent identity or Core pin, prevents an older host from silently building the wrong tree. See [build recipe context selection](build-cache.md#cli-executor-selection). +`codefly.dev/docker-build-recipe/v5` records support for declared Go module +downloads (`DockerBuildRecipe.go_module_downloads`). Only plans declaring one +use v5. A v5 host fetches each declared module graph before any image build, +supplies it as the named proxy build context, and refuses a declaration it did +not fetch. +`codefly.dev/docker-build-recipe/v6` records support for declared git source +downloads (`DockerBuildRecipe.git_source_downloads`). Only plans declaring one +use v6, and v6 also covers v5's declarations. A v6 host fetches each declared +commit into a bare mirror at `services.GitSourceMirrorPath(repository)`, under +`services.GitSourceMirrorRef(commit)`, supplies the mirrors as the named build +context, and refuses a declaration it did not fetch. `runtime-init-dependency-mappings/v1` is explicitly advertised by runtimes that consume `InitRequest.dependencies_network_mappings` before executing a test whose target is never started. These are the dependencies' accepted addresses, diff --git a/generated/go/codefly/services/builder/v0/docker.pb.go b/generated/go/codefly/services/builder/v0/docker.pb.go index 386367e69..772752b01 100644 --- a/generated/go/codefly/services/builder/v0/docker.pb.go +++ b/generated/go/codefly/services/builder/v0/docker.pb.go @@ -374,7 +374,9 @@ type DockerBuildRecipe struct { // build, and supplies it to the build as a bare repository mirror. A recipe // that declares them therefore needs no credential inside the build. A // non-empty list requires the v6 recipe contract and participates in its - // digest; older hosts reject v6 rather than build without supplying it. + // digest, in order; older hosts reject v6 rather than build without + // supplying it. A host accepts a v6 plan only after it has fetched every + // declared source, and refuses a declaration it did not fetch. GitSourceDownloads []*GitSourceDownload `protobuf:"bytes,11,rep,name=git_source_downloads,json=gitSourceDownloads,proto3" json:"git_source_downloads,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -551,9 +553,11 @@ func (x *GoModuleDownload) GetProxyContext() string { type GitSourceDownload struct { state protoimpl.MessageState `protogen:"open.v1"` // repository is the URL the build fetches from, exactly as the lockfile - // records it, without query or fragment: https, ssh or git+ssh, carrying no - // password or token. The Dockerfile maps this URL onto the supplied mirror - // (git's url..insteadOf), so it must match the lockfile byte for byte. + // records it: https:// or ssh://, spelled in lowercase, a host, an optional + // port and a path, every character in [A-Za-z0-9._~/@:%+-], with no query, + // fragment, password or token (ssh may name a login user). The Dockerfile + // maps this URL onto the supplied mirror (git's url..insteadOf), so it + // must match the lockfile byte for byte. Repository string `protobuf:"bytes,1,opt,name=repository,proto3" json:"repository,omitempty"` // commit is the full object id the lockfile pins: 40 (SHA-1) or 64 (SHA-256) // lowercase hexadecimal characters. @@ -562,10 +566,10 @@ type GitSourceDownload struct { // commits as: a directory holding one bare repository per declared // repository, named by the lowercase hex SHA-256 of the repository string // followed by ".git" (services.GitSourceMirrorPath in Go), each holding every - // declared commit under refs/commit/. Several downloads may - // share one mirror context. The Dockerfile declares a stage of the same name - // (FROM scratch) so it still builds, from the network, when no caller - // supplies one. + // declared commit under refs/commit/ (services.GitSourceMirrorRef). + // Several downloads may share one mirror context. The Dockerfile declares a + // stage of the same name (FROM scratch) so it still builds, from the network, + // when no caller supplies one. MirrorContext string `protobuf:"bytes,3,opt,name=mirror_context,json=mirrorContext,proto3" json:"mirror_context,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache diff --git a/proto/codefly/services/builder/v0/docker.proto b/proto/codefly/services/builder/v0/docker.proto index 4796b3422..d771e7223 100644 --- a/proto/codefly/services/builder/v0/docker.proto +++ b/proto/codefly/services/builder/v0/docker.proto @@ -91,7 +91,9 @@ message DockerBuildRecipe { // build, and supplies it to the build as a bare repository mirror. A recipe // that declares them therefore needs no credential inside the build. A // non-empty list requires the v6 recipe contract and participates in its - // digest; older hosts reject v6 rather than build without supplying it. + // digest, in order; older hosts reject v6 rather than build without + // supplying it. A host accepts a v6 plan only after it has fetched every + // declared source, and refuses a declaration it did not fetch. repeated GitSourceDownload git_source_downloads = 11; } @@ -111,9 +113,11 @@ message GoModuleDownload { // GitSourceDownload is one locked git commit a recipe fetches. message GitSourceDownload { // repository is the URL the build fetches from, exactly as the lockfile - // records it, without query or fragment: https, ssh or git+ssh, carrying no - // password or token. The Dockerfile maps this URL onto the supplied mirror - // (git's url..insteadOf), so it must match the lockfile byte for byte. + // records it: https:// or ssh://, spelled in lowercase, a host, an optional + // port and a path, every character in [A-Za-z0-9._~/@:%+-], with no query, + // fragment, password or token (ssh may name a login user). The Dockerfile + // maps this URL onto the supplied mirror (git's url..insteadOf), so it + // must match the lockfile byte for byte. string repository = 1; // commit is the full object id the lockfile pins: 40 (SHA-1) or 64 (SHA-256) // lowercase hexadecimal characters. @@ -122,10 +126,10 @@ message GitSourceDownload { // commits as: a directory holding one bare repository per declared // repository, named by the lowercase hex SHA-256 of the repository string // followed by ".git" (services.GitSourceMirrorPath in Go), each holding every - // declared commit under refs/commit/. Several downloads may - // share one mirror context. The Dockerfile declares a stage of the same name - // (FROM scratch) so it still builds, from the network, when no caller - // supplies one. + // declared commit under refs/commit/ (services.GitSourceMirrorRef). + // Several downloads may share one mirror context. The Dockerfile declares a + // stage of the same name (FROM scratch) so it still builds, from the network, + // when no caller supplies one. string mirror_context = 3; } From 9c4eef09838a07ddd528946831599fcf132eef4a Mon Sep 17 00:00:00 2001 From: Saurabh Maheshwari <242053479+saurabhmaheshwari-obin@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:43:32 -0400 Subject: [PATCH 3/5] fix(builder): refuse percent-encoding and "@" in a git source path git decodes percent-encoding before it splits an ssh URL into user, host and path, so ssh://host/team/x@%5B::1%5D/repo passed validation and plan verification while git read the user as host/team/x, the host as ::1 and the path as /repo. A git source repository now carries no "%" at all, for https as well as ssh: no lockfile URL needs an escape, and refusing every one is simpler to reason about than refusing the escapes of "/", "@", "[", "]", ":" and "%". No "@" may appear after the authority either; the only "@" accepted is an ssh login name's. Both refusals are tested through plan construction (WithGitSourceDownloads) and through VerifyDockerBuildPlan. A recipe declaring both Go module downloads and git sources is now also tested to be refused by a host that expects v5. Co-Authored-By: Claude Opus 5.5 --- agents/services/docker_recipe.go | 17 ++++++-- .../docker_recipe_git_sources_test.go | 40 ++++++++++++++++++- .../codefly/services/builder/v0/docker.pb.go | 5 ++- .../codefly/services/builder/v0/docker.proto | 5 ++- 4 files changed, 58 insertions(+), 9 deletions(-) diff --git a/agents/services/docker_recipe.go b/agents/services/docker_recipe.go index b6fd8787b..6eea68a17 100644 --- a/agents/services/docker_recipe.go +++ b/agents/services/docker_recipe.go @@ -514,10 +514,10 @@ var gitCommitID = regexp.MustCompile(`^([0-9a-f]{40}|[0-9a-f]{64})$`) // gitSourceRepository is the accepted shape of a git source URL: https or ssh, // spelled exactly so, an optional login name (ssh only, checked below), a host, -// an optional port and a non-empty path. Every character is one a Dockerfile +// an optional port and a non-empty path with no "@" and no percent-encoding. Every character is one a Dockerfile // and a git config value carry without quoting, so the URL can be written into // either verbatim. -var gitSourceRepository = regexp.MustCompile(`^(https|ssh)://(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9.-]+(?::[0-9]{1,5})?/[A-Za-z0-9._~/@:%+-]*[A-Za-z0-9._~@:%+-][A-Za-z0-9._~/@:%+-]*$`) +var gitSourceRepository = regexp.MustCompile(`^(https|ssh)://(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9.-]+(?::[0-9]{1,5})?/[A-Za-z0-9._~/:+-]*[A-Za-z0-9._~:+-][A-Za-z0-9._~/:+-]*$`) // validateGitSourceDownloads checks a recipe's declared git sources: each names // a credential-free https or ssh URL with no query or fragment, a full commit @@ -564,7 +564,16 @@ func gitSourceRepositoryProblem(repository string) string { if strings.ContainsAny(repository, "?#") { return "must carry no query or fragment" } - authority, _, _ := strings.Cut(rest, "/") + // git decodes percent-encoding before it splits an ssh URL into user, host + // and path, so an encoded "[", "@" or "/" would move the host after this + // check has passed; no lockfile URL needs one, so none is accepted. + if strings.Contains(repository, "%") { + return "must carry no percent-encoding" + } + authority, path, _ := strings.Cut(rest, "/") + if strings.Contains(path, "@") { + return "must carry no @ in the path" + } if userinfo, _, hasUser := strings.Cut(authority, "@"); hasUser { if scheme == "https" { return "must carry no user information over https" @@ -574,7 +583,7 @@ func gitSourceRepositoryProblem(repository string) string { } } if !gitSourceRepository.MatchString(repository) { - return "must be a host and repository path in [A-Za-z0-9._~/@:%+-], with an optional port" + return "must be a host and repository path in [A-Za-z0-9._~/:+-], with an optional port" } if _, err := url.Parse(repository); err != nil { return "is not a valid URL" diff --git a/agents/services/docker_recipe_git_sources_test.go b/agents/services/docker_recipe_git_sources_test.go index 563e125fb..411acf01c 100644 --- a/agents/services/docker_recipe_git_sources_test.go +++ b/agents/services/docker_recipe_git_sources_test.go @@ -77,6 +77,12 @@ func TestRecipeGitSourceDownloadsWithGoModules(t *testing.T) { require.NoError(t, err) require.Equal(t, DockerBuildRecipeGitSourcesContractVersion, plan.GetContractVersion()) + // A host that knows only v5 expects v5 for these recipes and refuses. + plan.ContractVersion = DockerBuildRecipeGoModulesContractVersion + require.ErrorContains(t, VerifyDockerBuildPlan(dir, plan), "contract") + plan.ContractVersion = DockerBuildRecipeGitSourcesContractVersion + require.NoError(t, VerifyDockerBuildPlan(dir, plan)) + // v6 still digests what v4 and v5 introduced. recipe.GoModuleDownloads[0].ModuleRoot = "other" require.ErrorContains(t, VerifyDockerBuildPlan(dir, plan), "digest") @@ -115,7 +121,7 @@ func TestRecipeGitSourceDownloadsAreValidated(t *testing.T) { recipe := gitSourceRecipe() recipe.GitSourceDownloads = append(recipe.GitSourceDownloads, &builderv0.GitSourceDownload{Repository: "ssh://git@git.example.com:2222/team/lib.git", Commit: lockedCommit, MirrorContext: "gitsources"}, - &builderv0.GitSourceDownload{Repository: "https://git.example.com/team/my%2Blib~v1_x-y", Commit: strings.Repeat("b", 64), MirrorContext: "gitsources"}, + &builderv0.GitSourceDownload{Repository: "https://git.example.com/team/my+lib~v1_x-y", Commit: strings.Repeat("b", 64), MirrorContext: "gitsources"}, &builderv0.GitSourceDownload{Repository: "https://git.example.com/team/lib", Commit: strings.Repeat("c", 40), MirrorContext: "gitsources"}) _, err := BuildDockerBuildPlan(dir, []*builderv0.DockerBuildRecipe{recipe}) require.NoError(t, err) @@ -182,6 +188,38 @@ func TestRecipeGitSourceRepositoryRefusalsNeverEchoTheURL(t *testing.T) { } } +// git decodes percent-encoding before it splits an ssh URL into user, host and +// path, so an encoded bracket or "@" could move the host past the checks. Such +// URLs are refused both when a plan is built and when one is verified. +func TestRecipeGitSourceRepositoryRefusesEncodedAuthorityTricks(t *testing.T) { + for name, repository := range map[string]string{ + "ssh encoded brackets": "ssh://git.example.com/team/other@%5B::1%5D/repo", + "ssh @ in path": "ssh://git.example.com/team/other@evil.example/repo", + "ssh encoded @": "ssh://git@git.example.com/team/other%40evil/repo", + "https encoded slash": "https://git.example.com/team%2F..%2Fother/lib", + "https encoded @": "https://git.example.com/team/other%40evil/lib", + "https encoded [": "https://git.example.com/team/%5B::1%5D/lib", + "https encoded colon": "https://git.example.com/team/a%3Ab", + "https encoded %": "https://git.example.com/team/a%25b", + "https @ in path": "https://git.example.com/team/other@evil.example/lib", + } { + t.Run(name, func(t *testing.T) { + dir := recipeDir(t) + source := gitSource() + source.Repository = repository + _, err := SingleImageBuildPlan(dir, "example/app:v1", RecipeBuildPlatforms(), []string{"Dockerfile"}, WithGitSourceDownloads(source)) + require.ErrorContains(t, err, "git source 0: repository") + + plan, err := SingleImageBuildPlan(dir, "example/app:v1", RecipeBuildPlatforms(), []string{"Dockerfile"}, WithGitSourceDownloads(gitSource())) + require.NoError(t, err) + plan.GetRecipes()[0].GetGitSourceDownloads()[0].Repository = repository + err = VerifyDockerBuildPlan(dir, plan) + require.ErrorContains(t, err, "git source 0: repository") + require.NotContains(t, err.Error(), "example") + }) + } +} + func TestGitSourceMirrorPath(t *testing.T) { path := GitSourceMirrorPath("https://git.example.com/team/lib") require.Regexp(t, regexp.MustCompile(`^[0-9a-f]{64}\.git$`), path) diff --git a/generated/go/codefly/services/builder/v0/docker.pb.go b/generated/go/codefly/services/builder/v0/docker.pb.go index 772752b01..c514a428b 100644 --- a/generated/go/codefly/services/builder/v0/docker.pb.go +++ b/generated/go/codefly/services/builder/v0/docker.pb.go @@ -554,8 +554,9 @@ type GitSourceDownload struct { state protoimpl.MessageState `protogen:"open.v1"` // repository is the URL the build fetches from, exactly as the lockfile // records it: https:// or ssh://, spelled in lowercase, a host, an optional - // port and a path, every character in [A-Za-z0-9._~/@:%+-], with no query, - // fragment, password or token (ssh may name a login user). The Dockerfile + // port and a path, every path character in [A-Za-z0-9._~/:+-] (no "@", no + // percent-encoding), with no query, fragment, password or token (ssh may + // name a login user). The Dockerfile // maps this URL onto the supplied mirror (git's url..insteadOf), so it // must match the lockfile byte for byte. Repository string `protobuf:"bytes,1,opt,name=repository,proto3" json:"repository,omitempty"` diff --git a/proto/codefly/services/builder/v0/docker.proto b/proto/codefly/services/builder/v0/docker.proto index d771e7223..f63e92683 100644 --- a/proto/codefly/services/builder/v0/docker.proto +++ b/proto/codefly/services/builder/v0/docker.proto @@ -114,8 +114,9 @@ message GoModuleDownload { message GitSourceDownload { // repository is the URL the build fetches from, exactly as the lockfile // records it: https:// or ssh://, spelled in lowercase, a host, an optional - // port and a path, every character in [A-Za-z0-9._~/@:%+-], with no query, - // fragment, password or token (ssh may name a login user). The Dockerfile + // port and a path, every path character in [A-Za-z0-9._~/:+-] (no "@", no + // percent-encoding), with no query, fragment, password or token (ssh may + // name a login user). The Dockerfile // maps this URL onto the supplied mirror (git's url..insteadOf), so it // must match the lockfile byte for byte. string repository = 1; From 519efae17f9ecc479ec59edc806051c486051107 Mon Sep 17 00:00:00 2001 From: Saurabh Maheshwari <242053479+saurabhmaheshwari-obin@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:43:32 -0400 Subject: [PATCH 4/5] fix(builder): refuse a git source port outside 1-65535 and an ssh login or host starting with - git cannot fetch either: it rejects the port, and blocks a login or host that would read as an option. Co-Authored-By: Claude Opus 5.5 --- agents/services/docker_recipe.go | 23 +++++++++++++++---- .../docker_recipe_git_sources_test.go | 4 ++++ 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/agents/services/docker_recipe.go b/agents/services/docker_recipe.go index 6eea68a17..ac95cb8ed 100644 --- a/agents/services/docker_recipe.go +++ b/agents/services/docker_recipe.go @@ -12,6 +12,7 @@ import ( "path/filepath" "regexp" "sort" + "strconv" "strings" "github.com/codefly-dev/core/templates" @@ -513,10 +514,10 @@ func validateGoModuleDownloads(recipe *builderv0.DockerBuildRecipe) error { var gitCommitID = regexp.MustCompile(`^([0-9a-f]{40}|[0-9a-f]{64})$`) // gitSourceRepository is the accepted shape of a git source URL: https or ssh, -// spelled exactly so, an optional login name (ssh only, checked below), a host, -// an optional port and a non-empty path with no "@" and no percent-encoding. Every character is one a Dockerfile -// and a git config value carry without quoting, so the URL can be written into -// either verbatim. +// spelled exactly so, an optional login name (ssh only, checked below), a +// host, an optional port and a non-empty path with no "@" and no +// percent-encoding. Every character is one a Dockerfile and a git config value +// carry without quoting, so the URL can be written into either verbatim. var gitSourceRepository = regexp.MustCompile(`^(https|ssh)://(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9.-]+(?::[0-9]{1,5})?/[A-Za-z0-9._~/:+-]*[A-Za-z0-9._~:+-][A-Za-z0-9._~/:+-]*$`) // validateGitSourceDownloads checks a recipe's declared git sources: each names @@ -585,9 +586,21 @@ func gitSourceRepositoryProblem(repository string) string { if !gitSourceRepository.MatchString(repository) { return "must be a host and repository path in [A-Za-z0-9._~/:+-], with an optional port" } - if _, err := url.Parse(repository); err != nil { + parsed, err := url.Parse(repository) + if err != nil { return "is not a valid URL" } + // git refuses an ssh login name or host that starts with "-" (it would read + // as an option) and a port outside 1-65535, so neither is a source a build + // could fetch. + if strings.HasPrefix(parsed.User.Username(), "-") || strings.HasPrefix(parsed.Hostname(), "-") { + return "must not start its login name or host with -" + } + if port := parsed.Port(); port != "" { + if n, err := strconv.Atoi(port); err != nil || n < 1 || n > 65535 { + return "must carry a port between 1 and 65535" + } + } return "" } diff --git a/agents/services/docker_recipe_git_sources_test.go b/agents/services/docker_recipe_git_sources_test.go index 411acf01c..abae72338 100644 --- a/agents/services/docker_recipe_git_sources_test.go +++ b/agents/services/docker_recipe_git_sources_test.go @@ -202,6 +202,10 @@ func TestRecipeGitSourceRepositoryRefusesEncodedAuthorityTricks(t *testing.T) { "https encoded colon": "https://git.example.com/team/a%3Ab", "https encoded %": "https://git.example.com/team/a%25b", "https @ in path": "https://git.example.com/team/other@evil.example/lib", + "ssh port too large": "ssh://git.example.com:65536/team/repo", + "https port zero": "https://git.example.com:0/team/lib", + "ssh login dash": "ssh://-git@git.example.com/team/repo", + "ssh host dash": "ssh://-host.example/team/repo", } { t.Run(name, func(t *testing.T) { dir := recipeDir(t) From abdc7077fb98b8db73b34920a7f4b6e92fb1672c Mon Sep 17 00:00:00 2001 From: Saurabh Maheshwari <242053479+saurabhmaheshwari-obin@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:38:26 -0400 Subject: [PATCH 5/5] feat(builder): export the git source repository and commit checks for agents An agent reading a lockfile can check a source before declaring it, and leave one the contract cannot carry to the build's own fetch instead of failing the plan after rendering. Co-Authored-By: Claude Opus 5.5 --- agents/services/docker_recipe.go | 15 +++++++++++++++ .../docker_recipe_git_sources_test.go | 19 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/agents/services/docker_recipe.go b/agents/services/docker_recipe.go index ac95cb8ed..9cfbc58e1 100644 --- a/agents/services/docker_recipe.go +++ b/agents/services/docker_recipe.go @@ -286,6 +286,21 @@ func GitSourceMirrorRef(commit string) string { return "refs/commit/" + commit } +// GitSourceRepositoryProblem returns why a recipe would refuse repository as a +// git source, or "" when it would accept it. An agent reading a lockfile calls +// it before declaring a source, so it can leave a source the contract cannot +// carry to the build's own fetch, or refuse it naming the package. The reason +// never quotes the URL. +func GitSourceRepositoryProblem(repository string) string { + return gitSourceRepositoryProblem(repository) +} + +// IsGitSourceCommit reports whether commit is a full lowercase hex object id +// (SHA-1 or SHA-256), the only form a recipe accepts. +func IsGitSourceCommit(commit string) bool { + return gitCommitID.MatchString(commit) +} + func SingleImageBuildPlan(outputDirectory, image string, platforms []string, emitted []string, options ...RecipeOption) (*builderv0.DockerBuildPlan, error) { dockerignore := "" for _, name := range emitted { diff --git a/agents/services/docker_recipe_git_sources_test.go b/agents/services/docker_recipe_git_sources_test.go index abae72338..4aa869a12 100644 --- a/agents/services/docker_recipe_git_sources_test.go +++ b/agents/services/docker_recipe_git_sources_test.go @@ -267,3 +267,22 @@ func TestSingleImageBuildPlanDeclaresGitSourceDownloads(t *testing.T) { require.NoError(t, err) require.Equal(t, DockerBuildRecipeGitSourcesContractVersion, resp.GetResult().GetDockerBuildPlan().GetContractVersion()) } + +func TestGitSourceChecksAreExportedForAgents(t *testing.T) { + require.Empty(t, GitSourceRepositoryProblem("https://git.example.com/team/lib")) + require.Empty(t, GitSourceRepositoryProblem("ssh://git@git.example.com:2222/team/lib.git")) + for _, refused := range []string{ + "http://git.example.com/team/lib", + "https://user@git.example.com/team/lib", + "https://git.example.com/team/a%20b", + "ssh://git.example.com/team/other@evil/lib", + } { + reason := GitSourceRepositoryProblem(refused) + require.NotEmpty(t, reason, refused) + require.NotContains(t, reason, "example") + } + require.True(t, IsGitSourceCommit(strings.Repeat("a", 40))) + require.True(t, IsGitSourceCommit(strings.Repeat("b", 64))) + require.False(t, IsGitSourceCommit(strings.Repeat("A", 40))) + require.False(t, IsGitSourceCommit("main")) +}