-
Notifications
You must be signed in to change notification settings - Fork 1.2k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
llbsolver: support pinning sources (same as the "Dockerfile.pin" proposal but agnostic to frontends) #2943
Conversation
3428c93
to
748c5e5
Compare
@tonistiigi Could you check the design of this too 🙏 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this is generally what we want.
Some things to consider though. We need to make sure we can update this format in the future. I see this as a general policy control for sources that the build uses. So this can also be used for user to say that image needs to be signed(also by who?). Or that all images need to be signed etc. Then there are controls like images are only allowed to come from a specific registries. Some mirror control maybe also could belong here. Or that all the sources need to be defined, eg. builder is not allowed to pull any external sources that are not listed in this policy.
The last one may also have two modes: one is images that are actual sources for the build result(eg. the ones in buildinfo), but there are also just images used by a random subbuild in the frontend. Eg. if I'm building Docker and internally Runc's Dockerfile is also built, my policy could be that I want to make sure runc authenticity is validated but what is happening inside the Runc Dockerfile is not something I care about. It could be the opposite thought that I want to lock down everything recursively(eg. go.sum).
Would be good to try to define a format that would satisfy all these cases. Even if we currently don't have the capability to implement all that, we can then make sure that we have a viable path to upgrade without breaking users' security expectations.
I think sending the policy together with the user request is the correct approach, but we do need to think about the cases where a build is being invoked from Git URL directly as well. If the repo defines a policy, then it should be loaded by the Dockerfile frontend from some file definition. The file format itself can be a follow-up but would be good to leave ways for the frontend to load additional policies(unless the user wants to override with theirs of course).
solver/types.go
Outdated
@@ -56,6 +56,9 @@ type VertexOptions struct { | |||
ExportCache *bool | |||
// WorkerConstraint | |||
ProgressGroup *pb.ProgressGroup | |||
// PinFunc takes a source.Identifier as the argument. | |||
// Defined as func(interface{}) to avoid circular imports. | |||
PinFunc func(context.Context, interface{}) error |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think this should be saved in VertexOptions
and then called during the resolve . Multiple builds with same LLB (but different policy) would share the VertexOptions
.
Better would be to actually rewrite the LLB while it is getting loaded into the graph. So if image(alpine)
is converted to image(alpine@sha)
then its vertex digest now also changes and matches another build that called image(alpine@sha)
directly.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Where would be the right place to rewrite the LLB?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Somewhere in
buildkit/solver/llbsolver/vertex.go
Lines 223 to 229 in 6805940
if v, ok := cache[dgst]; ok { | |
return v, nil | |
} | |
op, ok := allOps[dgst] | |
if !ok { | |
return nil, errors.Errorf("invalid missing input digest %s", dgst) | |
} |
*pb.Op
has been rewritten so that the parent that has input with the old digest also gets modified to the new one.
Note that all this conversion/mapping is only specific to the current load closure. Once the LLB is passed to the solver, it doesn't know anymore if it came directly from the user or passed a conversion step.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Updated like this
diff --git a/solver/llbsolver/vertex.go b/solver/llbsolver/vertex.go
index 4f36c2ed..e27158c7 100644
--- a/solver/llbsolver/vertex.go
+++ b/solver/llbsolver/vertex.go
@@ -1,11 +1,13 @@
package llbsolver
import (
+ "context"
"fmt"
"strings"
"github.com/containerd/containerd/platforms"
"github.com/moby/buildkit/solver"
+ "github.com/moby/buildkit/solver/llbsolver/llbmutator"
"github.com/moby/buildkit/solver/pb"
"github.com/moby/buildkit/source"
"github.com/moby/buildkit/util/entitlements"
@@ -143,8 +145,8 @@ func (dpc *detectPrunedCacheID) Load(op *pb.Op, md *pb.OpMetadata, opt *solver.V
return nil
}
-func Load(def *pb.Definition, opts ...LoadOpt) (solver.Edge, error) {
- return loadLLB(def, func(dgst digest.Digest, pbOp *pb.Op, load func(digest.Digest) (solver.Vertex, error)) (solver.Vertex, error) {
+func Load(ctx context.Context, def *pb.Definition, mutator llbmutator.LLBMutator, opts ...LoadOpt) (solver.Edge, error) {
+ return loadLLB(ctx, def, mutator, func(dgst digest.Digest, pbOp *pb.Op, load func(digest.Digest) (solver.Vertex, error)) (solver.Vertex, error) {
opMetadata := def.Metadata[dgst]
vtx, err := newVertex(dgst, pbOp, &opMetadata, load, opts...)
if err != nil {
@@ -187,12 +189,13 @@ func newVertex(dgst digest.Digest, op *pb.Op, opMeta *pb.OpMetadata, load func(d
// loadLLB loads LLB.
// fn is executed sequentially.
-func loadLLB(def *pb.Definition, fn func(digest.Digest, *pb.Op, func(digest.Digest) (solver.Vertex, error)) (solver.Vertex, error)) (solver.Edge, error) {
+func loadLLB(ctx context.Context, def *pb.Definition, mutator llbmutator.LLBMutator, fn func(digest.Digest, *pb.Op, func(digest.Digest) (solver.Vertex, error)) (solver.Vertex, error)) (solver.Edge, error) {
if len(def.Def) == 0 {
return solver.Edge{}, errors.New("invalid empty definition")
}
allOps := make(map[digest.Digest]*pb.Op)
+ mutatedDigests := make(map[digest.Digest]digest.Digest) // key: old, val: new
var dgst digest.Digest
@@ -202,9 +205,33 @@ func loadLLB(def *pb.Definition, fn func(digest.Digest, *pb.Op, func(digest.Dige
return solver.Edge{}, errors.Wrap(err, "failed to parse llb proto op")
}
dgst = digest.FromBytes(dt)
+ if mutator != nil {
+ mutated, err := mutator.Mutate(ctx, &op)
+ if err != nil {
+ return solver.Edge{}, errors.Wrap(err, "failed to call the LLB Mutator")
+ }
+ if mutated {
+ dtMutated, err := op.Marshal()
+ if err != nil {
+ return solver.Edge{}, err
+ }
+ dgstMutated := digest.FromBytes(dtMutated)
+ mutatedDigests[dgst] = dgstMutated
+ dgst = dgstMutated
+ }
+ }
allOps[dgst] = &op
}
+ // Rewrite the input digests if some op was mutated
+ for _, op := range allOps {
+ for _, inp := range op.Inputs {
+ if mutatedDgst, ok := mutatedDigests[inp.Digest]; ok {
+ inp.Digest = mutatedDgst
+ }
+ }
+ }
+
if len(allOps) < 2 {
return solver.Edge{}, errors.Errorf("invalid LLB with %d vertexes", len(allOps))
}
What do we need to do to ensure the extensibility?
Yes, the dockerfile frontend should automatically load |
6704aac
to
5f80a0f
Compare
What happens when client adds new keys to policy but daemon uses the old type. Do we need some versioning here to detect that daemon is actually capable of applying the policy that user sends.
Correct, but note that Frontend does not have access to control API where this new field is exposed atm. It is somewhat tricky actually as currently all the frontend calls that are part of same user build share the same LLBBridge (eg. they can have one policy definition). I think what we want is some kind of sub-bridge model where every frontend gets their own policy for any call that happens by that frontend. And when a frontend calls another frontend then ideally these policies would merge together same way as the user policy and the root frontend policy do. |
re: should pick up this file in the build context This is nice, but also should be able to apply this from outside the build context. |
@cpuguy83 Yes, policy is passed with the build request by the user. So for that case it can come from project-specific definition or from machine defaults or specific definition for a specific run. Then if frontends add their own policies they can limit the policy more (add own conversions) but can't escape the main policy(eg. can't use images from a specific registry if user policy does not allow it). |
api/services/control/control.proto
Outdated
message BuildInfoSource { | ||
string Type = 1; | ||
string Ref = 2; | ||
string Alias = 3; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Alias does not have a definition in here I believe.
I think rather than buildinfo this structure is more similar to the named contexts #2521 feature, except this time implemented in the LLB level and applying to all builds that are part of the same user request.
So it shouldn't be only possible to set the digest(pin) for the same source, but for example map "alpine:latest" to "busybox:latest", image ref from a registry to a local oci-layout://
directory. Replace Git URL with HTTP URL etc. Maybe also replace a source with another LLB definition like linked targets in buildx bake
do (not sure how that would be defined though).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Removed Alias
.
I guess we will add back this as "Replace" later (in another PR)
cc @cpuguy83
Added |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I like the look of this 👀
One potential use for me is that I'd love to be able to attach a public signing key into the SourcePolicySource
, which would then be able to check a signature on the source (with different implementations for git, oci images, files, etc).
The replace directive seems like a good idea to me as well, treating it as the llb-level named contexts seems like a good pattern for it. I would like it if we could make sure that this didn't conflict with the Pin
value, and we should still respect it in that case. So maybe Pin should be optional, with a blank Pin indicating a wildcard? - that would also allow a user to list all of the "allowed sources" without pinning them to exact versions, so we could have a SourcePolicy
that only permitted explicitly listed sources at some point.
sourcepolicy/sourcepolicy.go
Outdated
type SourcePolicy struct { | ||
// Version is the version of the SourcePolicy structure. | ||
// Must be set to 1 currently. | ||
Version int `json:"version"` |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there any reason we couldn't use the capability-style checking that we have for versioning things today? We don't have explicit version numbers in other places I don't think, and capabilities would let us have a fine-grained control over small differences in the API over time.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@tonistiigi WDYT?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is true for API controls. But if we imagine that in the future user would have a way to write a policy into some file, then if client that converts it into this policy object would ignore things it does not understand. Even if the server understands these fields client does not know what caps to request from the daemon.
This could be possibly implemented as user writing a version into a file and the client just converting that version to a CAP_POLICY_VERSION_N
before sending. Then a special field in here is not needed.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This could be possibly implemented as user writing a version into a file
What file?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not defined atm but there needs to be some way user can pass this that is converted to SourcePolicy
struct. Read also the points about frontend integration that would probably also use the same file.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Removed the version field and added CapSourcePolicy
@@ -317,7 +332,7 @@ func (c *Controller) Solve(ctx context.Context, req *controlapi.SolveRequest) (* | |||
Exporter: expi, | |||
CacheExporter: cacheExporter, | |||
CacheExportMode: cacheExportMode, | |||
}, req.Entitlements) | |||
}, req.Entitlements, srcPol) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If the policy is part of controlapi.SolveRequest
, it should probably be passed as part of frontend.SolveRequest
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Decoupled from frontend.SolveRequest
to follow the entitlement model
#2816 (comment)
401e11d
to
e90c674
Compare
|
||
message SourcePolicy { | ||
int64 Version = 1; // Currently 1 | ||
repeated SourcePolicySource Sources = 2; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
message SourcePolicySource {
string Type = 1;
SourcePolicyAction Action = 2;
string SourceIdentifier = 3; // wildcard string
repeated SourceAttrConstraint = 4;
string DestinationIdentifier = 5;
map<string, string> DestinationAttr= 6;
# repeated TrustChain RequiredSignatures = 7;
}
enum SourcePolicyAction {
ALLOW
DENY
CONVERT
}
message SourceAttrConstraint {
string AttrKey
string AttrValue
AttrMatch Condition
}
enum AttrMatch {
EQUAL
NOTEQUAL
MATCHES
}
message SourcePolicy {
int64 Version = 1; // Currently 1
repeated SourcePolicySource Sources = 2;
}
I think this should be quite future compatible. SourceIdentifier
is a wildcard comparison against https://github.com/moby/buildkit/blob/d1b0d8a/solver/pb/ops.proto#L179-L181 . Maybe without the protocol. Type
is separately so that identifier value can be normalized based on it. Attrs can be used to match a specific attribute value.
Policy can allow, deny or convert a source. In case on conversion identifier is replaced and new attribute values can be defined. For example in images this would mean setting identifier to ref@sha256
and for HTTP it would mean setting the http.checksum
attribute.
Every source is checked against all policy conditions. Earlier rule might deny the source, and next may allow it. If source is converted, then the new identifier needs to match the policy again (there needs to be some infinite loop protection).
Some examples:
No images are allowed except if they are busybox latest or any alpine:
[
{"docker-image", "DENY", "*"},
{"docker-image", "ALLOW", "docker.io/library/alpine:*"},
{"docker-image", "ALLOW", "docker.io/library/busybox:latest"},
]
Any Busybox points to Alpine (like a mirror), Alpine:latest is pinned (meaning busybox:latest is pinned as well)?
[
{"docker-image", "CONVERT", "docker.io/library/busybox:*", nil, "docker-image://docker.io/library/alpine:$1"},
{"docker-image", "CONVERT", "docker.io/library/alpine:latest", nil, "docker-image://docker.io/library/alpine:latest@sha256:123456"},
]
(might need a special key that sets the value a regexp?)
If build uses Alpine then use the local OCI-layout version instead
[
{"docker-image", "CONVERT", "docker.io/library/alpine:latest", nil, "oci-layout://contentstoreid@sha256"},
]
@AkihiroSuda WDYT?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks @tonistiigi
SourcePolicyAction
One concern is that this policy structure looks an imperative program (like iptables rules) rather than a declarative structure (like go.mod
and go.sum
).
It might be hard to implement an automated locking tool like go mod tidy
.
map<string, string>= 6;
What is this map for?
int64 Version = 1; // Currently 1
Do we need version? #2943 (comment)
docker-image://docker.io/library/alpine:$1
What is $1
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
One concern is that this policy structure looks an imperative program (like iptables rules) rather than a declarative structure (like go.mod and go.sum).
If the goal is for user to actually define policies, eg. images that are allowed/denied, require certain signatures etc then it can't be done with something like go mod tidy
. If you are only interested of pinning, then you would only have convert rules in your policy and then you can do automatic update based on some conditions etc. Pinning is a subset, but you can use only that if you want to.
The way I would imagine this defined is that you have one policy for your project and one for your whole host. Then they would be applied on top of each other. In the host file you, for example, would likely not put any pins. In the project one, you would likely not deny a specific image but either deny all (undefined) images or define specific signatures that your images require(also something that tidy possibly can't do). Then there are the use-cases where you use conversion rules the same as --build-context
today during dev without a specific policy file.
What is this map for?
New Attrs
for the conversion result, fixed.
Do we need version?
No, it was just carried over.
What is $1?
First match for the wildcard component. Same as regexp. I'm flexible on how this would be actually defined. Eg. in that example busybox:test
would be converted to alpine:test
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@cpuguy83 SGTY?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What do you think of using a oneof
instead of special keys like oci-layout://
? I guess the API has to be appended to for any new format to be supported (maybe not bad for policy like this).
I'm a bit worried about bugs in the policy engine leading to infinite loops or possibly suprising behaviors with the way things are suggested here.
Taking the example:
[
{"docker-image", "DENY", "*"},
{"docker-image", "ALLOW", "docker.io/library/alpine:*"},
{"docker-image", "ALLOW", "docker.io/library/busybox:latest"},
]
I would almost say if someone were to append a CONVERT
rule where the original ref is not already allowed then the image should be denied.
Just trying to think of all the cases here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agree.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we have consensus on this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@cpuguy83 no objections in over a week, so tempted to say that we do have consensus 😄
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Consensus on avoiding OPA/Rego, and considering wasm later.
But still not sure how the buildkit-specific config would look like (to machine and humans).
Is anyone working on design and POC?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
to machine
Anything missing from the proposal on top of this comment?
and humans
I think this shouldn't be a requirement for this PR. I imagine the human format would also for example have policy for pushes that is not part of buildkit solver at all, just validation for the build parameters.
As a follow up I think we also need to change the behavior or |
Alternative to PR 2816 ("dockerfile: support Dockerfile.pin for pinning sources") This version is implemented on the llbsolver side and agnostic to the LLB frontends. See `solver/llbsolver/vertex.go:loadLLB()`. See `docs/build-repro.md` for the usage. Signed-off-by: Akihiro Suda <akihiro.suda.cz@hco.ntt.co.jp>
Just wanted to check where we are with this. Anything missing from the machine config laid out above by @tonistiigi? |
Alternative to
This version is implemented on the llbsolver side and agnostic to the LLB frontends.
See
solver/llbsolver/vertex.go:loadLLB()
.See
docs/build-repro.md
for the usage.In a follow-up PR, we may want to let the dockerfile frontend to load
Dockerfile.pol
in the context automatically. (Needs further discussion)