From 6aa43fce215561997d2eb4641023ebdf4d40a182 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Fri, 23 Oct 2026 13:04:06 -0400 Subject: [PATCH 01/68] run: a native run delivers through codefly.dev/composition when the host implements it The launcher delivered a module's manifest by posting to one host's private routes and writing its envelope through that host's gateway, so a run could deliver to that host and to nothing else. When the run's host service declares, in its module's interface endpoints, that it implements codefly.dev/composition, the launcher now carries the signed manifest and the module's delivery credential to ApplyManifest over the host's endpoint, reads the applied state from the synchronous answer, writes no envelope (the manifest is the ceiling there) and requires no gateway. The native deliverer gains two optional hooks, Post and Applied; the HTTP delivery remains for a host that does not implement the interface. Refusals map onto the statuses the run already classifies. Fix at the owner: the CLI knows Codefly interfaces, never a host's routes. Not released; proven with a local build against codefly-dev/authority. Co-Authored-By: Claude Fable 5.1 --- cmd/run/launcher.go | 62 ++++++++++++-- go.mod | 5 +- go.sum | 10 ++- pkg/gitops/composition_delivery.go | 82 ++++++++++++++++++ pkg/gitops/composition_delivery_test.go | 109 ++++++++++++++++++++++++ pkg/gitops/native_applied.go | 3 + pkg/gitops/native_delivery.go | 10 +++ 7 files changed, 269 insertions(+), 12 deletions(-) create mode 100644 pkg/gitops/composition_delivery.go create mode 100644 pkg/gitops/composition_delivery_test.go diff --git a/cmd/run/launcher.go b/cmd/run/launcher.go index 982f3c51..1559eadc 100644 --- a/cmd/run/launcher.go +++ b/cmd/run/launcher.go @@ -3,6 +3,8 @@ package run import ( "context" "fmt" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" "os" "path/filepath" "slices" @@ -532,6 +534,29 @@ func (cluster *launcher) onRunning(ctx context.Context, unique string) { // state, and the delivery account's credential. It also answers the gateway's // current address, which the envelope follows. func (cluster *launcher) newDeliverer(ctx context.Context) (*gitops.NativeDeliverer, string, error) { + deliveryURL, err := cluster.flow.GetAddressForEndpoint(ctx, cluster.hostModule, cluster.hostService, strings.Split(cluster.env.Host.Delivery, "/")[2]) + if err != nil { + return nil, "", fmt.Errorf("the host's delivery endpoint is unresolved: %w", err) + } + // A host that implements codefly.dev/composition receives manifests through + // ApplyManifest and holds the manifest as the ceiling: no envelope, no + // gateway, nothing of the host known here but the interface it declares. + if cluster.hostImplements(ctx, compositionInterface) { + conn, err := grpc.NewClient(strings.TrimPrefix(strings.TrimPrefix(deliveryURL, "http://"), "grpc://"), grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return nil, "", fmt.Errorf("dial the host's composition interface: %w", err) + } + composition := gitops.NewCompositionDelivery(conn) + return &gitops.NativeDeliverer{ + Workspace: cluster.composed(), Env: cluster.env, Signer: cluster.signer, + StateDir: filepath.Join(cluster.stateDir, "delivery"), DeliveryURL: deliveryURL, + Token: func(module string) (string, error) { + return cluster.vouch.Issue(cluster.vouch.Workload(module, workloadidentity.DeliveryService)) + }, + ObserverToken: func() (string, error) { return cluster.vouch.Issue(cluster.vouch.Observer()) }, + Post: composition.Post, Applied: composition.Applied, + }, "", nil + } gatewayModule, gatewayService, ok := strings.Cut(cluster.env.Host.Gateway, "/") if !ok { return nil, "", fmt.Errorf("a native run writes the envelope through the host gateway, and host.gateway %q names no /", cluster.env.Host.Gateway) @@ -540,10 +565,6 @@ func (cluster *launcher) newDeliverer(ctx context.Context) (*gitops.NativeDelive if err != nil { return nil, "", fmt.Errorf("the host gateway's rest endpoint is unresolved: %w", err) } - deliveryURL, err := cluster.flow.GetAddressForEndpoint(ctx, cluster.hostModule, cluster.hostService, strings.Split(cluster.env.Host.Delivery, "/")[2]) - if err != nil { - return nil, "", fmt.Errorf("the host's delivery endpoint is unresolved: %w", err) - } return &gitops.NativeDeliverer{ Workspace: cluster.composed(), Env: cluster.env, Signer: cluster.signer, StateDir: filepath.Join(cluster.stateDir, "delivery"), DeliveryURL: deliveryURL, @@ -705,8 +726,12 @@ func (cluster *launcher) deliver(ctx context.Context, module string) error { if err != nil { return err } - if err = cluster.ensureEnvelope(ctx, gatewayURL); err != nil { - return err + // A host reached through the composition interface holds the manifest as the + // ceiling and takes no envelope. + if gatewayURL != "" { + if err = cluster.ensureEnvelope(ctx, gatewayURL); err != nil { + return err + } } bundle, err := cluster.frontendBundle(ctx, loaded) if err != nil { @@ -784,3 +809,28 @@ func (cluster *launcher) grants() (map[string]gitops.EnvelopeGrant, error) { } return out, nil } + +// compositionInterface is the Codefly interface a host declares when it receives +// manifests through ApplyManifest. +const compositionInterface = "codefly.dev/composition" + +// hostImplements reports whether the run's host service declares, in its +// module's interface endpoints, that it implements the named Codefly interface at +// any version. +func (cluster *launcher) hostImplements(ctx context.Context, name string) bool { + module, err := cluster.composed().LoadModuleFromName(ctx, cluster.hostModule) + if err != nil || module.Interface == nil { + return false + } + for _, endpoint := range module.Interface.Endpoints { + if endpoint.Service != cluster.hostService { + continue + } + for _, implemented := range endpoint.Implements { + if implemented == name || strings.HasPrefix(implemented, name+"@") { + return true + } + } + } + return false +} diff --git a/go.mod b/go.mod index d4ab0c0c..b20b2cd0 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/asottile/dockerfile v3.1.0+incompatible github.com/blang/semver v3.5.1+incompatible github.com/briandowns/spinner v1.23.2 + github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261023161921-1ee9f1e162cf github.com/codefly-dev/core v0.19.6-0.20261022093222-5522c4dac804 github.com/codefly-dev/golor v0.1.3 github.com/codefly-dev/llm v0.1.7 @@ -237,8 +238,8 @@ require ( golang.org/x/net v0.60.0 // indirect golang.org/x/text v0.42.0 // indirect golang.org/x/time v0.15.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20261019180522-ce908d29299b // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20261012220534-4341edf3f941 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad // indirect diff --git a/go.sum b/go.sum index 346c7db5..34a83ae7 100644 --- a/go.sum +++ b/go.sum @@ -103,6 +103,8 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cloudflare/circl v1.6.5 h1:O64F26HEqNhznd/hrC5KZXVKYuKM2rx4deZDTc4ihQA= github.com/cloudflare/circl v1.6.5/go.mod h1:h5LNyxAc5nTue9DS5jT+48en2PSDYt3zdGnz5OstK6c= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261023161921-1ee9f1e162cf h1:c4CWqybaeEZWGKEeH/TzmUu14DPd+JU/9KPn7jnH2VU= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261023161921-1ee9f1e162cf/go.mod h1:wxXASIpNKX7bzQzd0ZB17Xj2CFlO1ALRHqDaEoER+is= github.com/codefly-dev/core v0.19.6-0.20261022093222-5522c4dac804 h1:JyW6eDXENq/BqNJbPy0De30MpFHoEDBW9Uwc0eR2Prs= github.com/codefly-dev/core v0.19.6-0.20261022093222-5522c4dac804/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/golor v0.1.3 h1:xmo+ceyJFRYZdvpWE2fNd0jeaadp/Ibm1BnganiGKOc= @@ -574,10 +576,10 @@ golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI= golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc h1:rZHRz0ogq4Pid1IKtA/ivHi8XONVF5tqUQXA2TtgpkE= -google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:K/n0XLazCJHjD73YuMyNJsN38BPPy1kfFnKxUXaThFU= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc h1:4bNTbnb44EqGVy9HaQxSY2jnafSUdgV3qHtedvNpDKg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= +google.golang.org/genproto/googleapis/api v0.0.0-20261019180522-ce908d29299b h1:nB3spRIaSPtxJpxb0k41yljROvm2Jrwfs2pSNdKS5y8= +google.golang.org/genproto/googleapis/api v0.0.0-20261019180522-ce908d29299b/go.mod h1:9N4vtjEwvgQKEdlFVQO0M44ErhecQ9DCSQfGoZ51M6w= +google.golang.org/genproto/googleapis/rpc v0.0.0-20261012220534-4341edf3f941 h1:QG6hS4zFplRZvOQedzei4t2pT/zkQc3VR1u+S9/rcAw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20261012220534-4341edf3f941/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0= google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= diff --git a/pkg/gitops/composition_delivery.go b/pkg/gitops/composition_delivery.go new file mode 100644 index 00000000..612f5d87 --- /dev/null +++ b/pkg/gitops/composition_delivery.go @@ -0,0 +1,82 @@ +package gitops + +import ( + "context" + "fmt" + "net/http" + "sync" + + "github.com/codefly-dev/core/solutionhost" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + compositionv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/composition/v1" +) + +// CompositionDelivery delivers through codefly.dev/composition: the signed +// carrier and the module's delivery credential go to ApplyManifest, which +// answers synchronously with the generation the host holds. The run then knows +// nothing of the host but the interface it declared. +type CompositionDelivery struct { + client compositionv1.CompositionServiceClient + + mu sync.Mutex + applied map[string]uint64 +} + +// NewCompositionDelivery speaks the interface over conn. +func NewCompositionDelivery(conn grpc.ClientConnInterface) *CompositionDelivery { + return &CompositionDelivery{client: compositionv1.NewCompositionServiceClient(conn), applied: map[string]uint64{}} +} + +// Post is NativeDeliverer.Post over the interface. +func (c *CompositionDelivery) Post(ctx context.Context, module string, signed *solutionhost.Signed, token string) (Receipt, error) { + out, err := c.client.ApplyManifest(ctx, &compositionv1.ApplyManifestRequest{ + Carrier: &compositionv1.SignedManifestV1{Document: signed.Document, Envelope: signed.Envelope}, + Credential: token, + }) + if err != nil { + return Receipt{Status: httpStatusOf(err), Error: err.Error()}, &HostStatusError{Status: httpStatusOf(err), + Message: fmt.Sprintf("the host refused the module manifest of %s through the composition interface: %v", module, err)} + } + manifest, err := solutionhost.ManifestFromVerified(signed.Document) + if err != nil { + return Receipt{}, err + } + c.mu.Lock() + c.applied[manifest.Binding] = out.GetGeneration() + c.mu.Unlock() + return Receipt{Status: http.StatusOK, Disposition: "accepted", Generation: out.GetGeneration()}, nil +} + +// Applied is NativeDeliverer.Applied over the interface: ApplyManifest applies +// synchronously, so what it answered is what the host holds. +func (c *CompositionDelivery) Applied(_ context.Context, binding string) (*appliedManifest, bool, error) { + c.mu.Lock() + defer c.mu.Unlock() + generation, ok := c.applied[binding] + if !ok { + return nil, false, nil + } + return &appliedManifest{Binding: binding, Generation: generation}, true, nil +} + +// httpStatusOf maps a refusal's code onto the status the run's classification +// reads: a lower generation and a refused carrier are final, an unreachable host +// is transient. +func httpStatusOf(err error) int { + switch status.Code(err) { + case codes.FailedPrecondition: + return http.StatusConflict + case codes.NotFound: + return http.StatusNotFound + case codes.PermissionDenied, codes.Unauthenticated: + return http.StatusForbidden + case codes.InvalidArgument: + return http.StatusBadRequest + case codes.Unavailable, codes.DeadlineExceeded: + return http.StatusServiceUnavailable + } + return http.StatusInternalServerError +} diff --git a/pkg/gitops/composition_delivery_test.go b/pkg/gitops/composition_delivery_test.go new file mode 100644 index 00000000..455ec919 --- /dev/null +++ b/pkg/gitops/composition_delivery_test.go @@ -0,0 +1,109 @@ +package gitops + +import ( + "context" + "net" + "net/http" + "testing" + + "github.com/codefly-dev/core/solutionhost" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" + + compositionv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/composition/v1" +) + +// compositionStub is a host that implements codefly.dev/composition: it applies +// a manifest it has not seen at a higher generation and refuses a lower one by +// name, as the suite requires of every implementation. +type compositionStub struct { + compositionv1.UnimplementedCompositionServiceServer + held map[string]uint64 + seen []*compositionv1.ApplyManifestRequest +} + +func (s *compositionStub) ApplyManifest(_ context.Context, req *compositionv1.ApplyManifestRequest) (*compositionv1.ApplyManifestResponse, error) { + s.seen = append(s.seen, req) + if req.GetCredential() == "" { + return nil, status.Error(codes.PermissionDenied, "no delivery credential") + } + manifest, err := solutionhost.ManifestFromVerified(req.GetCarrier().GetDocument()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if manifest.Generation <= s.held[manifest.Binding] { + return nil, status.Errorf(codes.FailedPrecondition, "generation %d does not move %s forward", manifest.Generation, manifest.Binding) + } + s.held[manifest.Binding] = manifest.Generation + return &compositionv1.ApplyManifestResponse{Principal: manifest.Alias, Generation: manifest.Generation}, nil +} + +func TestCompositionDeliveryCarriesTheSignedManifestThroughApplyManifest(t *testing.T) { + stub := &compositionStub{held: map[string]uint64{}} + server := grpc.NewServer() + compositionv1.RegisterCompositionServiceServer(server, stub) + listener := bufconn.Listen(1 << 20) + go func() { _ = server.Serve(listener) }() + t.Cleanup(server.Stop) + conn, err := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { return listener.Dial() }), + grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + t.Fatal(err) + } + delivery := NewCompositionDelivery(conn) + manifest := &solutionhost.ModuleManifest{ + Schema: solutionhost.SchemaModuleManifestV1, Binding: "probe", Alias: "probe", Kind: solutionhost.KindModule, Generation: 2, + Host: solutionhost.HostTarget{Coordinate: "local", Domain: "local"}, Release: solutionhost.Release{Publisher: "test", Name: "probe", Version: "0.1.0"}, + Workloads: []solutionhost.Workload{{Service: "api", Container: "app"}}, Authority: &solutionhost.Authority{Principal: "probe"}, + } + document, err := solutionhost.SignedPayload(manifest) + if err != nil { + t.Fatal(err) + } + signed := &solutionhost.Signed{Document: document, Envelope: []byte(`{"envelope":"the test host does not verify"}`)} + ctx := context.Background() + receipt, err := delivery.Post(ctx, "probe", signed, "delivery-credential") + if err != nil || receipt.Status != http.StatusOK || receipt.Generation != 2 || receipt.Disposition != "accepted" { + t.Fatalf("post: %+v %v", receipt, err) + } + if stub.seen[0].GetCredential() != "delivery-credential" || string(stub.seen[0].GetCarrier().GetDocument()) != string(document) { + t.Fatal("the carrier and the credential reach the host as sent") + } + applied, found, err := delivery.Applied(ctx, "probe") + if err != nil || !found || applied.Generation != 2 { + t.Fatalf("applied: %+v %v %v", applied, found, err) + } + if _, found, _ := delivery.Applied(ctx, "never-delivered"); found { + t.Fatal("an undelivered binding is not applied") + } + manifest.Generation = 1 + document, _ = solutionhost.SignedPayload(manifest) + _, err = delivery.Post(ctx, "probe", &solutionhost.Signed{Document: document, Envelope: signed.Envelope}, "delivery-credential") + var refusal *HostStatusError + if !asHostStatus(err, &refusal) || refusal.Status != http.StatusConflict { + t.Fatalf("a lower generation is a final refusal (409): %v", err) + } + _, err = delivery.Post(ctx, "probe", signed, "") + if !asHostStatus(err, &refusal) || refusal.Status != http.StatusForbidden { + t.Fatalf("no credential is forbidden (403): %v", err) + } +} + +func asHostStatus(err error, target **HostStatusError) bool { + for err != nil { + if e, ok := err.(*HostStatusError); ok { + *target = e + return true + } + u, ok := err.(interface{ Unwrap() error }) + if !ok { + return false + } + err = u.Unwrap() + } + return false +} diff --git a/pkg/gitops/native_applied.go b/pkg/gitops/native_applied.go index 8b2fa512..0d8dd6b0 100644 --- a/pkg/gitops/native_applied.go +++ b/pkg/gitops/native_applied.go @@ -104,6 +104,9 @@ func (d *NativeDeliverer) WaitForApplied(ctx context.Context, manifest *solution // appliedState reads the host's applied record of binding; found is false on 404. func (d *NativeDeliverer) appliedState(ctx context.Context, binding string) (*appliedManifest, bool, error) { + if d.Applied != nil { + return d.Applied(ctx, binding) + } token, err := d.ObserverToken() if err != nil { return nil, false, err diff --git a/pkg/gitops/native_delivery.go b/pkg/gitops/native_delivery.go index 61ed1a12..e83abe22 100644 --- a/pkg/gitops/native_delivery.go +++ b/pkg/gitops/native_delivery.go @@ -106,6 +106,13 @@ type NativeDeliverer struct { // ObserverToken belongs to the separate read-only applied-state publisher. ObserverToken func() (string, error) Client *http.Client + + // Post, when set, carries a signed manifest to the host instead of the HTTP + // delivery route: a host that implements codefly.dev/composition receives it + // through ApplyManifest. Applied, when set, answers the applied state the + // same way. Both nil is the saas HTTP delivery, the transition's. + Post func(ctx context.Context, module string, signed *solutionhost.Signed, token string) (Receipt, error) + Applied func(ctx context.Context, binding string) (*appliedManifest, bool, error) } // Render builds the manifest of a module this run started identity workloads @@ -285,6 +292,9 @@ func (d *NativeDeliverer) post(ctx context.Context, module string, payload []byt if err != nil { return Receipt{}, err } + if d.Post != nil { + return d.Post(ctx, module, signed, token) + } request, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(d.DeliveryURL, "/")+manifestDeliveryPath, bytes.NewReader(body)) if err != nil { return Receipt{}, err From 13f7a3e3ccf77d4ba6d4f7517cdbebe705065886 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Fri, 23 Oct 2026 15:34:16 -0400 Subject: [PATCH 02/68] run: the delivery carries the authority's signed method policies With each manifest delivered through codefly.dev/composition, the run now delivers the authority's own document beside it: every method the module's published protobuf contracts serve, with what each declares with the authority.hop.v1.policy option, read from the module package (each contract refused unless its bytes are the digest the package names). The document names the core manifest's module, generation and digest, and is signed in the same envelope format by the run's release key, through the authority's delivery library. A module with no package declares no method policy and sends none. The CLI learns no host route and no policy vocabulary of its own: it reads the authority's option and speaks the authority's composition interface, both from codefly-dev/authority at bc4d129 (authority client, delivery and hop libraries). Proven live with codefly-dev/authority's console: the delivery is applied, the authority's Check admits Maya's WhoAmI and Version through the hop from the delivered policy, answers an unserved method NotFound, and refuses a method check for a module no signed document described. Co-Authored-By: Claude Opus 5.5 --- cmd/run/launcher.go | 11 +- go.mod | 4 +- go.sum | 8 +- pkg/gitops/composition_delivery.go | 58 +++++++-- pkg/gitops/composition_delivery_test.go | 5 +- pkg/gitops/method_policies.go | 93 +++++++++++++ pkg/gitops/method_policies_test.go | 165 ++++++++++++++++++++++++ 7 files changed, 330 insertions(+), 14 deletions(-) create mode 100644 pkg/gitops/method_policies.go create mode 100644 pkg/gitops/method_policies_test.go diff --git a/cmd/run/launcher.go b/cmd/run/launcher.go index 1559eadc..b99243a5 100644 --- a/cmd/run/launcher.go +++ b/cmd/run/launcher.go @@ -5,6 +5,8 @@ import ( "fmt" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" + + compositionv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/composition/v1" "os" "path/filepath" "slices" @@ -546,7 +548,14 @@ func (cluster *launcher) newDeliverer(ctx context.Context) (*gitops.NativeDelive if err != nil { return nil, "", fmt.Errorf("dial the host's composition interface: %w", err) } - composition := gitops.NewCompositionDelivery(conn) + composition := gitops.NewCompositionDelivery(conn, cluster.signer, + func(ctx context.Context, module string) ([]*compositionv1.MethodPolicyV1, bool, error) { + m, err := cluster.composed().LoadModuleFromName(ctx, module) + if err != nil { + return nil, false, err + } + return gitops.MethodPolicies(m.Dir()) + }) return &gitops.NativeDeliverer{ Workspace: cluster.composed(), Env: cluster.env, Signer: cluster.signer, StateDir: filepath.Join(cluster.stateDir, "delivery"), DeliveryURL: deliveryURL, diff --git a/go.mod b/go.mod index b20b2cd0..7af0d180 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,9 @@ require ( github.com/asottile/dockerfile v3.1.0+incompatible github.com/blang/semver v3.5.1+incompatible github.com/briandowns/spinner v1.23.2 - github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261023161921-1ee9f1e162cf + github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261023192826-bc4d12956a06 + github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261023192826-bc4d12956a06 + github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261023192826-bc4d12956a06 github.com/codefly-dev/core v0.19.6-0.20261022093222-5522c4dac804 github.com/codefly-dev/golor v0.1.3 github.com/codefly-dev/llm v0.1.7 diff --git a/go.sum b/go.sum index 34a83ae7..f8081e50 100644 --- a/go.sum +++ b/go.sum @@ -103,8 +103,12 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cloudflare/circl v1.6.5 h1:O64F26HEqNhznd/hrC5KZXVKYuKM2rx4deZDTc4ihQA= github.com/cloudflare/circl v1.6.5/go.mod h1:h5LNyxAc5nTue9DS5jT+48en2PSDYt3zdGnz5OstK6c= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261023161921-1ee9f1e162cf h1:c4CWqybaeEZWGKEeH/TzmUu14DPd+JU/9KPn7jnH2VU= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261023161921-1ee9f1e162cf/go.mod h1:wxXASIpNKX7bzQzd0ZB17Xj2CFlO1ALRHqDaEoER+is= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261023192826-bc4d12956a06 h1:GmhlPnIro0S7Us5UN1Iq3k54gI3+qzQrodeNS2YYtUo= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261023192826-bc4d12956a06/go.mod h1:wxXASIpNKX7bzQzd0ZB17Xj2CFlO1ALRHqDaEoER+is= +github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261023192826-bc4d12956a06 h1:7nIq/fdGoHp/70sSialNIrRWCvx6YCvzYPwKjleYefE= +github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261023192826-bc4d12956a06/go.mod h1:XsOFnO2MQdr37vM3ULa0Uwi8ZWptyVx7hRSUyIdaZ1Q= +github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261023192826-bc4d12956a06 h1:Gh531vlcnmPL41VurXGoVyn5Oyf5uJWH9j+wuvoPKAg= +github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261023192826-bc4d12956a06/go.mod h1:wPunnn+I0MM36rLqhRiCbIB/bqIypVA9+YjfQRHmgqY= github.com/codefly-dev/core v0.19.6-0.20261022093222-5522c4dac804 h1:JyW6eDXENq/BqNJbPy0De30MpFHoEDBW9Uwc0eR2Prs= github.com/codefly-dev/core v0.19.6-0.20261022093222-5522c4dac804/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/golor v0.1.3 h1:xmo+ceyJFRYZdvpWE2fNd0jeaadp/Ibm1BnganiGKOc= diff --git a/pkg/gitops/composition_delivery.go b/pkg/gitops/composition_delivery.go index 612f5d87..0dfbd497 100644 --- a/pkg/gitops/composition_delivery.go +++ b/pkg/gitops/composition_delivery.go @@ -10,6 +10,9 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" + + delivery "github.com/codefly-dev/authority/libraries/delivery/go" compositionv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/composition/v1" ) @@ -20,36 +23,73 @@ import ( // nothing of the host but the interface it declared. type CompositionDelivery struct { client compositionv1.CompositionServiceClient + // signer is the run's release key, which signs the core manifest too. + signer solutionhost.Signer + // methods reads what each method of a module declares, from its package; + // ok false means the module declares no method policy. + methods func(ctx context.Context, module string) ([]*compositionv1.MethodPolicyV1, bool, error) mu sync.Mutex applied map[string]uint64 } -// NewCompositionDelivery speaks the interface over conn. -func NewCompositionDelivery(conn grpc.ClientConnInterface) *CompositionDelivery { - return &CompositionDelivery{client: compositionv1.NewCompositionServiceClient(conn), applied: map[string]uint64{}} +// NewCompositionDelivery speaks the interface over conn. With each manifest it +// delivers the authority's own document, signed by signer: what each method of +// the module declares, read by methods from the module's package. +func NewCompositionDelivery(conn grpc.ClientConnInterface, signer solutionhost.Signer, + methods func(ctx context.Context, module string) ([]*compositionv1.MethodPolicyV1, bool, error)) *CompositionDelivery { + return &CompositionDelivery{client: compositionv1.NewCompositionServiceClient(conn), signer: signer, methods: methods, applied: map[string]uint64{}} } // Post is NativeDeliverer.Post over the interface. func (c *CompositionDelivery) Post(ctx context.Context, module string, signed *solutionhost.Signed, token string) (Receipt, error) { - out, err := c.client.ApplyManifest(ctx, &compositionv1.ApplyManifestRequest{ + manifest, err := solutionhost.ManifestFromVerified(signed.Document) + if err != nil { + return Receipt{}, err + } + request := &compositionv1.ApplyManifestRequest{ Carrier: &compositionv1.SignedManifestV1{Document: signed.Document, Envelope: signed.Envelope}, Credential: token, - }) + } + if request.Authority, err = c.authority(ctx, module, manifest, signed.Document); err != nil { + return Receipt{}, err + } + out, err := c.client.ApplyManifest(ctx, request) if err != nil { return Receipt{Status: httpStatusOf(err), Error: err.Error()}, &HostStatusError{Status: httpStatusOf(err), Message: fmt.Sprintf("the host refused the module manifest of %s through the composition interface: %v", module, err)} } - manifest, err := solutionhost.ManifestFromVerified(signed.Document) - if err != nil { - return Receipt{}, err - } c.mu.Lock() c.applied[manifest.Binding] = out.GetGeneration() c.mu.Unlock() return Receipt{Status: http.StatusOK, Disposition: "accepted", Generation: out.GetGeneration()}, nil } +// authority signs the authority's document for one delivery: the module's +// method policies, bound to the core manifest by its module, generation and +// digest, under the key that signs the manifest. Nil when the module declares +// no method policy. +func (c *CompositionDelivery) authority(ctx context.Context, module string, manifest *solutionhost.ModuleManifest, core []byte) (*compositionv1.SignedManifestV1, error) { + if c.methods == nil || c.signer == nil { + return nil, nil + } + methods, ok, err := c.methods(ctx, module) + if err != nil || !ok { + return nil, err + } + document, err := proto.MarshalOptions{Deterministic: true}.Marshal(&compositionv1.AuthorityManifestV1{ + Module: manifest.Alias, Generation: manifest.Generation, CoreManifestDigest: delivery.Digest(core), Methods: methods, + }) + if err != nil { + return nil, err + } + envelope, err := delivery.Sign(document, c.signer.KeyID(), c.signer.Sign) + if err != nil { + return nil, err + } + return &compositionv1.SignedManifestV1{Document: document, Envelope: envelope}, nil +} + // Applied is NativeDeliverer.Applied over the interface: ApplyManifest applies // synchronously, so what it answered is what the host holds. func (c *CompositionDelivery) Applied(_ context.Context, binding string) (*appliedManifest, bool, error) { diff --git a/pkg/gitops/composition_delivery_test.go b/pkg/gitops/composition_delivery_test.go index 455ec919..9e6419eb 100644 --- a/pkg/gitops/composition_delivery_test.go +++ b/pkg/gitops/composition_delivery_test.go @@ -54,7 +54,7 @@ func TestCompositionDeliveryCarriesTheSignedManifestThroughApplyManifest(t *test if err != nil { t.Fatal(err) } - delivery := NewCompositionDelivery(conn) + delivery := NewCompositionDelivery(conn, nil, nil) manifest := &solutionhost.ModuleManifest{ Schema: solutionhost.SchemaModuleManifestV1, Binding: "probe", Alias: "probe", Kind: solutionhost.KindModule, Generation: 2, Host: solutionhost.HostTarget{Coordinate: "local", Domain: "local"}, Release: solutionhost.Release{Publisher: "test", Name: "probe", Version: "0.1.0"}, @@ -73,6 +73,9 @@ func TestCompositionDeliveryCarriesTheSignedManifestThroughApplyManifest(t *test if stub.seen[0].GetCredential() != "delivery-credential" || string(stub.seen[0].GetCarrier().GetDocument()) != string(document) { t.Fatal("the carrier and the credential reach the host as sent") } + if stub.seen[0].GetAuthority() != nil { + t.Fatal("with no method source, no authority document is sent") + } applied, found, err := delivery.Applied(ctx, "probe") if err != nil || !found || applied.Generation != 2 { t.Fatalf("applied: %+v %v %v", applied, found, err) diff --git a/pkg/gitops/method_policies.go b/pkg/gitops/method_policies.go new file mode 100644 index 00000000..1563b220 --- /dev/null +++ b/pkg/gitops/method_policies.go @@ -0,0 +1,93 @@ +package gitops + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "sort" + + "github.com/codefly-dev/core/composition" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoregistry" + "google.golang.org/protobuf/types/descriptorpb" + + authorityv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/authority/v1" + compositionv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/composition/v1" + hopv1 "github.com/codefly-dev/authority/libraries/hop/go/gen/authority/hop/v1" +) + +// MethodPolicies reads what each method a module's published protobuf contracts +// serve declares with the authority's policy option, from the module package +// at dir. Every served method is listed, a method with no mark with neither +// field set, so the authority tells "declares nothing" from "not served". +// A module with no package declares no method policy: ok is false. +func MethodPolicies(dir string) (methods []*compositionv1.MethodPolicyV1, ok bool, err error) { + if _, err := os.Stat(filepath.Join(dir, composition.PackageManifestFileName)); os.IsNotExist(err) { + return nil, false, nil + } + manifest, err := composition.LoadPackageManifest(dir) + if err != nil { + return nil, false, err + } + seen := map[string]bool{} + for _, service := range manifest.Services { + for _, contract := range service.APIContracts { + if contract.Kind != "protobuf" { + continue + } + served, err := contractMethods(filepath.Join(dir, contract.Path), contract.Digest) + if err != nil { + return nil, false, fmt.Errorf("module package %s, service %s: %w", manifest.ID, service.Name, err) + } + for _, m := range served { + if !seen[m.GetMethod()] { + seen[m.GetMethod()] = true + methods = append(methods, m) + } + } + } + } + sort.Slice(methods, func(i, j int) bool { return methods[i].GetMethod() < methods[j].GetMethod() }) + return methods, true, nil +} + +// contractMethods reads one published contract, refusing one whose bytes are +// not the digest the package names. +func contractMethods(path, digest string) ([]*compositionv1.MethodPolicyV1, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + sum := sha256.Sum256(data) + if got := "sha256:" + hex.EncodeToString(sum[:]); got != digest { + return nil, fmt.Errorf("contract %s is %s, the package names %s; run codefly generate contracts", path, got, digest) + } + var set descriptorpb.FileDescriptorSet + // Decoded against the registered types, so the policy option reads as a + // message rather than unknown bytes. + if err := (proto.UnmarshalOptions{Resolver: protoregistry.GlobalTypes}).Unmarshal(data, &set); err != nil { + return nil, fmt.Errorf("contract %s does not decode: %w", path, err) + } + var out []*compositionv1.MethodPolicyV1 + for _, file := range set.GetFile() { + for _, service := range file.GetService() { + for _, method := range service.GetMethod() { + m := &compositionv1.MethodPolicyV1{Method: "/" + file.GetPackage() + "." + service.GetName() + "/" + method.GetName()} + if file.GetPackage() == "" { + m.Method = "/" + service.GetName() + "/" + method.GetName() + } + if options := method.GetOptions(); options != nil && proto.HasExtension(options, hopv1.E_Policy) { + policy, _ := proto.GetExtension(options, hopv1.E_Policy).(*hopv1.Policy) + m.AnyCheckedCaller = policy.GetAnyCheckedCaller() + for _, r := range policy.GetRequires() { + m.Requires = append(m.Requires, &authorityv1.WorkScopeV1{ResourceKind: r.GetResourceKind(), Actions: r.GetActions()}) + } + } + out = append(out, m) + } + } + } + return out, nil +} diff --git a/pkg/gitops/method_policies_test.go b/pkg/gitops/method_policies_test.go new file mode 100644 index 00000000..ba10ea82 --- /dev/null +++ b/pkg/gitops/method_policies_test.go @@ -0,0 +1,165 @@ +package gitops + +import ( + "context" + "crypto" + "crypto/ed25519" + "crypto/sha256" + "encoding/hex" + "net" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/codefly-dev/core/solutionhost" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/test/bufconn" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/descriptorpb" + + authorityv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/authority/v1" + compositionv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/composition/v1" + delivery "github.com/codefly-dev/authority/libraries/delivery/go" + hopv1 "github.com/codefly-dev/authority/libraries/hop/go/gen/authority/hop/v1" +) + +// policyPackage writes a module package whose one protobuf contract serves +// WhoAmI (requires console.identity read), Version (any checked caller) and +// Plain (no mark), and returns its directory. +func policyPackage(t *testing.T, tamper bool) string { + t.Helper() + service := &descriptorpb.ServiceDescriptorProto{Name: proto.String("ApiService")} + marks := map[string]*hopv1.Policy{ + "WhoAmI": {Requires: []*hopv1.Requirement{{ResourceKind: "console.identity", Actions: []string{"read"}}}}, + "Version": {AnyCheckedCaller: true}, + "Plain": nil, + } + for _, name := range []string{"WhoAmI", "Version", "Plain"} { + m := &descriptorpb.MethodDescriptorProto{Name: proto.String(name), InputType: proto.String(".api.Empty"), OutputType: proto.String(".api.Empty")} + if marks[name] != nil { + m.Options = &descriptorpb.MethodOptions{} + proto.SetExtension(m.Options, hopv1.E_Policy, marks[name]) + } + service.Method = append(service.Method, m) + } + set, err := proto.Marshal(&descriptorpb.FileDescriptorSet{File: []*descriptorpb.FileDescriptorProto{{ + Name: proto.String("api.proto"), Package: proto.String("api"), Syntax: proto.String("proto3"), + MessageType: []*descriptorpb.DescriptorProto{{Name: proto.String("Empty")}}, Service: []*descriptorpb.ServiceDescriptorProto{service}, + }}}) + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + contract := filepath.Join(dir, "contracts", "api", "api", "grpc", "contract.binpb") + for _, root := range []string{filepath.Dir(contract), filepath.Join(dir, "services")} { + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(contract, set, 0o644); err != nil { + t.Fatal(err) + } + if tamper { + set = append(set, 0) + } + sum := sha256.Sum256(set) + pkg := `schema: codefly/module-package/v2 +kind: module-package +id: codefly/console +version: 0.1.0 +minimum-codefly-version: ">=0.3.32 <1.0.0" +artifact-roots: + - contracts + - services +services: + - name: api + endpoints: [grpc] + api-contracts: + - endpoint: grpc + kind: protobuf + package: api + path: contracts/api/api/grpc/contract.binpb + digest: sha256:` + hex.EncodeToString(sum[:]) + ` +contracts: + composition: ">=2.0.0 <3.0.0" +` + if err := os.WriteFile(filepath.Join(dir, "module.package.codefly.yaml"), []byte(pkg), 0o644); err != nil { + t.Fatal(err) + } + return dir +} + +func TestMethodPoliciesAreReadFromThePublishedContract(t *testing.T) { + methods, ok, err := MethodPolicies(policyPackage(t, false)) + if err != nil || !ok || len(methods) != 3 { + t.Fatalf("methods: %v %v %v", methods, ok, err) + } + byName := map[string]*compositionv1.MethodPolicyV1{} + for _, m := range methods { + byName[m.GetMethod()] = m + } + if r := byName["/api.ApiService/WhoAmI"].GetRequires(); len(r) != 1 || r[0].GetResourceKind() != "console.identity" || r[0].GetActions()[0] != "read" { + t.Fatalf("WhoAmI: %v", byName["/api.ApiService/WhoAmI"]) + } + if !byName["/api.ApiService/Version"].GetAnyCheckedCaller() { + t.Fatal("Version admits any checked caller") + } + if p := byName["/api.ApiService/Plain"]; p == nil || p.GetAnyCheckedCaller() || len(p.GetRequires()) != 0 { + t.Fatalf("an unmarked method is listed with nothing set: %v", p) + } + if _, _, err := MethodPolicies(policyPackage(t, true)); err == nil || !strings.Contains(err.Error(), "the package names") { + t.Fatalf("a contract that is not its digest: %v", err) + } + if _, ok, err := MethodPolicies(t.TempDir()); ok || err != nil { + t.Fatalf("a module with no package declares no method policy: %v %v", ok, err) + } +} + +func TestTheAuthorityDocumentIsSignedAndBoundToItsManifest(t *testing.T) { + stub := &compositionStub{held: map[string]uint64{}} + server := grpc.NewServer() + compositionv1.RegisterCompositionServiceServer(server, stub) + listener := bufconn.Listen(1 << 20) + go func() { _ = server.Serve(listener) }() + t.Cleanup(server.Stop) + conn, err := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { return listener.Dial() }), + grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + t.Fatal(err) + } + public, private, _ := ed25519.GenerateKey(nil) + signer, err := solutionhost.NewInMemorySigner("release", private) + if err != nil { + t.Fatal(err) + } + want := []*compositionv1.MethodPolicyV1{{Method: "/api.ApiService/WhoAmI", Requires: []*authorityv1.WorkScopeV1{{ResourceKind: "console.identity", Actions: []string{"read"}}}}} + d := NewCompositionDelivery(conn, signer, func(_ context.Context, module string) ([]*compositionv1.MethodPolicyV1, bool, error) { + return want, module == "console", nil + }) + manifest := &solutionhost.ModuleManifest{ + Schema: solutionhost.SchemaModuleManifestV1, Binding: "console", Alias: "console", Kind: solutionhost.KindModule, Generation: 3, + Host: solutionhost.HostTarget{Coordinate: "local", Domain: "local"}, Release: solutionhost.Release{Publisher: "test", Name: "console", Version: "0.1.0"}, + Workloads: []solutionhost.Workload{{Service: "api", Container: "app"}}, Authority: &solutionhost.Authority{Principal: "console"}, + } + document, err := solutionhost.SignedPayload(manifest) + if err != nil { + t.Fatal(err) + } + if _, err := d.Post(context.Background(), "console", &solutionhost.Signed{Document: document, Envelope: []byte(`{}`)}, "delivery-credential"); err != nil { + t.Fatal(err) + } + authority := stub.seen[0].GetAuthority() + if _, err := delivery.Verify(authority.GetDocument(), authority.GetEnvelope(), map[string]crypto.PublicKey{"release": public}); err != nil { + t.Fatalf("the document is signed by the run's key: %v", err) + } + var doc compositionv1.AuthorityManifestV1 + if err := proto.Unmarshal(authority.GetDocument(), &doc); err != nil { + t.Fatal(err) + } + if doc.GetModule() != "console" || doc.GetGeneration() != 3 || doc.GetCoreManifestDigest() != delivery.Digest(document) || !proto.Equal(doc.GetMethods()[0], want[0]) { + t.Fatalf("the document names its manifest and carries the methods: %v", &doc) + } +} From b24ed0dd96946bd5b8e4b90b6fa2823e1fd127e0 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Fri, 23 Oct 2026 20:37:35 -0400 Subject: [PATCH 03/68] fix(run): an excluded workspace configuration group is not rendered either --exclude-workspace-configuration trimmed a group from resolution but not from the groups the CLI renders itself for a native run (module-authority). A run excluding module-authority still rendered it, and refused to start when the environment's host names no gateway, which is every run against the authority interface rather than the saas host. The render now sees the service's declared groups less the excluded ones; the declared service is not mutated. Test: an excluded group is not handed to the renderer; a run excluding nothing passes the service through. Co-Authored-By: Claude Opus 5.5 --- .../excluded_owned_groups_test.go | 26 +++++++++++++++++++ pkg/orchestration/workspace_configurations.go | 25 +++++++++++++++++- 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 pkg/orchestration/excluded_owned_groups_test.go diff --git a/pkg/orchestration/excluded_owned_groups_test.go b/pkg/orchestration/excluded_owned_groups_test.go new file mode 100644 index 00000000..84d6453a --- /dev/null +++ b/pkg/orchestration/excluded_owned_groups_test.go @@ -0,0 +1,26 @@ +package orchestration + +import ( + "slices" + "testing" + + "github.com/codefly-dev/core/resources" +) + +// A group the run excludes is not handed to the render-owned renderer either: +// an excluded module-authority is not rendered, and so not refused for a host +// that has no gateway to render it from. The service itself is not mutated. +func TestAnExcludedGroupIsNotRendered(t *testing.T) { + service := &resources.Service{Name: "page", WorkspaceConfigurationDependencies: []string{"module-authority", "org-directory", "authority"}} + world := &World{excludedWorkspaceConfigurations: map[string]bool{"module-authority": true}} + seen := world.withoutExcludedGroups(service) + if slices.Contains(seen.WorkspaceConfigurationDependencies, "module-authority") || len(seen.WorkspaceConfigurationDependencies) != 2 { + t.Fatalf("rendered with %v", seen.WorkspaceConfigurationDependencies) + } + if len(service.WorkspaceConfigurationDependencies) != 3 { + t.Fatalf("the declared service was mutated: %v", service.WorkspaceConfigurationDependencies) + } + if untouched := (&World{}).withoutExcludedGroups(service); untouched != service { + t.Fatal("a run excluding nothing copied the service") + } +} diff --git a/pkg/orchestration/workspace_configurations.go b/pkg/orchestration/workspace_configurations.go index 2e5a7ac7..8cac7634 100644 --- a/pkg/orchestration/workspace_configurations.go +++ b/pkg/orchestration/workspace_configurations.go @@ -199,7 +199,7 @@ func (world *World) workspaceConfigurationsFor( // declares them: the resolver above skipped those names on purpose, since a // render projects them, and this is the run standing in for that render. if world.RenderOwnedGroups != nil { - owned, ownedErr := world.RenderOwnedGroups(ctx, service) + owned, ownedErr := world.RenderOwnedGroups(ctx, world.withoutExcludedGroups(service)) if ownedErr != nil { return nil, ownedErr } @@ -1078,3 +1078,26 @@ func (b *Builder) workspaceConfigurations( return b.world.workspaceConfigurationsFor(ctx, b.instance.Service, dependencyMappings, resources.NewContainerNetworkAccess()) } + +// withoutExcludedGroups is service as the run sees it: its declared workspace +// configuration groups less the ones the run excludes. A render-owned group is +// rendered by the CLI rather than resolved, so it is trimmed here, before the +// render, or an excluded group would still be rendered — and refused when this +// run's host has nothing to render it from. +func (world *World) withoutExcludedGroups(service *resources.Service) *resources.Service { + if len(world.excludedWorkspaceConfigurations) == 0 { + return service + } + kept := make([]string, 0, len(service.WorkspaceConfigurationDependencies)) + for _, group := range service.WorkspaceConfigurationDependencies { + if !world.excludedWorkspaceConfigurations[group] { + kept = append(kept, group) + } + } + if len(kept) == len(service.WorkspaceConfigurationDependencies) { + return service + } + trimmed := *service + trimmed.WorkspaceConfigurationDependencies = kept + return &trimmed +} From ffdbddc65d3e4517625165f5c87701531393d8c2 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Fri, 23 Oct 2026 23:13:18 -0400 Subject: [PATCH 04/68] feat(run): deliver a module's scope ceilings in the authority's document Core's manifest names a module's own resource kinds without their actions, so an authority receiving it can only serve each kind with any action, and a person's ask for everything they hold there cannot be enumerated: the runtime's playground refused every task ("declares runtime.tasks without its actions"). The authority's document delivered with each manifest now carries the module's scope ceilings, read from its module contract (gitops.ScopeCeilings), beside the method policies; the document is sent when the module declares either. The field is the composition interface's (codefly-dev/authority 064127b), which the authority client moves to. Tests: the document carries the ceilings (sabotage-checked), and ceilings are read from a contract kind by kind, none without one. Co-Authored-By: Claude Opus 5.5 --- cmd/run/launcher.go | 8 +++++ go.mod | 2 +- go.sum | 2 ++ pkg/gitops/composition_delivery.go | 43 ++++++++++++++++++------- pkg/gitops/composition_delivery_test.go | 2 +- pkg/gitops/method_policies.go | 20 ++++++++++++ pkg/gitops/method_policies_test.go | 29 +++++++++++++++++ 7 files changed, 93 insertions(+), 13 deletions(-) diff --git a/cmd/run/launcher.go b/cmd/run/launcher.go index b99243a5..4b32ff65 100644 --- a/cmd/run/launcher.go +++ b/cmd/run/launcher.go @@ -6,6 +6,7 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" + authorityv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/authority/v1" compositionv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/composition/v1" "os" "path/filepath" @@ -555,6 +556,13 @@ func (cluster *launcher) newDeliverer(ctx context.Context) (*gitops.NativeDelive return nil, false, err } return gitops.MethodPolicies(m.Dir()) + }, + func(ctx context.Context, module string) ([]*authorityv1.WorkScopeV1, error) { + m, err := cluster.composed().LoadModuleFromName(ctx, module) + if err != nil { + return nil, err + } + return gitops.ScopeCeilings(m.Dir()) }) return &gitops.NativeDeliverer{ Workspace: cluster.composed(), Env: cluster.env, Signer: cluster.signer, diff --git a/go.mod b/go.mod index 7af0d180..71769ad2 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/asottile/dockerfile v3.1.0+incompatible github.com/blang/semver v3.5.1+incompatible github.com/briandowns/spinner v1.23.2 - github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261023192826-bc4d12956a06 + github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261024030918-064127bdd860 github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261023192826-bc4d12956a06 github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261023192826-bc4d12956a06 github.com/codefly-dev/core v0.19.6-0.20261022093222-5522c4dac804 diff --git a/go.sum b/go.sum index f8081e50..320d450c 100644 --- a/go.sum +++ b/go.sum @@ -105,6 +105,8 @@ github.com/cloudflare/circl v1.6.5 h1:O64F26HEqNhznd/hrC5KZXVKYuKM2rx4deZDTc4ihQ github.com/cloudflare/circl v1.6.5/go.mod h1:h5LNyxAc5nTue9DS5jT+48en2PSDYt3zdGnz5OstK6c= github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261023192826-bc4d12956a06 h1:GmhlPnIro0S7Us5UN1Iq3k54gI3+qzQrodeNS2YYtUo= github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261023192826-bc4d12956a06/go.mod h1:wxXASIpNKX7bzQzd0ZB17Xj2CFlO1ALRHqDaEoER+is= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261024030918-064127bdd860 h1:fDc1Nu+kOYlDIAGx+fXfqkkCqdgAMeL1Wh71D3HJ6DI= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261024030918-064127bdd860/go.mod h1:wxXASIpNKX7bzQzd0ZB17Xj2CFlO1ALRHqDaEoER+is= github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261023192826-bc4d12956a06 h1:7nIq/fdGoHp/70sSialNIrRWCvx6YCvzYPwKjleYefE= github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261023192826-bc4d12956a06/go.mod h1:XsOFnO2MQdr37vM3ULa0Uwi8ZWptyVx7hRSUyIdaZ1Q= github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261023192826-bc4d12956a06 h1:Gh531vlcnmPL41VurXGoVyn5Oyf5uJWH9j+wuvoPKAg= diff --git a/pkg/gitops/composition_delivery.go b/pkg/gitops/composition_delivery.go index 0dfbd497..c5f3517a 100644 --- a/pkg/gitops/composition_delivery.go +++ b/pkg/gitops/composition_delivery.go @@ -14,6 +14,7 @@ import ( delivery "github.com/codefly-dev/authority/libraries/delivery/go" + authorityv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/authority/v1" compositionv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/composition/v1" ) @@ -28,6 +29,9 @@ type CompositionDelivery struct { // methods reads what each method of a module declares, from its package; // ok false means the module declares no method policy. methods func(ctx context.Context, module string) ([]*compositionv1.MethodPolicyV1, bool, error) + // ceilings reads the module's own resource kinds with their actions, from + // its module contract; none when it publishes no contract. + ceilings func(ctx context.Context, module string) ([]*authorityv1.WorkScopeV1, error) mu sync.Mutex applied map[string]uint64 @@ -35,10 +39,13 @@ type CompositionDelivery struct { // NewCompositionDelivery speaks the interface over conn. With each manifest it // delivers the authority's own document, signed by signer: what each method of -// the module declares, read by methods from the module's package. +// the module declares, read by methods from the module's package, and the +// actions on each of its own resource kinds, read by ceilings from its module +// contract (core's manifest names the kinds alone). func NewCompositionDelivery(conn grpc.ClientConnInterface, signer solutionhost.Signer, - methods func(ctx context.Context, module string) ([]*compositionv1.MethodPolicyV1, bool, error)) *CompositionDelivery { - return &CompositionDelivery{client: compositionv1.NewCompositionServiceClient(conn), signer: signer, methods: methods, applied: map[string]uint64{}} + methods func(ctx context.Context, module string) ([]*compositionv1.MethodPolicyV1, bool, error), + ceilings func(ctx context.Context, module string) ([]*authorityv1.WorkScopeV1, error)) *CompositionDelivery { + return &CompositionDelivery{client: compositionv1.NewCompositionServiceClient(conn), signer: signer, methods: methods, ceilings: ceilings, applied: map[string]uint64{}} } // Post is NativeDeliverer.Post over the interface. @@ -66,19 +73,33 @@ func (c *CompositionDelivery) Post(ctx context.Context, module string, signed *s } // authority signs the authority's document for one delivery: the module's -// method policies, bound to the core manifest by its module, generation and -// digest, under the key that signs the manifest. Nil when the module declares -// no method policy. +// method policies and scope ceilings, bound to the core manifest by its module, +// generation and digest, under the key that signs the manifest. Nil when the +// module declares neither. func (c *CompositionDelivery) authority(ctx context.Context, module string, manifest *solutionhost.ModuleManifest, core []byte) (*compositionv1.SignedManifestV1, error) { - if c.methods == nil || c.signer == nil { + if c.signer == nil { return nil, nil } - methods, ok, err := c.methods(ctx, module) - if err != nil || !ok { - return nil, err + var methods []*compositionv1.MethodPolicyV1 + declared := false + if c.methods != nil { + var err error + if methods, declared, err = c.methods(ctx, module); err != nil { + return nil, err + } + } + var ceilings []*authorityv1.WorkScopeV1 + if c.ceilings != nil { + var err error + if ceilings, err = c.ceilings(ctx, module); err != nil { + return nil, err + } + } + if !declared && len(ceilings) == 0 { + return nil, nil } document, err := proto.MarshalOptions{Deterministic: true}.Marshal(&compositionv1.AuthorityManifestV1{ - Module: manifest.Alias, Generation: manifest.Generation, CoreManifestDigest: delivery.Digest(core), Methods: methods, + Module: manifest.Alias, Generation: manifest.Generation, CoreManifestDigest: delivery.Digest(core), Methods: methods, ScopeCeilings: ceilings, }) if err != nil { return nil, err diff --git a/pkg/gitops/composition_delivery_test.go b/pkg/gitops/composition_delivery_test.go index 9e6419eb..e063f7f8 100644 --- a/pkg/gitops/composition_delivery_test.go +++ b/pkg/gitops/composition_delivery_test.go @@ -54,7 +54,7 @@ func TestCompositionDeliveryCarriesTheSignedManifestThroughApplyManifest(t *test if err != nil { t.Fatal(err) } - delivery := NewCompositionDelivery(conn, nil, nil) + delivery := NewCompositionDelivery(conn, nil, nil, nil) manifest := &solutionhost.ModuleManifest{ Schema: solutionhost.SchemaModuleManifestV1, Binding: "probe", Alias: "probe", Kind: solutionhost.KindModule, Generation: 2, Host: solutionhost.HostTarget{Coordinate: "local", Domain: "local"}, Release: solutionhost.Release{Publisher: "test", Name: "probe", Version: "0.1.0"}, diff --git a/pkg/gitops/method_policies.go b/pkg/gitops/method_policies.go index 1563b220..e1f05daf 100644 --- a/pkg/gitops/method_policies.go +++ b/pkg/gitops/method_policies.go @@ -3,12 +3,14 @@ package gitops import ( "crypto/sha256" "encoding/hex" + "errors" "fmt" "os" "path/filepath" "sort" "github.com/codefly-dev/core/composition" + modulecontract "github.com/codefly-dev/core/contracts/module" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/reflect/protoregistry" "google.golang.org/protobuf/types/descriptorpb" @@ -91,3 +93,21 @@ func contractMethods(path, digest string) ([]*compositionv1.MethodPolicyV1, erro } return out, nil } + +// ScopeCeilings reads the module's own resource kinds with the actions its +// module contract declares on each, from the module at dir. A module that +// publishes no contract declares none. +func ScopeCeilings(dir string) ([]*authorityv1.WorkScopeV1, error) { + contract, err := modulecontract.Load(dir) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + var out []*authorityv1.WorkScopeV1 + for _, ceiling := range contract.ScopeCeilings { + out = append(out, &authorityv1.WorkScopeV1{ResourceKind: ceiling.ResourceKind, Actions: append([]string(nil), ceiling.Actions...)}) + } + return out, nil +} diff --git a/pkg/gitops/method_policies_test.go b/pkg/gitops/method_policies_test.go index ba10ea82..a302b520 100644 --- a/pkg/gitops/method_policies_test.go +++ b/pkg/gitops/method_policies_test.go @@ -9,6 +9,7 @@ import ( "net" "os" "path/filepath" + "slices" "strings" "testing" @@ -136,8 +137,11 @@ func TestTheAuthorityDocumentIsSignedAndBoundToItsManifest(t *testing.T) { t.Fatal(err) } want := []*compositionv1.MethodPolicyV1{{Method: "/api.ApiService/WhoAmI", Requires: []*authorityv1.WorkScopeV1{{ResourceKind: "console.identity", Actions: []string{"read"}}}}} + ceilings := []*authorityv1.WorkScopeV1{{ResourceKind: "console.identity", Actions: []string{"read"}}} d := NewCompositionDelivery(conn, signer, func(_ context.Context, module string) ([]*compositionv1.MethodPolicyV1, bool, error) { return want, module == "console", nil + }, func(_ context.Context, module string) ([]*authorityv1.WorkScopeV1, error) { + return ceilings, nil }) manifest := &solutionhost.ModuleManifest{ Schema: solutionhost.SchemaModuleManifestV1, Binding: "console", Alias: "console", Kind: solutionhost.KindModule, Generation: 3, @@ -162,4 +166,29 @@ func TestTheAuthorityDocumentIsSignedAndBoundToItsManifest(t *testing.T) { if doc.GetModule() != "console" || doc.GetGeneration() != 3 || doc.GetCoreManifestDigest() != delivery.Digest(document) || !proto.Equal(doc.GetMethods()[0], want[0]) { t.Fatalf("the document names its manifest and carries the methods: %v", &doc) } + // And the module's own kinds with their actions, which core's manifest + // names without them. + if len(doc.GetScopeCeilings()) != 1 || !proto.Equal(doc.GetScopeCeilings()[0], ceilings[0]) { + t.Fatalf("the document carries the scope ceilings: %v", doc.GetScopeCeilings()) + } +} + +// A module's scope ceilings come from the contract it publishes, kind by kind +// with their actions; a module publishing none declares none. +func TestScopeCeilingsAreReadFromTheModuleContract(t *testing.T) { + dir := t.TempDir() + if got, err := ScopeCeilings(dir); err != nil || got != nil { + t.Fatalf("no contract: %v %v", got, err) + } + contract := "schema: codefly/module-contract/v1\nprincipal: runtime\nnamespaces: []\nqueues: []\nscope_ceilings:\n - resource_kind: runtime.tasks\n actions: [execute, start]\nbindings: []\ndestinations: []\n" + if err := os.WriteFile(filepath.Join(dir, "module.contract.codefly.yaml"), []byte(contract), 0o600); err != nil { + t.Fatal(err) + } + got, err := ScopeCeilings(dir) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].GetResourceKind() != "runtime.tasks" || !slices.Equal(got[0].GetActions(), []string{"execute", "start"}) { + t.Fatalf("ceilings: %v", got) + } } From 6faa4b7c9775491469aa0e973a57b6e76c272f63 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Sat, 24 Oct 2026 02:00:06 -0400 Subject: [PATCH 05/68] gitops: a module serving HTTP delivers its routes' policies from its OpenAPI contract The authority now admits an HTTP request by its route ("