diff --git a/agents/contract/contract.json b/agents/contract/contract.json
index 37ba73c1..5d291b74 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 057cb575..f968fad3 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/base_builder.go b/agents/services/base_builder.go
index 5cdb0062..9bfce13b 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 feb4e1cb..9cfbc58e 100644
--- a/agents/services/docker_recipe.go
+++ b/agents/services/docker_recipe.go
@@ -6,11 +6,13 @@ import (
"fmt"
"io"
"io/fs"
+ "net/url"
"os"
"path"
"path/filepath"
"regexp"
"sort"
+ "strconv"
"strings"
"github.com/codefly-dev/core/templates"
@@ -44,8 +46,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 +255,52 @@ 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), 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...)
+ }
+}
+
+// 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"
+}
+
+// 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
+}
+
+// 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 {
@@ -416,6 +476,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 +525,100 @@ 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})$`)
+
+// 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.
+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
+// 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. 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 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 %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 %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 git source %d: build context %q is also a Go module proxy context", recipe.GetName(), index, name)
+ }
+ key := [3]string{download.GetRepository(), download.GetCommit(), name}
+ if _, ok := seen[key]; ok {
+ return fmt.Errorf("recipe %q git source %d: declared twice", recipe.GetName(), index)
+ }
+ seen[key] = struct{}{}
+ }
+ return nil
+}
+
+// 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://"
+ }
+ if strings.ContainsAny(repository, "?#") {
+ return "must carry no query or fragment"
+ }
+ // 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"
+ }
+ if strings.Contains(userinfo, ":") {
+ return "must carry no password"
+ }
+ }
+ if !gitSourceRepository.MatchString(repository) {
+ return "must be a host and repository path in [A-Za-z0-9._~/:+-], with an optional port"
+ }
+ 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 ""
+}
+
// 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 +715,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 +740,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 00000000..4aa869a1
--- /dev/null
+++ b/agents/services/docker_recipe_git_sources_test.go
@@ -0,0 +1,288 @@
+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())
+
+ // 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")
+ 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, "also a Go module proxy context")
+}
+
+func TestRecipeGitSourceDownloadsAreValidated(t *testing.T) {
+ dir := recipeDir(t)
+ for name, mutate := range map[string]func(*builderv0.GitSourceDownload){
+ "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 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:2222/team/lib.git", Commit: lockedCommit, 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)
+
+ recipe.GitSourceDownloads = append(recipe.GitSourceDownloads, gitSource())
+ _, err = BuildDockerBuildPlan(dir, []*builderv0.DockerBuildRecipe{recipe})
+ 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")
+ })
+ }
+}
+
+// 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",
+ "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)
+ 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)
+ // 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",
+ } {
+ mirror := GitSourceMirrorPath(repository)
+ require.NotContains(t, seen, mirror, "%s collides with %s", repository, seen[mirror])
+ seen[mirror] = repository
+ }
+}
+
+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"},
+ 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())
+}
+
+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"))
+}
diff --git a/docs/agent-contract.md b/docs/agent-contract.md
index 8bcabb86..40cec725 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 3730467a..c514a428 100644
--- a/generated/go/codefly/services/builder/v0/docker.pb.go
+++ b/generated/go/codefly/services/builder/v0/docker.pb.go
@@ -367,8 +367,19 @@ 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, 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
}
func (x *DockerBuildRecipe) Reset() {
@@ -471,6 +482,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 +549,84 @@ 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: https:// or ssh://, spelled in lowercase, a host, an optional
+ // 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"`
+ // 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/ (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
+}
+
+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 +655,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 +667,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 +680,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 +739,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 +751,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 +764,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 +818,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 +833,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 +883,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 +892,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 +924,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 e7960296..c90fbc7e 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 e0f91886..f63e9268 100644
--- a/proto/codefly/services/builder/v0/docker.proto
+++ b/proto/codefly/services/builder/v0/docker.proto
@@ -84,6 +84,17 @@ 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, 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;
}
// GoModuleDownload is one Go module graph a recipe downloads.
@@ -99,6 +110,30 @@ 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: https:// or ssh://, spelled in lowercase, a host, an optional
+ // 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;
+ // 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/ (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;
+}
+
// 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