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