diff --git a/.github/workflows/pr-workflow.yaml b/.github/workflows/pr-workflow.yaml index cc55c9276..c16d4b1d4 100644 --- a/.github/workflows/pr-workflow.yaml +++ b/.github/workflows/pr-workflow.yaml @@ -107,6 +107,32 @@ jobs: env: E2E_SANDBOX_CLASS: microvm run: hack/run-e2e-kind.sh -v -args --no-color + - name: Deploy MITM egress (sdsmint) + # Swap the passthrough egress gateway for the sdsmint variant, which + # mints per-SNI leaves from the egress-mitm-ca-pool (created here if + # missing). --deploy-atenet redeploys only the atenet components (the + # rest of the control plane is unchanged), keeping this step cheap. + # Cluster-wide, so it must come AFTER the standard lanes: once egress + # TLS is intercepted, their passthrough assumptions + # (TestActorEgressHTTPS's end-to-end TLS with the origin) no longer hold. + run: hack/install-ate-kind.sh --deploy-atenet --experimental-use-sdsmint + - name: Run E2E tests (egress MITM trust) + # The consumption half of the trust-bundle chain: an actor does TLS with + # the MITM gateway's minted leaf using ONLY the projected bundle, plus a + # system-roots negative control proving interception is real (see + # internal/e2e/suites/egressmitm). + env: + E2E_EGRESS_MITM: "1" + run: hack/run-e2e-kind.sh ./internal/e2e/suites/egressmitm -v -args --no-color + - name: Run E2E tests (egress MITM trust, micro-VM) + # The same proof with the probe on the micro-VM runtime. Trust DELIVERY + # differs per sandbox class (gVisor RO bind vs the micro-VM unified + # virtio-fs share), so the handshake is proven on both. Uses the + # micro-VM deps staged earlier in this job. + env: + E2E_EGRESS_MITM: "1" + E2E_SANDBOX_CLASS: microvm + run: hack/run-e2e-kind.sh ./internal/e2e/suites/egressmitm -v -args --no-color - name: Dump diagnostics on failure if: failure() run: | diff --git a/cmd/ateapi/internal/controlapi/workload_spec.go b/cmd/ateapi/internal/controlapi/workload_spec.go index cfb6a5084..f77222350 100644 --- a/cmd/ateapi/internal/controlapi/workload_spec.go +++ b/cmd/ateapi/internal/controlapi/workload_spec.go @@ -56,6 +56,17 @@ func workloadSpecFromActorTemplate(actorTemplate *atev1alpha1.ActorTemplate, act ActorMetadata: actorMetadata, }, }) + case dataSource.TrustBundle != nil: + // atelet resolves named trustBundles against its allowlist + // and ClusterTrustBundle informer at write time + ateletSystemInfo.DataSources = append(ateletSystemInfo.DataSources, &ateletpb.SystemInfoDataSource{ + DataSource: &ateletpb.SystemInfoDataSource_TrustBundle{ + TrustBundle: &ateletpb.TrustBundleDataSource{ + Name: dataSource.TrustBundle.Name, + Path: dataSource.TrustBundle.Path, + }, + }, + }) default: continue // Drop unrecognized data sources } diff --git a/cmd/ateapi/internal/controlapi/workload_spec_test.go b/cmd/ateapi/internal/controlapi/workload_spec_test.go index bd33e295e..17bc02019 100644 --- a/cmd/ateapi/internal/controlapi/workload_spec_test.go +++ b/cmd/ateapi/internal/controlapi/workload_spec_test.go @@ -180,6 +180,62 @@ func TestWorkloadSpecFromActorTemplate(t *testing.T) { }, }, }, + { + name: "converts trustBundle data sources carrying the name for node-side resolution", + template: &atev1alpha1.ActorTemplate{ + ObjectMeta: metav1.ObjectMeta{Name: "tmpl1", Namespace: "agent-ns"}, + Spec: atev1alpha1.ActorTemplateSpec{ + Volumes: []atev1alpha1.Volume{ + { + Name: "system-info", + VolumeSource: atev1alpha1.VolumeSource{ + SystemInfo: &atev1alpha1.SystemInfoVolumeSource{ + DataSources: []atev1alpha1.SystemInfoDataSource{ + {TrustBundle: &atev1alpha1.TrustBundleDataSource{Name: "egress-trust", Path: "trust/ca.pem"}}, + }, + }, + }, + }, + }, + Containers: []atev1alpha1.Container{ + { + Name: "main", + Image: "main", + VolumeMounts: []atev1alpha1.VolumeMount{ + {Name: "system-info", MountPath: "/run/substrate/certs"}, + }, + }, + }, + }, + }, + // The NAME crosses the wire; atelet resolves it against its + // allowlist and ClusterTrustBundle informer at write time. + want: &ateletpb.WorkloadSpec{ + Volumes: []*ateletpb.Volume{ + { + Name: "system-info", + Source: &ateletpb.Volume_SystemInfo{ + SystemInfo: &ateletpb.SystemInfoVolume{ + DataSources: []*ateletpb.SystemInfoDataSource{ + {DataSource: &ateletpb.SystemInfoDataSource_TrustBundle{ + TrustBundle: &ateletpb.TrustBundleDataSource{Name: "egress-trust", Path: "trust/ca.pem"}, + }}, + }, + }, + }, + }, + }, + Containers: []*ateletpb.Container{ + { + Name: "main", + Image: "main", + VolumeMounts: []*ateletpb.VolumeMount{ + {Name: "system-info", MountPath: "/run/substrate/certs"}, + }, + }, + }, + }, + }, { name: "skips non-DurableDir volumes", template: &atev1alpha1.ActorTemplate{ diff --git a/cmd/atelet/main.go b/cmd/atelet/main.go index e9a4654db..621386dae 100644 --- a/cmd/atelet/main.go +++ b/cmd/atelet/main.go @@ -77,8 +77,12 @@ import ( "google.golang.org/grpc/reflection" "google.golang.org/grpc/status" "k8s.io/apimachinery/pkg/api/validate/content" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/fields" "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/client-go/informers" "k8s.io/client-go/kubernetes" + certlisters "k8s.io/client-go/listers/certificates/v1beta1" "k8s.io/client-go/rest" "k8s.io/utils/lru" ) @@ -276,10 +280,22 @@ func main() { ateFactory := externalversions.NewSharedInformerFactory(ateClient, 0) csiDriverConfigLister := ateFactory.Api().V1alpha1().CSIDriverConfigs().Lister() + // Start an informer on the ClusterTrustBundle we care about (currently + // only the egress trust bundle). The v1beta1 API is feature-gated: on a + // cluster that does not serve it, startup blocks at WaitForCacheSync + // below, with the reflector's errors naming the missing API. + coreFactory := informers.NewSharedInformerFactoryWithOptions(k8sClient, 0, + informers.WithTweakListOptions(func(o *metav1.ListOptions) { + o.FieldSelector = fields.OneTermEqualSelector("metadata.name", supportedTrustBundles[EgressTrustBundleName]).String() + })) + clusterTrustBundleLister := coreFactory.Certificates().V1beta1().ClusterTrustBundles().Lister() + stopCh := make(chan struct{}) defer close(stopCh) ateFactory.Start(stopCh) + coreFactory.Start(stopCh) ateFactory.WaitForCacheSync(stopCh) + coreFactory.WaitForCacheSync(stopCh) wmService := NewService( ctx, @@ -290,6 +306,7 @@ func main() { instruments, volPlugins, csiDriverConfigLister, + clusterTrustBundleLister, ) dialOpts, err := ateapiauth.DialOptions(ateapiauth.ClientConfig{ K8sClient: k8sClient, @@ -403,14 +420,15 @@ func drainOnShutdown(ctx context.Context, srv *grpc.Server, readiness *serverboo type AteomHerder struct { ateletpb.UnimplementedAteomHerderServer - ateomDialer *AteomDialer - imageCache *imagecache.Store - anonGCSClient ategcs.ObjectStorage - gcsClient ategcs.ObjectStorage - instruments *Instruments - mu sync.RWMutex - volumePlugins map[string]volume.VolumePluginWorkerPlane - csiDriverConfigLister listersv1alpha1.CSIDriverConfigLister + ateomDialer *AteomDialer + imageCache *imagecache.Store + anonGCSClient ategcs.ObjectStorage + gcsClient ategcs.ObjectStorage + instruments *Instruments + mu sync.RWMutex + volumePlugins map[string]volume.VolumePluginWorkerPlane + csiDriverConfigLister listersv1alpha1.CSIDriverConfigLister + clusterTrustBundleLister certlisters.ClusterTrustBundleLister } var _ ateletpb.AteomHerderServer = (*AteomHerder)(nil) @@ -425,15 +443,17 @@ func NewService( instruments *Instruments, volumePlugins map[string]volume.VolumePluginWorkerPlane, csiDriverConfigLister listersv1alpha1.CSIDriverConfigLister, + clusterTrustBundleLister certlisters.ClusterTrustBundleLister, ) *AteomHerder { wms := &AteomHerder{ - ateomDialer: ateomDialer, - imageCache: imageCache, - anonGCSClient: anonGCSClient, - gcsClient: gcsClient, - instruments: instruments, - volumePlugins: volumePlugins, - csiDriverConfigLister: csiDriverConfigLister, + ateomDialer: ateomDialer, + imageCache: imageCache, + anonGCSClient: anonGCSClient, + gcsClient: gcsClient, + instruments: instruments, + volumePlugins: volumePlugins, + csiDriverConfigLister: csiDriverConfigLister, + clusterTrustBundleLister: clusterTrustBundleLister, } return wms } @@ -1505,7 +1525,7 @@ func (s *AteomHerder) prepareOCIBundles( case *ateletpb.Volume_SystemInfo: volRootHostPath := ateompath.SystemInfoVolumeRoot(actorUID, vol.GetName()) - if err := writeSystemInfoVolume(ctx, volRootHostPath, actorRef, actorUID, volSrc.SystemInfo); err != nil { + if err := writeSystemInfoVolume(ctx, volRootHostPath, actorRef, actorUID, s.clusterTrustBundleLister, volSrc.SystemInfo); err != nil { return fmt.Errorf("while populating system-info volume %q: %w", vol.GetName(), err) } } @@ -1604,13 +1624,25 @@ func (s *AteomHerder) prepareOCIBundles( // these files refreshed while the actor runs, not just at Run/Restore — and // must keep the per-file rename discipline so visible paths never move. // actorMetadata never changes after start, so writing here is enough for it. -func writeSystemInfoVolume(ctx context.Context, rootPath string, actorRef resources.ActorRef, actorUID string, si *ateletpb.SystemInfoVolume) error { +// +// TODO(#932): trustBundle projections currently refresh only here, on +// Run/Restore; live refresh for running actors is PR 2 of that issue. +func writeSystemInfoVolume(ctx context.Context, rootPath string, actorRef resources.ActorRef, actorUID string, ctbLister certlisters.ClusterTrustBundleLister, si *ateletpb.SystemInfoVolume) error { if err := os.MkdirAll(rootPath, 0o755); err != nil { return fmt.Errorf("while creating %q: %w", rootPath, err) } for _, dataSourceAny := range si.GetDataSources() { switch dataSource := dataSourceAny.GetDataSource().(type) { + case *ateletpb.SystemInfoDataSource_TrustBundle: + tb := dataSource.TrustBundle + pemBundle, err := resolveTrustBundle(ctbLister, tb.GetName()) + if err != nil { + return fmt.Errorf("system-info projection %q: %w", tb.GetPath(), err) + } + if err := writeSystemInfoFile(rootPath, tb.GetPath(), pemBundle); err != nil { + return err + } case *ateletpb.SystemInfoDataSource_ActorMetadata: for _, item := range dataSource.ActorMetadata.GetItems() { var value string diff --git a/cmd/atelet/main_test.go b/cmd/atelet/main_test.go index 709c28a8c..2897eca67 100644 --- a/cmd/atelet/main_test.go +++ b/cmd/atelet/main_test.go @@ -50,6 +50,8 @@ import ( "google.golang.org/grpc/status" "google.golang.org/protobuf/testing/protocmp" "google.golang.org/protobuf/types/known/emptypb" + certsv1beta1 "k8s.io/api/certificates/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) const testPauseImage = "registry.k8s.io/pause:3.10.2@sha256:f548e0e8e3dc1896ca956272154dde3314e8cc4fde0a57577ee9fa1c63f5baf4" @@ -137,14 +139,14 @@ func TestWriteSystemInfoVolume(t *testing.T) { } golden := resources.ActorRef{Atespace: "ate-e2e-probe", Name: "golden-actor"} - if err := writeSystemInfoVolume(ctx, root, golden, "uid-golden", si); err != nil { + if err := writeSystemInfoVolume(ctx, root, golden, "uid-golden", nil, si); err != nil { t.Fatalf("writeSystemInfoVolume: %v", err) } // Overwrite with a different actor, as happens when a snapshot taken from // one actor seeds another on resume: files must carry the new values. alpha := resources.ActorRef{Atespace: "ate-e2e-probe", Name: "probe-alpha"} - if err := writeSystemInfoVolume(ctx, root, alpha, "uid-alpha", si); err != nil { + if err := writeSystemInfoVolume(ctx, root, alpha, "uid-alpha", nil, si); err != nil { t.Fatalf("writeSystemInfoVolume (rewrite): %v", err) } @@ -198,7 +200,7 @@ func TestWriteSystemInfoVolume_StableRealPaths(t *testing.T) { } golden := resources.ActorRef{Atespace: "ate-e2e-probe", Name: "golden-actor"} - if err := writeSystemInfoVolume(ctx, root, golden, "uid-golden", si); err != nil { + if err := writeSystemInfoVolume(ctx, root, golden, "uid-golden", nil, si); err != nil { t.Fatalf("writeSystemInfoVolume: %v", err) } @@ -222,7 +224,7 @@ func TestWriteSystemInfoVolume_StableRealPaths(t *testing.T) { // Regenerate for a different actor, as a restore from a shared golden // snapshot does. alpha := resources.ActorRef{Atespace: "ate-e2e-probe", Name: "probe-alpha"} - if err := writeSystemInfoVolume(ctx, root, alpha, "uid-alpha", si); err != nil { + if err := writeSystemInfoVolume(ctx, root, alpha, "uid-alpha", nil, si); err != nil { t.Fatalf("writeSystemInfoVolume (rewrite): %v", err) } @@ -240,6 +242,42 @@ func TestWriteSystemInfoVolume_StableRealPaths(t *testing.T) { } } +func TestWriteSystemInfoVolume_TrustBundle(t *testing.T) { + ctx := context.Background() + certPEM := testCertPEM(t) + si := &ateletpb.SystemInfoVolume{ + DataSources: []*ateletpb.SystemInfoDataSource{ + {DataSource: &ateletpb.SystemInfoDataSource_TrustBundle{ + TrustBundle: &ateletpb.TrustBundleDataSource{Name: EgressTrustBundleName, Path: "trust/ca.pem"}, + }}, + }, + } + lister := ctbLister(t, &certsv1beta1.ClusterTrustBundle{ + ObjectMeta: metav1.ObjectMeta{Name: egressTrustBundleObjectName}, + Spec: certsv1beta1.ClusterTrustBundleSpec{TrustBundle: string(certPEM)}, + }) + + root := filepath.Join(t.TempDir(), "system-info", "vol1") + ref := resources.ActorRef{Atespace: "team-a", Name: "actor-1"} + if err := writeSystemInfoVolume(ctx, root, ref, "uid-1", lister, si); err != nil { + t.Fatalf("writeSystemInfoVolume: %v", err) + } + got, err := os.ReadFile(filepath.Join(root, "trust/ca.pem")) + if err != nil { + t.Fatalf("reading projected bundle: %v", err) + } + if string(got) != string(certPEM) { + t.Errorf("content = %q, want the sanitized bundle", got) + } + + t.Run("resolution failure fails the write rather than produce an empty trust file", func(t *testing.T) { + err := writeSystemInfoVolume(ctx, filepath.Join(t.TempDir(), "vol2"), ref, "uid-1", ctbLister(t), si) + if err == nil || !strings.Contains(err.Error(), "not found") { + t.Errorf("writeSystemInfoVolume = %v, want not-found resolution error", err) + } + }) +} + func TestWriteFileAtomic(t *testing.T) { dir := t.TempDir() target := filepath.Join(dir, "actor-id") diff --git a/cmd/atelet/trustbundle.go b/cmd/atelet/trustbundle.go new file mode 100644 index 000000000..d4b6a994c --- /dev/null +++ b/cmd/atelet/trustbundle.go @@ -0,0 +1,74 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "fmt" + "slices" + "strings" + + "github.com/agent-substrate/substrate/internal/pemutil" + apierrors "k8s.io/apimachinery/pkg/api/errors" + certlisters "k8s.io/client-go/listers/certificates/v1beta1" +) + +// EgressTrustBundleName is the well-known name of the egress gateway CA +// bundle (#823): the trust anchors for the per-SNI leaves the egress gateway +// mints, maintained by atecontroller from the egress-mitm-ca-pool. +const EgressTrustBundleName = "egress-mitm.ate.dev" + +// supportedTrustBundles maps the bundle names the trustBundle data source +// may reference to their backing ClusterTrustBundle objects. Enforced here +// rather than in the CRD schema so a configurable backend registry (#932) +// can widen it without a template API change. +var supportedTrustBundles = map[string]string{ + EgressTrustBundleName: "egress-mitm.ate.dev:mitm:primary-bundle", +} + +// supportedTrustBundleNames returns the allowlist, sorted, for error text. +func supportedTrustBundleNames() string { + names := make([]string, 0, len(supportedTrustBundles)) + for name := range supportedTrustBundles { + names = append(names, name) + } + slices.Sort(names) + return strings.Join(names, ", ") +} + +// resolveTrustBundle returns the sanitized PEM of the named trust bundle, +// resolving the name against the allowlist and reading the backing +// ClusterTrustBundle through atelet's informer-backed lister. Every error +// fails the actor start: an actor that declared a trust bundle must not +// start without one. +func resolveTrustBundle(lister certlisters.ClusterTrustBundleLister, name string) ([]byte, error) { + objectName, supported := supportedTrustBundles[name] + if !supported { + return nil, fmt.Errorf("trust bundle %q is not supported by this deployment (supported: %s)", name, supportedTrustBundleNames()) + } + if lister == nil { + return nil, fmt.Errorf("trust bundle %q: no ClusterTrustBundle lister configured", name) + } + bundle, err := lister.Get(objectName) + if apierrors.IsNotFound(err) { + return nil, fmt.Errorf("trust bundle %q: ClusterTrustBundle %q not found", name, objectName) + } else if err != nil { + return nil, fmt.Errorf("trust bundle %q: while reading ClusterTrustBundle %q: %w", name, objectName, err) + } + pemBundle, err := pemutil.SanitizeCertificateBundle([]byte(bundle.Spec.TrustBundle)) + if err != nil { + return nil, fmt.Errorf("trust bundle %q: unusable ClusterTrustBundle %q: %w", name, objectName, err) + } + return pemBundle, nil +} diff --git a/cmd/atelet/trustbundle_test.go b/cmd/atelet/trustbundle_test.go new file mode 100644 index 000000000..b761e9452 --- /dev/null +++ b/cmd/atelet/trustbundle_test.go @@ -0,0 +1,129 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "strings" + "testing" + "time" + + certsv1beta1 "k8s.io/api/certificates/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + certlisters "k8s.io/client-go/listers/certificates/v1beta1" + "k8s.io/client-go/tools/cache" +) + +// testCertPEM mints a throwaway self-signed certificate, PEM-encoded. +func testCertPEM(t *testing.T) []byte { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + der, err := x509.CreateCertificate(rand.Reader, &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "test"}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + }, &x509.Certificate{SerialNumber: big.NewInt(1)}, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) +} + +func ctbLister(t *testing.T, bundles ...*certsv1beta1.ClusterTrustBundle) certlisters.ClusterTrustBundleLister { + t.Helper() + indexer := cache.NewIndexer(cache.MetaNamespaceKeyFunc, cache.Indexers{}) + for _, b := range bundles { + if err := indexer.Add(b); err != nil { + t.Fatal(err) + } + } + return certlisters.NewClusterTrustBundleLister(indexer) +} + +// egressTrustBundleObjectName is the backing ClusterTrustBundle the allowlist +// maps EgressTrustBundleName to (named by atecontroller's reconciler). +const egressTrustBundleObjectName = "egress-mitm.ate.dev:mitm:primary-bundle" + +func TestResolveTrustBundle(t *testing.T) { + certPEM := testCertPEM(t) + junk := "garbage\n" + string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: []byte("x")})) + + t.Run("resolves the allowlisted name through the mapped object and sanitizes", func(t *testing.T) { + lister := ctbLister(t, &certsv1beta1.ClusterTrustBundle{ + ObjectMeta: metav1.ObjectMeta{Name: egressTrustBundleObjectName}, + // Junk around the certificate proves kubelet-style sanitization: + // only the CERTIFICATE block survives, and the duplicate is dropped. + Spec: certsv1beta1.ClusterTrustBundleSpec{TrustBundle: junk + string(certPEM) + string(certPEM)}, + }) + got, err := resolveTrustBundle(lister, EgressTrustBundleName) + if err != nil { + t.Fatalf("resolveTrustBundle: %v", err) + } + if string(got) != string(certPEM) { + t.Errorf("pem bundle = %q, want the sanitized certificate", got) + } + }) + + t.Run("unsupported bundle name fails naming it and the allowlist", func(t *testing.T) { + // The lister has the bundle; the allowlist must still reject it — + // supported names are a substrate decision, not a cluster lookup. + lister := ctbLister(t, &certsv1beta1.ClusterTrustBundle{ + ObjectMeta: metav1.ObjectMeta{Name: "my-own-bundle"}, + Spec: certsv1beta1.ClusterTrustBundleSpec{TrustBundle: string(certPEM)}, + }) + _, err := resolveTrustBundle(lister, "my-own-bundle") + if err == nil || !strings.Contains(err.Error(), `"my-own-bundle"`) || !strings.Contains(err.Error(), "not supported") || !strings.Contains(err.Error(), EgressTrustBundleName) { + t.Errorf("error = %v, want unsupported-name error listing the allowlist", err) + } + }) + + t.Run("missing bundle fails naming it", func(t *testing.T) { + _, err := resolveTrustBundle(ctbLister(t), EgressTrustBundleName) + if err == nil || !strings.Contains(err.Error(), egressTrustBundleObjectName) || !strings.Contains(err.Error(), "not found") { + t.Errorf("error = %v, want not-found naming the backing object", err) + } + }) + + t.Run("unusable bundle fails naming it", func(t *testing.T) { + lister := ctbLister(t, &certsv1beta1.ClusterTrustBundle{ + ObjectMeta: metav1.ObjectMeta{Name: egressTrustBundleObjectName}, + Spec: certsv1beta1.ClusterTrustBundleSpec{TrustBundle: junk}, + }) + _, err := resolveTrustBundle(lister, EgressTrustBundleName) + if err == nil || !strings.Contains(err.Error(), "unusable") { + t.Errorf("error = %v, want unusable-bundle error", err) + } + }) + + t.Run("nil lister fails with a clear error, not a panic", func(t *testing.T) { + // A nil lister is a wiring bug (production always registers the + // informer at boot); it must fail the actor start, not panic the + // node daemon. + _, err := resolveTrustBundle(nil, EgressTrustBundleName) + if err == nil || !strings.Contains(err.Error(), "no ClusterTrustBundle lister") { + t.Errorf("error = %v, want no-lister error", err) + } + }) +} diff --git a/docs/api-guide.md b/docs/api-guide.md index 78ca38035..e8f15b689 100644 --- a/docs/api-guide.md +++ b/docs/api-guide.md @@ -226,6 +226,30 @@ spec: The values are delivered as files on a read-only per-actor bind mount, not environment variables, precisely so they carry the correct values after a resume from a shared snapshot — an env var (or a file baked into the image) would be frozen at the snapshot-source actor's values, since it lives in the checkpointed process memory, and would therefore be identical for every actor restored from that snapshot. The metadata fields themselves are fixed for the actor's lifetime, so workloads may cache them; future data sources that rotate (identity tokens and certificates) must be re-read at time of use. +#### trustBundle +The trustBundle data source projects the trust anchors of a named trust bundle to a single PEM file — inspired by the [Kubernetes clusterTrustBundle projected volume source](https://kubernetes.io/docs/concepts/storage/projected-volumes/#clustertrustbundle), but source-neutral: the name selects a bundle substrate knows how to fetch, and where it is fetched from is a deployment concern, not part of the API. + +Supported names are allowlisted. Today the only supported bundle is `egress-mitm.ate.dev` — the egress gateway CA bundle — resolved from the [ClusterTrustBundle](https://kubernetes.io/docs/reference/access-authn-authz/certificate-signing-requests/#cluster-trust-bundles) (`certificates.k8s.io/v1beta1`) that atecontroller's reconciler derives from the `egress-mitm-ca-pool` Secret in the `ate-system` namespace. A configurable backend registry may widen the allowlist later. + +```yaml +spec: + volumes: + - name: trust + systemInfo: + dataSources: + - trustBundle: + name: egress-mitm.ate.dev + path: ca.pem + containers: + - name: main + # ... + volumeMounts: + - name: trust + mountPath: /run/substrate/certs # the actor reads /run/substrate/certs/ca.pem +``` + +atelet resolves the bundle on the node when the actor starts, reading the backing object through a cluster-wide watch (the same informer dynamic refresh will later hang off) and sanitizing it the way kubelet does for projections: only `CERTIFICATE` PEM blocks are kept, deduplicated, with block headers stripped and the anchors deliberately shuffled — order carries no meaning, so consumers must not depend on it. The actor itself never talks to any bundle backend. Starting the actor fails, with an error naming the bundle, if the name is not on the allowlist, the bundle's backend is unavailable in this deployment, or the resolved bundle is missing, empty, or contains no certificates. Bundle contents are re-resolved on every Run/Restore. + ### Container Fields Each entry in `containers` describes one process to run in the actor's sandbox. diff --git a/internal/e2e/fixtures/probe/main.go b/internal/e2e/fixtures/probe/main.go index 4c121b71d..f2a5fe160 100644 --- a/internal/e2e/fixtures/probe/main.go +++ b/internal/e2e/fixtures/probe/main.go @@ -22,6 +22,8 @@ package main import ( "bufio" + "crypto/tls" + "crypto/x509" "encoding/json" "fmt" "io" @@ -31,14 +33,17 @@ import ( "runtime" "strconv" "strings" + "time" ) -// The actorMetadata data-source files of the systemInfo volume that -// probe.yaml.tmpl mounts at /run/ate. +// The systemInfo volume data-source files that probe.yaml.tmpl mounts at +// /run/ate: the actorMetadata projections plus a trustBundle +// projection. const ( identityFile = "/run/ate/actor-id" atespaceFile = "/run/ate/atespace" uidFile = "/run/ate/actor-uid" + trustFile = "/run/ate/trust-bundle.pem" ) // procStatus is where the kernel reports this process's capability sets. Asking @@ -170,6 +175,7 @@ func whoami(w http.ResponseWriter, _ *http.Request) { "file": identityFile, "atespace": atespaceFile, "uid": uidFile, + "trust": trustFile, } { if b, err := os.ReadFile(path); err == nil { resp[key] = string(b) @@ -287,6 +293,64 @@ func memTotalBytes() (int64, error) { return 0, os.ErrNotExist } +// fetch GETs ?url= over the actor's normal egress path and reports the +// outcome, doing TLS with the trust anchors selected by ?roots=: "bundle" +// (the default) loads the projected trust bundle at trustFile, "system" uses +// the image's system roots. TestActorEgressMITMTrust documents why each mode +// passes or fails. TLS failures land in the "error" field rather than the +// HTTP status: a verification failure is a result for the suite to assert +// on, not a broken probe. +func fetch(w http.ResponseWriter, r *http.Request) { + resp := map[string]string{} + url := r.URL.Query().Get("url") + if url == "" { + resp["error"] = "missing url parameter" + writeJSON(w, resp) + return + } + roots := r.URL.Query().Get("roots") + switch roots { + case "", "bundle", "system": + default: + // Fail closed on typos: silently treating an unknown value as + // "bundle" would flip a suite's negative control into a positive + // fetch with a misleading failure message. + resp["error"] = "unknown roots value " + strconv.Quote(roots) + " (want bundle or system)" + writeJSON(w, resp) + return + } + tlsCfg := &tls.Config{} + if roots != "system" { + b, err := os.ReadFile(trustFile) + if err != nil { + resp["error"] = "reading trust bundle: " + err.Error() + writeJSON(w, resp) + return + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(b) { + resp["error"] = "no certificates parsed from " + trustFile + writeJSON(w, resp) + return + } + tlsCfg.RootCAs = pool + } + client := &http.Client{ + Timeout: 20 * time.Second, + Transport: &http.Transport{TLSClientConfig: tlsCfg}, + } + res, err := client.Get(url) + if err != nil { + resp["error"] = err.Error() + writeJSON(w, resp) + return + } + defer res.Body.Close() + _, _ = io.Copy(io.Discard, res.Body) + resp["status"] = strconv.Itoa(res.StatusCode) + writeJSON(w, resp) +} + func writeJSON(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(v); err != nil { @@ -306,6 +370,7 @@ func main() { mux := http.NewServeMux() mux.HandleFunc("/whoami", whoami) + mux.HandleFunc("/fetch", fetch) mux.HandleFunc("/readfile", readfile) mux.HandleFunc("/writefile", writefile) mux.HandleFunc("/resources", resources) diff --git a/internal/e2e/fixtures/probe/probe.yaml.tmpl b/internal/e2e/fixtures/probe/probe.yaml.tmpl index 6bf31e64b..c94454b22 100644 --- a/internal/e2e/fixtures/probe/probe.yaml.tmpl +++ b/internal/e2e/fixtures/probe/probe.yaml.tmpl @@ -57,13 +57,19 @@ ${TEMPLATE_SANDBOX_CLASS} path: atespace - field: uid path: actor-uid + # The name must be on atelet's supported-bundle allowlist. DeployProbe + # ensures the bundle exists before applying this template: actors fail + # to start while it is missing. + - trustBundle: + name: egress-mitm.ate.dev + path: trust-bundle.pem containers: - name: probe image: ko://github.com/agent-substrate/substrate/internal/e2e/fixtures/probe command: ["/ko-app/probe"] volumeMounts: - name: system-info - mountPath: /run/ate # the probe reads /run/ate/actor-id + mountPath: /run/ate # the probe reads actor metadata and trust bundles under /run/ate # The probe binary binds :80 immediately, so this gates actor start on a # readiness signal rather than a guess, and carries a non-default # timeoutSeconds so e2e covers the value crossing ateapi -> atelet -> ateom diff --git a/internal/e2e/probe.go b/internal/e2e/probe.go index 56d7ab05f..3ee570f16 100644 --- a/internal/e2e/probe.go +++ b/internal/e2e/probe.go @@ -15,6 +15,7 @@ package e2e import ( + "context" "path/filepath" "testing" ) @@ -36,6 +37,11 @@ func DeployProbe(t *testing.T, bucket, name string) string { t.Fatalf("FindRepoRoot: %v", err) } + // The probe template projects the egress trust bundle, and every actor — + // including the fixture's golden boot — fails closed while the bundle is + // missing, so make sure it exists whatever suite is deploying. + EnsureEgressTrustBundle(t, context.Background(), GetClients()) + // One manifest, rendered for the sandbox class under test, so both apply // and delete consume the same file without any shell involved. manifest := RenderFixtureManifest(t, "internal/e2e/fixtures/probe/probe.yaml.tmpl", bucket, name) diff --git a/internal/e2e/suites/egressmitm/egressmitm_test.go b/internal/e2e/suites/egressmitm/egressmitm_test.go new file mode 100644 index 000000000..97870e499 --- /dev/null +++ b/internal/e2e/suites/egressmitm/egressmitm_test.go @@ -0,0 +1,233 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package egressmitm e2e-tests the trust half of MITM'd egress TLS (#871): +// an actor that projects the egress trust bundle can complete a TLS +// handshake with the sdsmint egress gateway's per-SNI minted leaf, using +// ONLY the projected anchors. See TestActorEgressMITMTrust for the proof +// structure and how to run this locally. +package egressmitm + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/url" + "os" + "strings" + "testing" + "time" + + "github.com/agent-substrate/substrate/internal/e2e" + "github.com/agent-substrate/substrate/internal/resources" + "github.com/agent-substrate/substrate/pkg/api/v1alpha1" + "github.com/agent-substrate/substrate/pkg/proto/ateapipb" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const probeTemplate = "probe" + +var probeNamespace string + +// TestActorEgressMITMTrust proves an actor can do TLS through the MITM +// egress gateway using only the projected trust bundle: +// +// - positive: /fetch with roots=bundle succeeds — the gateway's per-SNI +// minted leaf (signed from the egress-mitm-ca-pool) validates against +// the anchors atelet projected from the reconciler-published bundle. +// This also fails under a PASSTHROUGH gateway (the bundle holds no +// public CAs), so a pass certifies interception is on. +// - negative: /fetch with roots=system fails certificate verification — +// the minted leaf chains to no public CA, proving the traffic really is +// intercepted rather than relayed (under passthrough this fetch would +// succeed, and the positive case would be meaningless). +// +// The gate: this needs the sdsmint egress gateway variant, which replaces +// the passthrough gateway cluster-wide, so CI runs it as separate steps +// after the standard lanes (see pr-workflow.yaml) — once per sandbox class, +// since trust delivery differs per class (gVisor RO bind vs the micro-VM +// unified virtio-fs share). Locally: +// +// hack/install-ate-kind.sh --deploy-atenet --experimental-use-sdsmint +// E2E_EGRESS_MITM=1 hack/run-e2e-kind.sh ./internal/e2e/suites/egressmitm -v -args --no-color +// E2E_EGRESS_MITM=1 E2E_SANDBOX_CLASS=microvm hack/run-e2e-kind.sh ./internal/e2e/suites/egressmitm -v -args --no-color +// +// The micro-VM variant additionally needs the micro-VM deps installed +// (hack/run-microvm-demo-kind.sh, or hack/install-microvm-deps.sh --install). +func TestActorEgressMITMTrust(t *testing.T) { + if os.Getenv("E2E_EGRESS_MITM") == "" { + t.Skip("needs the sdsmint (MITM) egress gateway: deploy with hack/install-ate-kind.sh --deploy-atenet --experimental-use-sdsmint, then set E2E_EGRESS_MITM=1") + } + env, err := e2e.CheckEnv("BUCKET_NAME", "KO_DOCKER_REPO") + if err != nil { + t.Fatalf("CheckEnv failed: %v", err) + } + ctx := context.Background() + clients := e2e.GetClients() + + // Ensure, never replace: sdsmintd signs with the pool mounted into the + // gateway pod, and kubelet propagates Secret updates into that mount on + // its own schedule — replacing the pool here would race the propagation + // and flake the handshake. The sdsmint install path created the pool; we + // only wait for the reconciler-derived bundle so actor start can resolve + // the projection. (DeployProbe ensures too; this makes the dependency + // explicit and fails with the clearer message when the reconciler is + // missing.) + e2e.EnsureEgressTrustBundle(t, ctx, clients) + + probeNamespace = e2e.DeployProbe(t, env["BUCKET_NAME"], "egressmitm") + waitForGolden(t, ctx, clients) + + const id = "probe-mitm" + createAndResumeActor(t, ctx, clients, id) + waitForActorState(t, ctx, clients, id, ateapipb.ActorState_ACTOR_STATE_RUNNING) + + rc, err := e2e.NewRouterClient(ctx) + if err != nil { + t.Fatalf("NewRouterClient: %v", err) + } + defer rc.Close() + + const origin = "https://example.com/" + + // sdsmintd signs with the pool mounted into the gateway pod, and kubelet + // propagates Secret contents into that mount on its own schedule (up to + // ~1 minute). In CI the pool predates the gateway pod, but a LOCAL rerun + // can recreate the pool moments before this fetch (a prior run's cleanup + // deleted it), leaving the gateway briefly signing with the old CA — so + // certificate failures retry for one propagation window before counting. + deadline := time.Now().Add(2 * time.Minute) + var pos fetchResponse + for { + pos = probeFetch(t, ctx, rc, id, origin, "bundle") + isCertErr := strings.Contains(pos.Error, "certificate") || strings.Contains(pos.Error, "x509") + if pos.Error == "" || !isCertErr || time.Now().After(deadline) { + break + } + time.Sleep(5 * time.Second) + } + if pos.Error != "" { + t.Fatalf("TLS through the MITM egress gateway with the projected trust bundle failed: %s — the projected anchors did not validate the gateway's minted leaf (or interception/minting is broken)", pos.Error) + } + if pos.Status != "200" { + t.Fatalf("fetch %s via projected bundle: status %s, want 200", origin, pos.Status) + } + + neg := probeFetch(t, ctx, rc, id, origin, "system") + if neg.Error == "" { + t.Errorf("fetch with system roots unexpectedly succeeded (status %s): the minted leaf should chain to no public CA — is the sdsmint (MITM) gateway actually deployed, or is egress running in passthrough mode?", neg.Status) + } else if !strings.Contains(neg.Error, "certificate") && !strings.Contains(neg.Error, "x509") { + t.Errorf("fetch with system roots failed, but not with a certificate-verification error: %s", neg.Error) + } +} + +type fetchResponse struct { + Status string `json:"status"` + Error string `json:"error"` +} + +// probeFetch asks the probe to fetch origin with the given roots mode. +// Router-level failures are retried for up to 30s (a resume can return +// before the route reaches the router's xDS snapshot); probe-level TLS +// failures are results, returned for the caller to assert on. +func probeFetch(t *testing.T, ctx context.Context, rc *e2e.RouterClient, id, origin, roots string) fetchResponse { + t.Helper() + path := "/fetch?roots=" + roots + "&url=" + url.QueryEscape(origin) + ref := resources.ActorRef{Atespace: probeNamespace, Name: id} + + deadline := time.Now().Add(30 * time.Second) + for { + resp, err := rc.Get(ctx, ref, path) + if err != nil { + t.Fatalf("GET %s for %q: %v", path, id, err) + } + body, readErr := io.ReadAll(resp.Body) + resp.Body.Close() + if readErr != nil { + t.Fatalf("reading %s response for %q: %v", path, id, readErr) + } + if resp.StatusCode == http.StatusOK { + var out fetchResponse + if err := json.Unmarshal(body, &out); err != nil { + t.Fatalf("decoding %s response for %q: %v (body %q)", path, id, err, body) + } + return out + } + if time.Now().After(deadline) { + t.Fatalf("GET %s for %q: status %d, body %q", path, id, resp.StatusCode, body) + } + time.Sleep(2 * time.Second) + } +} + +// The helpers below mirror the identity suite's: fixture golden wait and a +// self-healing actor lifecycle (actor records outlive the fixture namespace). + +func waitForGolden(t *testing.T, ctx context.Context, clients *e2e.Clients) { + t.Helper() + deadline := time.Now().Add(e2e.TemplateReadyTimeout(t)) + for time.Now().Before(deadline) { + at, err := clients.SubstrateK8s.ApiV1alpha1().ActorTemplates(probeNamespace).Get(ctx, probeTemplate, metav1.GetOptions{}) + if err == nil { + switch at.Status.Phase { + case v1alpha1.PhaseReady: + return + case v1alpha1.PhaseFailed: + t.Fatalf("probe ActorTemplate entered PhaseFailed") + } + } + time.Sleep(2 * time.Second) + } + t.Fatalf("timed out waiting for probe ActorTemplate to be Ready") +} + +func createAndResumeActor(t *testing.T, ctx context.Context, clients *e2e.Clients, id string) { + t.Helper() + _, _ = clients.SubstrateAPI.CreateAtespace(ctx, &ateapipb.CreateAtespaceRequest{Atespace: &ateapipb.Atespace{Metadata: &ateapipb.ResourceMetadata{Name: probeNamespace}}}) + ref := &ateapipb.ObjectRef{Atespace: probeNamespace, Name: id} + _, _ = clients.SubstrateAPI.SuspendActor(ctx, &ateapipb.SuspendActorRequest{Actor: ref}) + _, _ = clients.SubstrateAPI.DeleteActor(ctx, &ateapipb.DeleteActorRequest{Actor: ref}) + if _, err := clients.SubstrateAPI.CreateActor(ctx, &ateapipb.CreateActorRequest{Actor: &ateapipb.Actor{ + Metadata: &ateapipb.ResourceMetadata{Atespace: probeNamespace, Name: id}, + ActorTemplateNamespace: probeNamespace, + ActorTemplateName: probeTemplate, + }}); err != nil { + t.Fatalf("CreateActor %q: %v", id, err) + } + t.Cleanup(func() { + _, _ = clients.SubstrateAPI.SuspendActor(ctx, &ateapipb.SuspendActorRequest{Actor: ref}) + if _, err := clients.SubstrateAPI.DeleteActor(ctx, &ateapipb.DeleteActorRequest{Actor: ref}); err != nil { + t.Logf("cleanup: DeleteActor %q failed, actor leaked (remove with: kubectl ate delete actor %s -a %s): %v", id, id, probeNamespace, err) + } + }) + if _, err := clients.SubstrateAPI.ResumeActor(ctx, &ateapipb.ResumeActorRequest{Actor: ref}); err != nil { + t.Fatalf("ResumeActor %q: %v", id, err) + } +} + +func waitForActorState(t *testing.T, ctx context.Context, clients *e2e.Clients, actorName string, want ateapipb.ActorState) { + t.Helper() + deadline := time.Now().Add(60 * time.Second) + for time.Now().Before(deadline) { + resp, err := clients.SubstrateAPI.GetActor(ctx, &ateapipb.GetActorRequest{ + Actor: &ateapipb.ObjectRef{Atespace: probeNamespace, Name: actorName}, + }) + if err == nil && resp.GetStatus().GetState() == want { + return + } + time.Sleep(1 * time.Second) + } + t.Fatalf("timed out waiting for actor %q to reach state %v", actorName, want) +} diff --git a/internal/e2e/suites/egressmitm/testmain_test.go b/internal/e2e/suites/egressmitm/testmain_test.go new file mode 100644 index 000000000..0fd795832 --- /dev/null +++ b/internal/e2e/suites/egressmitm/testmain_test.go @@ -0,0 +1,24 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package egressmitm + +import ( + "os" + "testing" + + "github.com/agent-substrate/substrate/internal/e2e" +) + +func TestMain(m *testing.M) { os.Exit(e2e.RunTestMain(m)) } diff --git a/internal/e2e/suites/identity/identity_test.go b/internal/e2e/suites/identity/identity_test.go index 99bfa908a..eed417700 100644 --- a/internal/e2e/suites/identity/identity_test.go +++ b/internal/e2e/suites/identity/identity_test.go @@ -39,6 +39,7 @@ type whoamiResponse struct { File string `json:"file"` Atespace string `json:"atespace"` UID string `json:"uid"` + Trust string `json:"trust"` Hostname string `json:"hostname"` // Held is the actor id read through a file descriptor the probe opened at // startup and holds across checkpoints — the snapshot therefore carries an @@ -74,6 +75,10 @@ func TestActorIdentity_AfterRestore_IsOwnID_NotGolden(t *testing.T) { ctx := context.Background() clients := e2e.GetClients() + // Own the pool's contents before the fixture deploys (DeployProbe only + // ensures a bundle EXISTS): the assertions below compare the projected + // file against this run's CA, and rotation later replaces it again. + wantTrust := e2e.ReplaceEgressTrustPool(t, ctx, clients, "ate-e2e-probe-trust") probeNamespace = e2e.DeployProbe(t, env["BUCKET_NAME"], "identity") golden := waitForGolden(t, ctx, clients) @@ -117,6 +122,14 @@ func TestActorIdentity_AfterRestore_IsOwnID_NotGolden(t *testing.T) { t.Errorf("actor %q: /run/ate/atespace = %q, want %q (probe read error: %q)", id, got.Atespace, probeNamespace, got.Error) } + // The projected trust bundle must be this run's pool CA, as published + // by the reconciler and sanitized by atelet (byte-identical here: the + // reconciler emits clean CERTIFICATE blocks; junk-tolerant + // sanitization is pinned by the resolve and pemutil unit tests). + if got.Trust != wantTrust { + t.Errorf("actor %q: /run/ate/trust-bundle.pem = %q, want the sanitized bundle %q (probe read error: %q)", id, got.Trust, wantTrust, got.Error) + } + // The projected UID must match the control plane's authoritative view // of this actor, and be distinct per actor even though both actors // were seeded from the same golden snapshot. @@ -137,6 +150,14 @@ func TestActorIdentity_AfterRestore_IsOwnID_NotGolden(t *testing.T) { // calls above deliberately seeded the guest state a suspend records — the // held fd from probe startup plus the freshly indexed file inodes — and the // resume regenerates every file underneath that state. + // + // The trust bundle is rotated first, so the same cycle also proves the + // "bundle contents refresh on every Run/Restore" semantic end to end: the + // resumed actor must observe the NEW sanitized contents at the same path. + // (Live propagation to running actors, without a resume, is #932 PR 2; + // until then a running actor's file is the bundle as of its last + // Run/Restore.) + rotatedTrust := e2e.ReplaceEgressTrustPool(t, ctx, clients, "ate-e2e-probe-trust-rotated") id := ids[0] ref := &ateapipb.ObjectRef{Atespace: probeNamespace, Name: id} if _, err := clients.SubstrateAPI.SuspendActor(ctx, &ateapipb.SuspendActorRequest{Actor: ref}); err != nil { @@ -161,6 +182,9 @@ func TestActorIdentity_AfterRestore_IsOwnID_NotGolden(t *testing.T) { if wantUID := seenUIDFor(t, seenUIDs, id); got.UID != wantUID { t.Errorf("after suspend/resume: /run/ate/actor-uid = %q, want %q (probe read error: %q)", got.UID, wantUID, got.Error) } + if got.Trust != rotatedTrust { + t.Errorf("after suspend/resume: /run/ate/trust-bundle.pem = %q, want the rotated sanitized bundle %q (probe read error: %q)", got.Trust, rotatedTrust, got.Error) + } } // seenUIDFor returns the UID recorded for actor id in the first phase of the diff --git a/internal/e2e/trustbundle.go b/internal/e2e/trustbundle.go new file mode 100644 index 000000000..9b099cb3b --- /dev/null +++ b/internal/e2e/trustbundle.go @@ -0,0 +1,140 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package e2e + +import ( + "context" + "encoding/pem" + "testing" + "time" + + "github.com/agent-substrate/substrate/internal/localca" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// Constants of atecontroller's EgressMITMTrustReconciler (#946): the CA pool +// Secret it watches (the key is what `kubectl-ate admin make-ca-pool` writes) +// and the ClusterTrustBundle it derives from that pool — the backing object +// of the allowlisted "egress-mitm.ate.dev" bundle the probe fixture projects. +// +// Suites provision the POOL and let the real reconciler publish the bundle, +// exercising the whole chain (pool -> reconciler -> bundle -> projection). +// Writing the bundle directly is not an option: the reconciler watches it +// and reverts or deletes hand-written contents. +const ( + // EgressTrustBundleObjectName is the reconciler-owned ClusterTrustBundle. + EgressTrustBundleObjectName = "egress-mitm.ate.dev:mitm:primary-bundle" + + egressCAPoolNamespace = "ate-system" + egressCAPoolSecretName = "egress-mitm-ca-pool" + egressCAPoolSecretKey = "pool" +) + +// EnsureEgressTrustBundle makes sure the egress trust bundle exists: if the +// CA pool Secret is absent it provisions one (registering cleanup), then +// waits until the reconciler-published bundle is non-empty. DeployProbe calls +// this because the probe template projects the bundle and every actor — +// including the fixture's golden boot — fails closed while it is missing; a +// suite that needs to OWN the pool's contents (the identity suite's +// deterministic assertions and rotation) uses ReplaceEgressTrustPool first, +// which this then leaves untouched. +func EnsureEgressTrustBundle(t *testing.T, ctx context.Context, clients *Clients) { + t.Helper() + _, err := clients.K8s.CoreV1().Secrets(egressCAPoolNamespace).Get(ctx, egressCAPoolSecretName, metav1.GetOptions{}) + if err == nil { + waitForEgressTrustBundle(t, ctx, clients, "") + return + } + if !apierrors.IsNotFound(err) { + t.Fatalf("reading CA pool secret %s/%s: %v", egressCAPoolNamespace, egressCAPoolSecretName, err) + } + ReplaceEgressTrustPool(t, ctx, clients, "ate-e2e-trust") +} + +// ReplaceEgressTrustPool creates or replaces the egress CA pool with a fresh +// single-CA pool (the shape `kubectl-ate admin make-ca-pool` creates for the +// egress MITM CA), waits for the reconciler to publish the derived bundle, +// and returns the PEM of the new CA's root certificate — exactly what a +// trustBundle projection must then deliver. cn keeps successive pools +// distinguishable in failure output. Cleanup deletes the Secret, whereupon +// the reconciler deletes the bundle; create-or-replace keeps reruns +// self-healing after a failed prior run. +func ReplaceEgressTrustPool(t *testing.T, ctx context.Context, clients *Clients, cn string) string { + t.Helper() + ca, err := localca.GenerateCA(localca.GenerateOptions{ID: "mitm", CommonName: cn, KeyType: localca.KeyTypeECDSAP256}) + if err != nil { + t.Fatalf("generating CA for the egress pool: %v", err) + } + poolBytes, err := localca.Marshal(&localca.Pool{CAs: []*localca.CA{ca}}) + if err != nil { + t.Fatalf("marshaling the egress pool: %v", err) + } + wantPEM := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: ca.RootCertificate.Raw})) + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Namespace: egressCAPoolNamespace, Name: egressCAPoolSecretName}, + Data: map[string][]byte{egressCAPoolSecretKey: poolBytes}, + } + if _, err := clients.K8s.CoreV1().Secrets(egressCAPoolNamespace).Create(ctx, secret, metav1.CreateOptions{}); err != nil { + if !apierrors.IsAlreadyExists(err) { + t.Fatalf("creating CA pool secret %s/%s: %v", egressCAPoolNamespace, egressCAPoolSecretName, err) + } + existing, getErr := clients.K8s.CoreV1().Secrets(egressCAPoolNamespace).Get(ctx, egressCAPoolSecretName, metav1.GetOptions{}) + if getErr != nil { + t.Fatalf("reading existing CA pool secret: %v", getErr) + } + existing.Data = secret.Data + if _, err := clients.K8s.CoreV1().Secrets(egressCAPoolNamespace).Update(ctx, existing, metav1.UpdateOptions{}); err != nil { + t.Fatalf("updating CA pool secret: %v", err) + } + // The replacer takes over an existing pool without adopting its + // cleanup: whoever created it registered one already. + waitForEgressTrustBundle(t, ctx, clients, wantPEM) + return wantPEM + } + t.Cleanup(func() { + _ = clients.K8s.CoreV1().Secrets(egressCAPoolNamespace).Delete(context.Background(), egressCAPoolSecretName, metav1.DeleteOptions{}) + }) + waitForEgressTrustBundle(t, ctx, clients, wantPEM) + return wantPEM +} + +// waitForEgressTrustBundle polls the reconciler-owned bundle until its +// contents match want, or are merely non-empty when want is "", keeping the +// reconcile latency out of later assertions. Accepted race: this polls the +// apiserver while atelet resolves from its informer cache, but the suites' +// start/resume latency dwarfs watch delivery — if a rotated-bundle +// assertion ever flakes, this lag is the first suspect. +func waitForEgressTrustBundle(t *testing.T, ctx context.Context, clients *Clients, want string) { + t.Helper() + var last string + deadline := time.Now().Add(60 * time.Second) + for time.Now().Before(deadline) { + ctb, err := clients.K8s.CertificatesV1beta1().ClusterTrustBundles().Get(ctx, EgressTrustBundleObjectName, metav1.GetOptions{}) + if err == nil { + if got := ctb.Spec.TrustBundle; got == want || (want == "" && got != "") { + return + } else { + last = got + } + } else { + last = "<" + err.Error() + ">" + } + time.Sleep(1 * time.Second) + } + t.Fatalf("timed out waiting for ClusterTrustBundle %q to carry the pool's root certificate (last observed: %.80q...); is atecontroller's EgressMITMTrustReconciler running?", EgressTrustBundleObjectName, last) +} diff --git a/internal/pemutil/pemutil.go b/internal/pemutil/pemutil.go new file mode 100644 index 000000000..9bd413815 --- /dev/null +++ b/internal/pemutil/pemutil.go @@ -0,0 +1,70 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package pemutil sanitizes PEM certificate bundles for projection into +// actors, the way kubelet does for clusterTrustBundle projected volumes. +package pemutil + +import ( + "encoding/pem" + "fmt" + "math/rand/v2" + "sort" +) + +// SanitizeCertificateBundle re-encodes a PEM bundle keeping only CERTIFICATE +// blocks, with block headers stripped and exact duplicates (by DER bytes) +// removed. It returns an error if the input contains no CERTIFICATE blocks +// at all — an empty trust bundle is never what a workload should silently +// receive. +// +// The anchors are deliberately shuffled, as in kubelet's clustertrustbundle +// manager: order carries no meaning, and a scrambled order keeps consumers +// from growing a dependence on it. +func SanitizeCertificateBundle(in []byte) ([]byte, error) { + seen := map[string]bool{} + var ders []string + rest := in + for { + var block *pem.Block + block, rest = pem.Decode(rest) + if block == nil { + break + } + if block.Type != "CERTIFICATE" { + continue + } + der := string(block.Bytes) + if seen[der] { + continue + } + seen[der] = true + ders = append(ders, der) + } + if len(ders) == 0 { + return nil, fmt.Errorf("bundle contains no CERTIFICATE PEM blocks") + } + + // Sort first so the shuffle's input is independent of source order. + sort.Strings(ders) + rand.Shuffle(len(ders), func(i, j int) { + ders[i], ders[j] = ders[j], ders[i] + }) + + var out []byte + for _, der := range ders { + out = append(out, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: []byte(der)})...) + } + return out, nil +} diff --git a/internal/pemutil/pemutil_test.go b/internal/pemutil/pemutil_test.go new file mode 100644 index 000000000..836fac54b --- /dev/null +++ b/internal/pemutil/pemutil_test.go @@ -0,0 +1,142 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pemutil + +import ( + "bytes" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "testing" + "time" +) + +// selfSignedCertPEM mints a throwaway self-signed certificate, PEM-encoded. +func selfSignedCertPEM(t *testing.T, cn string) []byte { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: cn}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) +} + +func TestSanitizeCertificateBundle(t *testing.T) { + certA := selfSignedCertPEM(t, "a") + certB := selfSignedCertPEM(t, "b") + + junkKey := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: []byte("not-a-cert")}) + withHeaders := func(certPEM []byte) []byte { + block, _ := pem.Decode(certPEM) + block.Headers = map[string]string{"Comment": "should be stripped"} + return pem.EncodeToMemory(block) + } + + t.Run("keeps only certificates, strips headers, dedupes", func(t *testing.T) { + in := bytes.Join([][]byte{ + []byte("leading garbage\n"), + withHeaders(certA), + junkKey, + certB, + certA, // duplicate + }, nil) + got, err := SanitizeCertificateBundle(in) + if err != nil { + t.Fatalf("SanitizeCertificateBundle: %v", err) + } + // Order-insensitive: the anchors are deliberately shuffled. Compare + // the decoded block SET (which also proves headers were stripped — + // re-encoding a block with headers would not match a bare cert). + want := map[string]int{string(certA): 1, string(certB): 1} + if diff := blockCounts(t, got); !mapsEqual(diff, want) { + t.Errorf("sanitized bundle blocks = %v certs, want exactly certA and certB once each", diff) + } + }) + + t.Run("output order is a shuffle, not source order", func(t *testing.T) { + certs := [][]byte{certA, certB, selfSignedCertPEM(t, "c"), selfSignedCertPEM(t, "d")} + in := bytes.Join(certs, nil) + orders := map[string]bool{} + for i := 0; i < 32; i++ { + got, err := SanitizeCertificateBundle(in) + if err != nil { + t.Fatalf("SanitizeCertificateBundle: %v", err) + } + orders[string(got)] = true + } + // 4 anchors have 24 orderings; 32 draws landing on one ordering has + // probability (1/24)^31 — if this fires, the shuffle is gone. + if len(orders) < 2 { + t.Errorf("32 sanitizations produced a single ordering; anchors are no longer shuffled") + } + }) + + t.Run("errors when no certificates present", func(t *testing.T) { + for name, in := range map[string][]byte{ + "empty": nil, + "junk only": junkKey, + "not pem": []byte("hello"), + } { + if _, err := SanitizeCertificateBundle(in); err == nil { + t.Errorf("%s: SanitizeCertificateBundle = nil error, want error", name) + } + } + }) +} + +// blockCounts decodes a PEM stream into a multiset of re-encoded +// header-free CERTIFICATE blocks. +func blockCounts(t *testing.T, in []byte) map[string]int { + t.Helper() + out := map[string]int{} + rest := in + for { + var block *pem.Block + block, rest = pem.Decode(rest) + if block == nil { + return out + } + if len(block.Headers) != 0 { + t.Errorf("block has headers %v, want none", block.Headers) + } + out[string(pem.EncodeToMemory(&pem.Block{Type: block.Type, Bytes: block.Bytes}))]++ + } +} + +func mapsEqual(a, b map[string]int) bool { + if len(a) != len(b) { + return false + } + for k, v := range a { + if b[k] != v { + return false + } + } + return true +} diff --git a/internal/proto/ateletpb/atelet.pb.go b/internal/proto/ateletpb/atelet.pb.go index 84a04444b..86ded9bf8 100644 --- a/internal/proto/ateletpb/atelet.pb.go +++ b/internal/proto/ateletpb/atelet.pb.go @@ -1075,11 +1075,70 @@ func (x *ActorMetadataDataSource) GetItems() []*ActorMetadataItem { return nil } +// TrustBundleDataSource projects the trust anchors of a named trust bundle +// to a file at the given path, relative to the root of the enclosing +// system-info volume. atelet resolves the name against its supported-bundle +// allowlist and reads the backing ClusterTrustBundle through its informer at +// write time, sanitizing kubelet-style; an unsupported name or missing +// bundle fails the actor start. +type TrustBundleDataSource struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TrustBundleDataSource) Reset() { + *x = TrustBundleDataSource{} + mi := &file_atelet_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TrustBundleDataSource) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TrustBundleDataSource) ProtoMessage() {} + +func (x *TrustBundleDataSource) ProtoReflect() protoreflect.Message { + mi := &file_atelet_proto_msgTypes[15] + 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 TrustBundleDataSource.ProtoReflect.Descriptor instead. +func (*TrustBundleDataSource) Descriptor() ([]byte, []int) { + return file_atelet_proto_rawDescGZIP(), []int{15} +} + +func (x *TrustBundleDataSource) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *TrustBundleDataSource) GetName() string { + if x != nil { + return x.Name + } + return "" +} + type SystemInfoDataSource struct { state protoimpl.MessageState `protogen:"open.v1"` // Types that are valid to be assigned to DataSource: // // *SystemInfoDataSource_ActorMetadata + // *SystemInfoDataSource_TrustBundle DataSource isSystemInfoDataSource_DataSource `protobuf_oneof:"data_source"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -1087,7 +1146,7 @@ type SystemInfoDataSource struct { func (x *SystemInfoDataSource) Reset() { *x = SystemInfoDataSource{} - mi := &file_atelet_proto_msgTypes[15] + mi := &file_atelet_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1099,7 +1158,7 @@ func (x *SystemInfoDataSource) String() string { func (*SystemInfoDataSource) ProtoMessage() {} func (x *SystemInfoDataSource) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[15] + mi := &file_atelet_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1112,7 +1171,7 @@ func (x *SystemInfoDataSource) ProtoReflect() protoreflect.Message { // Deprecated: Use SystemInfoDataSource.ProtoReflect.Descriptor instead. func (*SystemInfoDataSource) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{15} + return file_atelet_proto_rawDescGZIP(), []int{16} } func (x *SystemInfoDataSource) GetDataSource() isSystemInfoDataSource_DataSource { @@ -1131,6 +1190,15 @@ func (x *SystemInfoDataSource) GetActorMetadata() *ActorMetadataDataSource { return nil } +func (x *SystemInfoDataSource) GetTrustBundle() *TrustBundleDataSource { + if x != nil { + if x, ok := x.DataSource.(*SystemInfoDataSource_TrustBundle); ok { + return x.TrustBundle + } + } + return nil +} + type isSystemInfoDataSource_DataSource interface { isSystemInfoDataSource_DataSource() } @@ -1139,8 +1207,14 @@ type SystemInfoDataSource_ActorMetadata struct { ActorMetadata *ActorMetadataDataSource `protobuf:"bytes,1,opt,name=actor_metadata,json=actorMetadata,proto3,oneof"` } +type SystemInfoDataSource_TrustBundle struct { + TrustBundle *TrustBundleDataSource `protobuf:"bytes,2,opt,name=trust_bundle,json=trustBundle,proto3,oneof"` +} + func (*SystemInfoDataSource_ActorMetadata) isSystemInfoDataSource_DataSource() {} +func (*SystemInfoDataSource_TrustBundle) isSystemInfoDataSource_DataSource() {} + // SystemInfoVolume is a read-only volume whose files are generated by atelet // on every Run/Restore, so they carry the values of the actor actually being // started, whatever checkpointed state it boots from. @@ -1153,7 +1227,7 @@ type SystemInfoVolume struct { func (x *SystemInfoVolume) Reset() { *x = SystemInfoVolume{} - mi := &file_atelet_proto_msgTypes[16] + mi := &file_atelet_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1165,7 +1239,7 @@ func (x *SystemInfoVolume) String() string { func (*SystemInfoVolume) ProtoMessage() {} func (x *SystemInfoVolume) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[16] + mi := &file_atelet_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1178,7 +1252,7 @@ func (x *SystemInfoVolume) ProtoReflect() protoreflect.Message { // Deprecated: Use SystemInfoVolume.ProtoReflect.Descriptor instead. func (*SystemInfoVolume) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{16} + return file_atelet_proto_rawDescGZIP(), []int{17} } func (x *SystemInfoVolume) GetDataSources() []*SystemInfoDataSource { @@ -1204,7 +1278,7 @@ type Volume struct { func (x *Volume) Reset() { *x = Volume{} - mi := &file_atelet_proto_msgTypes[17] + mi := &file_atelet_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1216,7 +1290,7 @@ func (x *Volume) String() string { func (*Volume) ProtoMessage() {} func (x *Volume) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[17] + mi := &file_atelet_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1229,7 +1303,7 @@ func (x *Volume) ProtoReflect() protoreflect.Message { // Deprecated: Use Volume.ProtoReflect.Descriptor instead. func (*Volume) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{17} + return file_atelet_proto_rawDescGZIP(), []int{18} } func (x *Volume) GetName() string { @@ -1320,7 +1394,7 @@ type VolumeMount struct { func (x *VolumeMount) Reset() { *x = VolumeMount{} - mi := &file_atelet_proto_msgTypes[18] + mi := &file_atelet_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1332,7 +1406,7 @@ func (x *VolumeMount) String() string { func (*VolumeMount) ProtoMessage() {} func (x *VolumeMount) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[18] + mi := &file_atelet_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1345,7 +1419,7 @@ func (x *VolumeMount) ProtoReflect() protoreflect.Message { // Deprecated: Use VolumeMount.ProtoReflect.Descriptor instead. func (*VolumeMount) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{18} + return file_atelet_proto_rawDescGZIP(), []int{19} } func (x *VolumeMount) GetName() string { @@ -1378,7 +1452,7 @@ type Container struct { func (x *Container) Reset() { *x = Container{} - mi := &file_atelet_proto_msgTypes[19] + mi := &file_atelet_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1390,7 +1464,7 @@ func (x *Container) String() string { func (*Container) ProtoMessage() {} func (x *Container) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[19] + mi := &file_atelet_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1403,7 +1477,7 @@ func (x *Container) ProtoReflect() protoreflect.Message { // Deprecated: Use Container.ProtoReflect.Descriptor instead. func (*Container) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{19} + return file_atelet_proto_rawDescGZIP(), []int{20} } func (x *Container) GetName() string { @@ -1472,7 +1546,7 @@ type SecurityContext struct { func (x *SecurityContext) Reset() { *x = SecurityContext{} - mi := &file_atelet_proto_msgTypes[20] + mi := &file_atelet_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1484,7 +1558,7 @@ func (x *SecurityContext) String() string { func (*SecurityContext) ProtoMessage() {} func (x *SecurityContext) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[20] + mi := &file_atelet_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1497,7 +1571,7 @@ func (x *SecurityContext) ProtoReflect() protoreflect.Message { // Deprecated: Use SecurityContext.ProtoReflect.Descriptor instead. func (*SecurityContext) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{20} + return file_atelet_proto_rawDescGZIP(), []int{21} } func (x *SecurityContext) GetCapabilities() *Capabilities { @@ -1519,7 +1593,7 @@ type Capabilities struct { func (x *Capabilities) Reset() { *x = Capabilities{} - mi := &file_atelet_proto_msgTypes[21] + mi := &file_atelet_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1531,7 +1605,7 @@ func (x *Capabilities) String() string { func (*Capabilities) ProtoMessage() {} func (x *Capabilities) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[21] + mi := &file_atelet_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1544,7 +1618,7 @@ func (x *Capabilities) ProtoReflect() protoreflect.Message { // Deprecated: Use Capabilities.ProtoReflect.Descriptor instead. func (*Capabilities) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{21} + return file_atelet_proto_rawDescGZIP(), []int{22} } func (x *Capabilities) GetAdd() []string { @@ -1571,7 +1645,7 @@ type EnvEntry struct { func (x *EnvEntry) Reset() { *x = EnvEntry{} - mi := &file_atelet_proto_msgTypes[22] + mi := &file_atelet_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1583,7 +1657,7 @@ func (x *EnvEntry) String() string { func (*EnvEntry) ProtoMessage() {} func (x *EnvEntry) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[22] + mi := &file_atelet_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1596,7 +1670,7 @@ func (x *EnvEntry) ProtoReflect() protoreflect.Message { // Deprecated: Use EnvEntry.ProtoReflect.Descriptor instead. func (*EnvEntry) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{22} + return file_atelet_proto_rawDescGZIP(), []int{23} } func (x *EnvEntry) GetName() string { @@ -1627,7 +1701,7 @@ type Readyz struct { func (x *Readyz) Reset() { *x = Readyz{} - mi := &file_atelet_proto_msgTypes[23] + mi := &file_atelet_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1639,7 +1713,7 @@ func (x *Readyz) String() string { func (*Readyz) ProtoMessage() {} func (x *Readyz) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[23] + mi := &file_atelet_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1652,7 +1726,7 @@ func (x *Readyz) ProtoReflect() protoreflect.Message { // Deprecated: Use Readyz.ProtoReflect.Descriptor instead. func (*Readyz) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{23} + return file_atelet_proto_rawDescGZIP(), []int{24} } func (x *Readyz) GetHttpGet() *HTTPGetAction { @@ -1682,7 +1756,7 @@ type HTTPGetAction struct { func (x *HTTPGetAction) Reset() { *x = HTTPGetAction{} - mi := &file_atelet_proto_msgTypes[24] + mi := &file_atelet_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1694,7 +1768,7 @@ func (x *HTTPGetAction) String() string { func (*HTTPGetAction) ProtoMessage() {} func (x *HTTPGetAction) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[24] + mi := &file_atelet_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1707,7 +1781,7 @@ func (x *HTTPGetAction) ProtoReflect() protoreflect.Message { // Deprecated: Use HTTPGetAction.ProtoReflect.Descriptor instead. func (*HTTPGetAction) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{24} + return file_atelet_proto_rawDescGZIP(), []int{25} } func (x *HTTPGetAction) GetPath() string { @@ -1732,7 +1806,7 @@ type RunResponse struct { func (x *RunResponse) Reset() { *x = RunResponse{} - mi := &file_atelet_proto_msgTypes[25] + mi := &file_atelet_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1744,7 +1818,7 @@ func (x *RunResponse) String() string { func (*RunResponse) ProtoMessage() {} func (x *RunResponse) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[25] + mi := &file_atelet_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1757,7 +1831,7 @@ func (x *RunResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RunResponse.ProtoReflect.Descriptor instead. func (*RunResponse) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{25} + return file_atelet_proto_rawDescGZIP(), []int{26} } type LocalCheckpointConfiguration struct { @@ -1773,7 +1847,7 @@ type LocalCheckpointConfiguration struct { func (x *LocalCheckpointConfiguration) Reset() { *x = LocalCheckpointConfiguration{} - mi := &file_atelet_proto_msgTypes[26] + mi := &file_atelet_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1785,7 +1859,7 @@ func (x *LocalCheckpointConfiguration) String() string { func (*LocalCheckpointConfiguration) ProtoMessage() {} func (x *LocalCheckpointConfiguration) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[26] + mi := &file_atelet_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1798,7 +1872,7 @@ func (x *LocalCheckpointConfiguration) ProtoReflect() protoreflect.Message { // Deprecated: Use LocalCheckpointConfiguration.ProtoReflect.Descriptor instead. func (*LocalCheckpointConfiguration) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{26} + return file_atelet_proto_rawDescGZIP(), []int{27} } func (x *LocalCheckpointConfiguration) GetSnapshotName() string { @@ -1819,7 +1893,7 @@ type ExternalCheckpointConfiguration struct { func (x *ExternalCheckpointConfiguration) Reset() { *x = ExternalCheckpointConfiguration{} - mi := &file_atelet_proto_msgTypes[27] + mi := &file_atelet_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1831,7 +1905,7 @@ func (x *ExternalCheckpointConfiguration) String() string { func (*ExternalCheckpointConfiguration) ProtoMessage() {} func (x *ExternalCheckpointConfiguration) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[27] + mi := &file_atelet_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1844,7 +1918,7 @@ func (x *ExternalCheckpointConfiguration) ProtoReflect() protoreflect.Message { // Deprecated: Use ExternalCheckpointConfiguration.ProtoReflect.Descriptor instead. func (*ExternalCheckpointConfiguration) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{27} + return file_atelet_proto_rawDescGZIP(), []int{28} } func (x *ExternalCheckpointConfiguration) GetSnapshotUri() string { @@ -1882,7 +1956,7 @@ type CheckpointRequest struct { func (x *CheckpointRequest) Reset() { *x = CheckpointRequest{} - mi := &file_atelet_proto_msgTypes[28] + mi := &file_atelet_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1894,7 +1968,7 @@ func (x *CheckpointRequest) String() string { func (*CheckpointRequest) ProtoMessage() {} func (x *CheckpointRequest) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[28] + mi := &file_atelet_proto_msgTypes[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1907,7 +1981,7 @@ func (x *CheckpointRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CheckpointRequest.ProtoReflect.Descriptor instead. func (*CheckpointRequest) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{28} + return file_atelet_proto_rawDescGZIP(), []int{29} } func (x *CheckpointRequest) GetTargetAteomUid() string { @@ -2022,7 +2096,7 @@ type CheckpointResponse struct { func (x *CheckpointResponse) Reset() { *x = CheckpointResponse{} - mi := &file_atelet_proto_msgTypes[29] + mi := &file_atelet_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2034,7 +2108,7 @@ func (x *CheckpointResponse) String() string { func (*CheckpointResponse) ProtoMessage() {} func (x *CheckpointResponse) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[29] + mi := &file_atelet_proto_msgTypes[30] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2047,7 +2121,7 @@ func (x *CheckpointResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CheckpointResponse.ProtoReflect.Descriptor instead. func (*CheckpointResponse) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{29} + return file_atelet_proto_rawDescGZIP(), []int{30} } type UploadPausedCheckpointRequest struct { @@ -2075,7 +2149,7 @@ type UploadPausedCheckpointRequest struct { func (x *UploadPausedCheckpointRequest) Reset() { *x = UploadPausedCheckpointRequest{} - mi := &file_atelet_proto_msgTypes[30] + mi := &file_atelet_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2087,7 +2161,7 @@ func (x *UploadPausedCheckpointRequest) String() string { func (*UploadPausedCheckpointRequest) ProtoMessage() {} func (x *UploadPausedCheckpointRequest) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[30] + mi := &file_atelet_proto_msgTypes[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2100,7 +2174,7 @@ func (x *UploadPausedCheckpointRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UploadPausedCheckpointRequest.ProtoReflect.Descriptor instead. func (*UploadPausedCheckpointRequest) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{30} + return file_atelet_proto_rawDescGZIP(), []int{31} } func (x *UploadPausedCheckpointRequest) GetAtespace() string { @@ -2167,7 +2241,7 @@ type UploadPausedCheckpointResponse struct { func (x *UploadPausedCheckpointResponse) Reset() { *x = UploadPausedCheckpointResponse{} - mi := &file_atelet_proto_msgTypes[31] + mi := &file_atelet_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2179,7 +2253,7 @@ func (x *UploadPausedCheckpointResponse) String() string { func (*UploadPausedCheckpointResponse) ProtoMessage() {} func (x *UploadPausedCheckpointResponse) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[31] + mi := &file_atelet_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2192,7 +2266,7 @@ func (x *UploadPausedCheckpointResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UploadPausedCheckpointResponse.ProtoReflect.Descriptor instead. func (*UploadPausedCheckpointResponse) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{31} + return file_atelet_proto_rawDescGZIP(), []int{32} } type RestoreRequest struct { @@ -2238,7 +2312,7 @@ type RestoreRequest struct { func (x *RestoreRequest) Reset() { *x = RestoreRequest{} - mi := &file_atelet_proto_msgTypes[32] + mi := &file_atelet_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2250,7 +2324,7 @@ func (x *RestoreRequest) String() string { func (*RestoreRequest) ProtoMessage() {} func (x *RestoreRequest) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[32] + mi := &file_atelet_proto_msgTypes[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2263,7 +2337,7 @@ func (x *RestoreRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RestoreRequest.ProtoReflect.Descriptor instead. func (*RestoreRequest) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{32} + return file_atelet_proto_rawDescGZIP(), []int{33} } func (x *RestoreRequest) GetTargetAteomUid() string { @@ -2406,7 +2480,7 @@ type RestoreResponse struct { func (x *RestoreResponse) Reset() { *x = RestoreResponse{} - mi := &file_atelet_proto_msgTypes[33] + mi := &file_atelet_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2418,7 +2492,7 @@ func (x *RestoreResponse) String() string { func (*RestoreResponse) ProtoMessage() {} func (x *RestoreResponse) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[33] + mi := &file_atelet_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2431,7 +2505,7 @@ func (x *RestoreResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RestoreResponse.ProtoReflect.Descriptor instead. func (*RestoreResponse) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{33} + return file_atelet_proto_rawDescGZIP(), []int{34} } var File_atelet_proto protoreflect.FileDescriptor @@ -2510,9 +2584,13 @@ const file_atelet_proto_rawDesc = "" + "\x05field\x18\x01 \x01(\x0e2\x1a.atelet.ActorMetadataFieldR\x05field\x12\x12\n" + "\x04path\x18\x02 \x01(\tR\x04path\"J\n" + "\x17ActorMetadataDataSource\x12/\n" + - "\x05items\x18\x01 \x03(\v2\x19.atelet.ActorMetadataItemR\x05items\"o\n" + + "\x05items\x18\x01 \x03(\v2\x19.atelet.ActorMetadataItemR\x05items\"?\n" + + "\x15TrustBundleDataSource\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\"\xb3\x01\n" + "\x14SystemInfoDataSource\x12H\n" + - "\x0eactor_metadata\x18\x01 \x01(\v2\x1f.atelet.ActorMetadataDataSourceH\x00R\ractorMetadataB\r\n" + + "\x0eactor_metadata\x18\x01 \x01(\v2\x1f.atelet.ActorMetadataDataSourceH\x00R\ractorMetadata\x12B\n" + + "\ftrust_bundle\x18\x02 \x01(\v2\x1d.atelet.TrustBundleDataSourceH\x00R\vtrustBundleB\r\n" + "\vdata_source\"S\n" + "\x10SystemInfoVolume\x12?\n" + "\fdata_sources\x18\x01 \x03(\v2\x1c.atelet.SystemInfoDataSourceR\vdataSources\"\x8f\x02\n" + @@ -2642,7 +2720,7 @@ func file_atelet_proto_rawDescGZIP() []byte { } var file_atelet_proto_enumTypes = make([]protoimpl.EnumInfo, 3) -var file_atelet_proto_msgTypes = make([]protoimpl.MessageInfo, 37) +var file_atelet_proto_msgTypes = make([]protoimpl.MessageInfo, 38) var file_atelet_proto_goTypes = []any{ (ActorMetadataField)(0), // 0: atelet.ActorMetadataField (CheckpointType)(0), // 1: atelet.CheckpointType @@ -2662,84 +2740,86 @@ var file_atelet_proto_goTypes = []any{ (*ImageVolumeSource)(nil), // 15: atelet.ImageVolumeSource (*ActorMetadataItem)(nil), // 16: atelet.ActorMetadataItem (*ActorMetadataDataSource)(nil), // 17: atelet.ActorMetadataDataSource - (*SystemInfoDataSource)(nil), // 18: atelet.SystemInfoDataSource - (*SystemInfoVolume)(nil), // 19: atelet.SystemInfoVolume - (*Volume)(nil), // 20: atelet.Volume - (*VolumeMount)(nil), // 21: atelet.VolumeMount - (*Container)(nil), // 22: atelet.Container - (*SecurityContext)(nil), // 23: atelet.SecurityContext - (*Capabilities)(nil), // 24: atelet.Capabilities - (*EnvEntry)(nil), // 25: atelet.EnvEntry - (*Readyz)(nil), // 26: atelet.Readyz - (*HTTPGetAction)(nil), // 27: atelet.HTTPGetAction - (*RunResponse)(nil), // 28: atelet.RunResponse - (*LocalCheckpointConfiguration)(nil), // 29: atelet.LocalCheckpointConfiguration - (*ExternalCheckpointConfiguration)(nil), // 30: atelet.ExternalCheckpointConfiguration - (*CheckpointRequest)(nil), // 31: atelet.CheckpointRequest - (*CheckpointResponse)(nil), // 32: atelet.CheckpointResponse - (*UploadPausedCheckpointRequest)(nil), // 33: atelet.UploadPausedCheckpointRequest - (*UploadPausedCheckpointResponse)(nil), // 34: atelet.UploadPausedCheckpointResponse - (*RestoreRequest)(nil), // 35: atelet.RestoreRequest - (*RestoreResponse)(nil), // 36: atelet.RestoreResponse - nil, // 37: atelet.ArchAssets.FilesEntry - nil, // 38: atelet.SandboxAssets.AssetsEntry - nil, // 39: atelet.ExternalVolumeSource.VolumeContextEntry + (*TrustBundleDataSource)(nil), // 18: atelet.TrustBundleDataSource + (*SystemInfoDataSource)(nil), // 19: atelet.SystemInfoDataSource + (*SystemInfoVolume)(nil), // 20: atelet.SystemInfoVolume + (*Volume)(nil), // 21: atelet.Volume + (*VolumeMount)(nil), // 22: atelet.VolumeMount + (*Container)(nil), // 23: atelet.Container + (*SecurityContext)(nil), // 24: atelet.SecurityContext + (*Capabilities)(nil), // 25: atelet.Capabilities + (*EnvEntry)(nil), // 26: atelet.EnvEntry + (*Readyz)(nil), // 27: atelet.Readyz + (*HTTPGetAction)(nil), // 28: atelet.HTTPGetAction + (*RunResponse)(nil), // 29: atelet.RunResponse + (*LocalCheckpointConfiguration)(nil), // 30: atelet.LocalCheckpointConfiguration + (*ExternalCheckpointConfiguration)(nil), // 31: atelet.ExternalCheckpointConfiguration + (*CheckpointRequest)(nil), // 32: atelet.CheckpointRequest + (*CheckpointResponse)(nil), // 33: atelet.CheckpointResponse + (*UploadPausedCheckpointRequest)(nil), // 34: atelet.UploadPausedCheckpointRequest + (*UploadPausedCheckpointResponse)(nil), // 35: atelet.UploadPausedCheckpointResponse + (*RestoreRequest)(nil), // 36: atelet.RestoreRequest + (*RestoreResponse)(nil), // 37: atelet.RestoreResponse + nil, // 38: atelet.ArchAssets.FilesEntry + nil, // 39: atelet.SandboxAssets.AssetsEntry + nil, // 40: atelet.ExternalVolumeSource.VolumeContextEntry } var file_atelet_proto_depIdxs = []int32{ 12, // 0: atelet.TerminateRequest.spec:type_name -> atelet.WorkloadSpec 12, // 1: atelet.RunRequest.spec:type_name -> atelet.WorkloadSpec 11, // 2: atelet.RunRequest.sandbox_assets:type_name -> atelet.SandboxAssets 8, // 3: atelet.RunRequest.egress_gateway:type_name -> atelet.EgressGateway - 37, // 4: atelet.ArchAssets.files:type_name -> atelet.ArchAssets.FilesEntry - 38, // 5: atelet.SandboxAssets.assets:type_name -> atelet.SandboxAssets.AssetsEntry - 22, // 6: atelet.WorkloadSpec.containers:type_name -> atelet.Container - 20, // 7: atelet.WorkloadSpec.volumes:type_name -> atelet.Volume - 39, // 8: atelet.ExternalVolumeSource.volume_context:type_name -> atelet.ExternalVolumeSource.VolumeContextEntry + 38, // 4: atelet.ArchAssets.files:type_name -> atelet.ArchAssets.FilesEntry + 39, // 5: atelet.SandboxAssets.assets:type_name -> atelet.SandboxAssets.AssetsEntry + 23, // 6: atelet.WorkloadSpec.containers:type_name -> atelet.Container + 21, // 7: atelet.WorkloadSpec.volumes:type_name -> atelet.Volume + 40, // 8: atelet.ExternalVolumeSource.volume_context:type_name -> atelet.ExternalVolumeSource.VolumeContextEntry 0, // 9: atelet.ActorMetadataItem.field:type_name -> atelet.ActorMetadataField 16, // 10: atelet.ActorMetadataDataSource.items:type_name -> atelet.ActorMetadataItem 17, // 11: atelet.SystemInfoDataSource.actor_metadata:type_name -> atelet.ActorMetadataDataSource - 18, // 12: atelet.SystemInfoVolume.data_sources:type_name -> atelet.SystemInfoDataSource - 13, // 13: atelet.Volume.durable_dir:type_name -> atelet.DurableDirVolume - 14, // 14: atelet.Volume.external:type_name -> atelet.ExternalVolumeSource - 19, // 15: atelet.Volume.system_info:type_name -> atelet.SystemInfoVolume - 15, // 16: atelet.Volume.image:type_name -> atelet.ImageVolumeSource - 25, // 17: atelet.Container.env:type_name -> atelet.EnvEntry - 26, // 18: atelet.Container.readyz:type_name -> atelet.Readyz - 21, // 19: atelet.Container.volume_mounts:type_name -> atelet.VolumeMount - 23, // 20: atelet.Container.security_context:type_name -> atelet.SecurityContext - 24, // 21: atelet.SecurityContext.capabilities:type_name -> atelet.Capabilities - 27, // 22: atelet.Readyz.http_get:type_name -> atelet.HTTPGetAction - 12, // 23: atelet.CheckpointRequest.spec:type_name -> atelet.WorkloadSpec - 1, // 24: atelet.CheckpointRequest.type:type_name -> atelet.CheckpointType - 29, // 25: atelet.CheckpointRequest.local_config:type_name -> atelet.LocalCheckpointConfiguration - 30, // 26: atelet.CheckpointRequest.external_config:type_name -> atelet.ExternalCheckpointConfiguration - 2, // 27: atelet.CheckpointRequest.scope:type_name -> atelet.SnapshotScope - 2, // 28: atelet.UploadPausedCheckpointRequest.desired_scope:type_name -> atelet.SnapshotScope - 12, // 29: atelet.RestoreRequest.spec:type_name -> atelet.WorkloadSpec - 1, // 30: atelet.RestoreRequest.type:type_name -> atelet.CheckpointType - 29, // 31: atelet.RestoreRequest.local_config:type_name -> atelet.LocalCheckpointConfiguration - 30, // 32: atelet.RestoreRequest.external_config:type_name -> atelet.ExternalCheckpointConfiguration - 2, // 33: atelet.RestoreRequest.scope:type_name -> atelet.SnapshotScope - 8, // 34: atelet.RestoreRequest.egress_gateway:type_name -> atelet.EgressGateway - 9, // 35: atelet.ArchAssets.FilesEntry.value:type_name -> atelet.AssetFile - 10, // 36: atelet.SandboxAssets.AssetsEntry.value:type_name -> atelet.ArchAssets - 3, // 37: atelet.CredentialBroker.MintActorCertificate:input_type -> atelet.MintActorCertificateRequest - 7, // 38: atelet.AteomHerder.Run:input_type -> atelet.RunRequest - 31, // 39: atelet.AteomHerder.Checkpoint:input_type -> atelet.CheckpointRequest - 35, // 40: atelet.AteomHerder.Restore:input_type -> atelet.RestoreRequest - 33, // 41: atelet.AteomHerder.UploadPausedCheckpoint:input_type -> atelet.UploadPausedCheckpointRequest - 5, // 42: atelet.AteomHerder.Terminate:input_type -> atelet.TerminateRequest - 4, // 43: atelet.CredentialBroker.MintActorCertificate:output_type -> atelet.MintActorCertificateResponse - 28, // 44: atelet.AteomHerder.Run:output_type -> atelet.RunResponse - 32, // 45: atelet.AteomHerder.Checkpoint:output_type -> atelet.CheckpointResponse - 36, // 46: atelet.AteomHerder.Restore:output_type -> atelet.RestoreResponse - 34, // 47: atelet.AteomHerder.UploadPausedCheckpoint:output_type -> atelet.UploadPausedCheckpointResponse - 6, // 48: atelet.AteomHerder.Terminate:output_type -> atelet.TerminateResponse - 43, // [43:49] is the sub-list for method output_type - 37, // [37:43] is the sub-list for method input_type - 37, // [37:37] is the sub-list for extension type_name - 37, // [37:37] is the sub-list for extension extendee - 0, // [0:37] is the sub-list for field type_name + 18, // 12: atelet.SystemInfoDataSource.trust_bundle:type_name -> atelet.TrustBundleDataSource + 19, // 13: atelet.SystemInfoVolume.data_sources:type_name -> atelet.SystemInfoDataSource + 13, // 14: atelet.Volume.durable_dir:type_name -> atelet.DurableDirVolume + 14, // 15: atelet.Volume.external:type_name -> atelet.ExternalVolumeSource + 20, // 16: atelet.Volume.system_info:type_name -> atelet.SystemInfoVolume + 15, // 17: atelet.Volume.image:type_name -> atelet.ImageVolumeSource + 26, // 18: atelet.Container.env:type_name -> atelet.EnvEntry + 27, // 19: atelet.Container.readyz:type_name -> atelet.Readyz + 22, // 20: atelet.Container.volume_mounts:type_name -> atelet.VolumeMount + 24, // 21: atelet.Container.security_context:type_name -> atelet.SecurityContext + 25, // 22: atelet.SecurityContext.capabilities:type_name -> atelet.Capabilities + 28, // 23: atelet.Readyz.http_get:type_name -> atelet.HTTPGetAction + 12, // 24: atelet.CheckpointRequest.spec:type_name -> atelet.WorkloadSpec + 1, // 25: atelet.CheckpointRequest.type:type_name -> atelet.CheckpointType + 30, // 26: atelet.CheckpointRequest.local_config:type_name -> atelet.LocalCheckpointConfiguration + 31, // 27: atelet.CheckpointRequest.external_config:type_name -> atelet.ExternalCheckpointConfiguration + 2, // 28: atelet.CheckpointRequest.scope:type_name -> atelet.SnapshotScope + 2, // 29: atelet.UploadPausedCheckpointRequest.desired_scope:type_name -> atelet.SnapshotScope + 12, // 30: atelet.RestoreRequest.spec:type_name -> atelet.WorkloadSpec + 1, // 31: atelet.RestoreRequest.type:type_name -> atelet.CheckpointType + 30, // 32: atelet.RestoreRequest.local_config:type_name -> atelet.LocalCheckpointConfiguration + 31, // 33: atelet.RestoreRequest.external_config:type_name -> atelet.ExternalCheckpointConfiguration + 2, // 34: atelet.RestoreRequest.scope:type_name -> atelet.SnapshotScope + 8, // 35: atelet.RestoreRequest.egress_gateway:type_name -> atelet.EgressGateway + 9, // 36: atelet.ArchAssets.FilesEntry.value:type_name -> atelet.AssetFile + 10, // 37: atelet.SandboxAssets.AssetsEntry.value:type_name -> atelet.ArchAssets + 3, // 38: atelet.CredentialBroker.MintActorCertificate:input_type -> atelet.MintActorCertificateRequest + 7, // 39: atelet.AteomHerder.Run:input_type -> atelet.RunRequest + 32, // 40: atelet.AteomHerder.Checkpoint:input_type -> atelet.CheckpointRequest + 36, // 41: atelet.AteomHerder.Restore:input_type -> atelet.RestoreRequest + 34, // 42: atelet.AteomHerder.UploadPausedCheckpoint:input_type -> atelet.UploadPausedCheckpointRequest + 5, // 43: atelet.AteomHerder.Terminate:input_type -> atelet.TerminateRequest + 4, // 44: atelet.CredentialBroker.MintActorCertificate:output_type -> atelet.MintActorCertificateResponse + 29, // 45: atelet.AteomHerder.Run:output_type -> atelet.RunResponse + 33, // 46: atelet.AteomHerder.Checkpoint:output_type -> atelet.CheckpointResponse + 37, // 47: atelet.AteomHerder.Restore:output_type -> atelet.RestoreResponse + 35, // 48: atelet.AteomHerder.UploadPausedCheckpoint:output_type -> atelet.UploadPausedCheckpointResponse + 6, // 49: atelet.AteomHerder.Terminate:output_type -> atelet.TerminateResponse + 44, // [44:50] is the sub-list for method output_type + 38, // [38:44] is the sub-list for method input_type + 38, // [38:38] is the sub-list for extension type_name + 38, // [38:38] is the sub-list for extension extendee + 0, // [0:38] is the sub-list for field type_name } func init() { file_atelet_proto_init() } @@ -2748,20 +2828,21 @@ func file_atelet_proto_init() { return } file_atelet_proto_msgTypes[4].OneofWrappers = []any{} - file_atelet_proto_msgTypes[15].OneofWrappers = []any{ + file_atelet_proto_msgTypes[16].OneofWrappers = []any{ (*SystemInfoDataSource_ActorMetadata)(nil), + (*SystemInfoDataSource_TrustBundle)(nil), } - file_atelet_proto_msgTypes[17].OneofWrappers = []any{ + file_atelet_proto_msgTypes[18].OneofWrappers = []any{ (*Volume_DurableDir)(nil), (*Volume_External)(nil), (*Volume_SystemInfo)(nil), (*Volume_Image)(nil), } - file_atelet_proto_msgTypes[28].OneofWrappers = []any{ + file_atelet_proto_msgTypes[29].OneofWrappers = []any{ (*CheckpointRequest_LocalConfig)(nil), (*CheckpointRequest_ExternalConfig)(nil), } - file_atelet_proto_msgTypes[32].OneofWrappers = []any{ + file_atelet_proto_msgTypes[33].OneofWrappers = []any{ (*RestoreRequest_LocalConfig)(nil), (*RestoreRequest_ExternalConfig)(nil), } @@ -2771,7 +2852,7 @@ func file_atelet_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_atelet_proto_rawDesc), len(file_atelet_proto_rawDesc)), NumEnums: 3, - NumMessages: 37, + NumMessages: 38, NumExtensions: 0, NumServices: 2, }, diff --git a/internal/proto/ateletpb/atelet.proto b/internal/proto/ateletpb/atelet.proto index d21e526c2..c64a6b0cf 100644 --- a/internal/proto/ateletpb/atelet.proto +++ b/internal/proto/ateletpb/atelet.proto @@ -186,9 +186,21 @@ message ActorMetadataDataSource { repeated ActorMetadataItem items = 1; } +// TrustBundleDataSource projects the trust anchors of a named trust bundle +// to a file at the given path, relative to the root of the enclosing +// system-info volume. atelet resolves the name against its supported-bundle +// allowlist and reads the backing ClusterTrustBundle through its informer at +// write time, sanitizing kubelet-style; an unsupported name or missing +// bundle fails the actor start. +message TrustBundleDataSource { + string path = 1; + string name = 2; +} + message SystemInfoDataSource { oneof data_source { ActorMetadataDataSource actor_metadata = 1; + TrustBundleDataSource trust_bundle = 2; } } diff --git a/manifests/ate-install/atelet.yaml b/manifests/ate-install/atelet.yaml index a484498ad..71a84ed61 100644 --- a/manifests/ate-install/atelet.yaml +++ b/manifests/ate-install/atelet.yaml @@ -31,6 +31,12 @@ rules: - apiGroups: ["ate.dev"] resources: ["csidriverconfigs"] verbs: ["get", "list", "watch"] +# ClusterTrustBundles referenced by SystemInfo trustBundle data sources are +# resolved on the node: atelet reads them through an informer and projects +# the sanitized PEM into actors (see cmd/atelet/trustbundle.go). +- apiGroups: ["certificates.k8s.io"] + resources: ["clustertrustbundles"] + verbs: ["get", "watch", "list"] --- # 3. Bind Identity to Permissions apiVersion: rbac.authorization.k8s.io/v1 diff --git a/manifests/ate-install/generated/ate.dev_actortemplates.yaml b/manifests/ate-install/generated/ate.dev_actortemplates.yaml index 1bd443061..188ed1d66 100644 --- a/manifests/ate-install/generated/ate.dev_actortemplates.yaml +++ b/manifests/ate-install/generated/ate.dev_actortemplates.yaml @@ -498,9 +498,9 @@ spec: DataSources is the list of data sources to place within the SystemInfo volume. - At most one actorMetadata entry may appear; this is what keeps file - paths unique across the whole volume (uniqueness within the entry is - enforced on its items). + At most one actorMetadata entry may appear, and file paths must be + unique across all entries (uniqueness within actorMetadata is enforced + on its items). items: description: |- SystemInfoDataSource is a container allowing you to pick a particular @@ -535,17 +535,18 @@ spec: path: description: |- Relative path from the root of the SystemInfo volume at which the - field's value is written. Must be a clean relative Unix path: must not - start or end with '/', and contain no ':', '..', '.', '//', or control - characters. + field's value is written. Must be a clean relative Unix path: it must + not start or end with '/' and must not contain ':', '//', '.' or '..' + segments, or control characters. maxLength: 255 minLength: 1 type: string x-kubernetes-validations: - message: 'path must be a clean relative - Unix path: must not start or end with - ''/'', and contain no '':'', ''..'', - ''.'', ''//'', or control characters' + Unix path: it must not start or end + with ''/'' and must not contain '':'', + ''//'', ''.'' or ''..'' segments, or + control characters' rule: '!self.startsWith(''/'') && !self.endsWith(''/'') && !self.contains(''//'') && !self.contains('':'') && !self.matches(''[\x00-\x1f\x7f]'') @@ -568,18 +569,76 @@ spec: required: - items type: object + trustBundle: + description: |- + TrustBundleDataSource is a SystemInfo volume data source that projects the + trust anchors of a named trust bundle to a single PEM file — inspired by + the Kubernetes clusterTrustBundle projected volume source, but + source-neutral: the name selects a bundle substrate knows how to fetch, + and where it is fetched from is a substrate deployment concern, not part + of this API (atelet enforces the supported set and resolves the backend). + + Supported names are allowlisted in atelet. Initially the only supported + bundle is "egress-mitm.ate.dev" (the egress gateway CA bundle), resolved + from the Kubernetes ClusterTrustBundle (certificates.k8s.io/v1beta1) that + atecontroller derives from the egress-mitm-ca-pool; a configurable backend + registry may widen this later. + + The bundle is resolved and sanitized on the node when the actor starts: + atelet reads the backing object through a cluster-wide watch and keeps + only CERTIFICATE PEM blocks, deduplicated and deliberately shuffled (order + carries no meaning); the actor itself never talks to any bundle backend. + Starting the actor fails if the named bundle is not on the allowlist, its + backend is unavailable in this deployment, or the resolved bundle is + missing, empty, or unparseable. + properties: + name: + description: |- + Name of the trust bundle to project. Must be a bundle name supported + by this deployment (currently only "egress-mitm.ate.dev"). + maxLength: 253 + minLength: 1 + type: string + path: + description: |- + Relative path from the root of the SystemInfo volume at which the PEM + bundle is written. Must be a clean relative Unix path: it must not + start or end with '/' and must not contain ':', '//', '.' or '..' + segments, or control characters. + maxLength: 255 + minLength: 1 + type: string + x-kubernetes-validations: + - message: 'path must be a clean relative Unix + path: it must not start or end with ''/'' + and must not contain '':'', ''//'', ''.'' + or ''..'' segments, or control characters' + rule: '!self.startsWith(''/'') && !self.endsWith(''/'') + && !self.contains(''//'') && !self.contains('':'') + && !self.matches(''[\x00-\x1f\x7f]'') && !self.matches(''(^|/)[.][.]?(/|$)'')' + required: + - name + - path + type: object type: object x-kubernetes-validations: - - message: exactly one of the fields in [actorMetadata] - must be set - rule: '[has(self.actorMetadata)].filter(x,x==true).size() + - message: exactly one of the fields in [actorMetadata + trustBundle] must be set + rule: '[has(self.actorMetadata),has(self.trustBundle)].filter(x,x==true).size() == 1' - maxItems: 32 + maxItems: 8 type: array x-kubernetes-validations: - message: dataSources must contain at most one actorMetadata entry rule: self.filter(x, has(x.actorMetadata)).size() <= 1 + - message: dataSources must not contain duplicate paths + rule: self.all(x, !has(x.trustBundle) || self.exists_one(y, + has(y.trustBundle) && y.trustBundle.path == x.trustBundle.path)) + - message: dataSources must not contain duplicate paths + rule: '!self.exists(x, has(x.trustBundle) && self.exists(y, + has(y.actorMetadata) && y.actorMetadata.items.exists(i, + i.path == x.trustBundle.path)))' type: object required: - name diff --git a/pkg/api/v1alpha1/actortemplate_types.go b/pkg/api/v1alpha1/actortemplate_types.go index 579812b67..1fd1ec6c1 100644 --- a/pkg/api/v1alpha1/actortemplate_types.go +++ b/pkg/api/v1alpha1/actortemplate_types.go @@ -81,14 +81,14 @@ type ActorMetadataItem struct { Field ActorMetadataField `json:"field"` // Relative path from the root of the SystemInfo volume at which the - // field's value is written. Must be a clean relative Unix path: must not - // start or end with '/', and contain no ':', '..', '.', '//', or control - // characters. + // field's value is written. Must be a clean relative Unix path: it must + // not start or end with '/' and must not contain ':', '//', '.' or '..' + // segments, or control characters. // // +required // +kubebuilder:validation:MinLength=1 // +kubebuilder:validation:MaxLength=255 - // +kubebuilder:validation:XValidation:rule="!self.startsWith('/') && !self.endsWith('/') && !self.contains('//') && !self.contains(':') && !self.matches('[\\x00-\\x1f\\x7f]') && !self.matches('(^|/)[.][.]?(/|$)')",message="path must be a clean relative Unix path: must not start or end with '/', and contain no ':', '..', '.', '//', or control characters" + // +kubebuilder:validation:XValidation:rule="!self.startsWith('/') && !self.endsWith('/') && !self.contains('//') && !self.contains(':') && !self.matches('[\\x00-\\x1f\\x7f]') && !self.matches('(^|/)[.][.]?(/|$)')",message="path must be a clean relative Unix path: it must not start or end with '/' and must not contain ':', '//', '.' or '..' segments, or control characters" Path string `json:"path"` } @@ -109,29 +109,75 @@ type ActorMetadataDataSource struct { Items []ActorMetadataItem `json:"items"` } +// TrustBundleDataSource is a SystemInfo volume data source that projects the +// trust anchors of a named trust bundle to a single PEM file — inspired by +// the Kubernetes clusterTrustBundle projected volume source, but +// source-neutral: the name selects a bundle substrate knows how to fetch, +// and where it is fetched from is a substrate deployment concern, not part +// of this API (atelet enforces the supported set and resolves the backend). +// +// Supported names are allowlisted in atelet. Initially the only supported +// bundle is "egress-mitm.ate.dev" (the egress gateway CA bundle), resolved +// from the Kubernetes ClusterTrustBundle (certificates.k8s.io/v1beta1) that +// atecontroller derives from the egress-mitm-ca-pool; a configurable backend +// registry may widen this later. +// +// The bundle is resolved and sanitized on the node when the actor starts: +// atelet reads the backing object through a cluster-wide watch and keeps +// only CERTIFICATE PEM blocks, deduplicated and deliberately shuffled (order +// carries no meaning); the actor itself never talks to any bundle backend. +// Starting the actor fails if the named bundle is not on the allowlist, its +// backend is unavailable in this deployment, or the resolved bundle is +// missing, empty, or unparseable. +type TrustBundleDataSource struct { + // Name of the trust bundle to project. Must be a bundle name supported + // by this deployment (currently only "egress-mitm.ate.dev"). + // + // +required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + Name string `json:"name"` + + // Relative path from the root of the SystemInfo volume at which the PEM + // bundle is written. Must be a clean relative Unix path: it must not + // start or end with '/' and must not contain ':', '//', '.' or '..' + // segments, or control characters. + // + // +required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=255 + // +kubebuilder:validation:XValidation:rule="!self.startsWith('/') && !self.endsWith('/') && !self.contains('//') && !self.contains(':') && !self.matches('[\\x00-\\x1f\\x7f]') && !self.matches('(^|/)[.][.]?(/|$)')",message="path must be a clean relative Unix path: it must not start or end with '/' and must not contain ':', '//', '.' or '..' segments, or control characters" + Path string `json:"path"` +} + // SystemInfoDataSource is a container allowing you to pick a particular // SystemInfo data source. // // Exactly one member must be set. // -// +kubebuilder:validation:ExactlyOneOf={actorMetadata} +// +kubebuilder:validation:ExactlyOneOf={actorMetadata,trustBundle} type SystemInfoDataSource struct { ActorMetadata *ActorMetadataDataSource `json:"actorMetadata,omitempty"` + + TrustBundle *TrustBundleDataSource `json:"trustBundle,omitempty"` } // Represents a system information volume, which provides files containing -// substrate-generated per-actor data such as the actor's identity fields -// (and, in the future, identity JWTs and certificates). +// substrate-generated per-actor data such as the actor's identity fields, +// projected trust bundles (and, in the future, identity JWTs and +// certificates). type SystemInfoVolumeSource struct { // DataSources is the list of data sources to place within the SystemInfo // volume. // - // At most one actorMetadata entry may appear; this is what keeps file - // paths unique across the whole volume (uniqueness within the entry is - // enforced on its items). + // At most one actorMetadata entry may appear, and file paths must be + // unique across all entries (uniqueness within actorMetadata is enforced + // on its items). // - // +kubebuilder:validation:MaxItems=32 + // +kubebuilder:validation:MaxItems=8 // +kubebuilder:validation:XValidation:rule="self.filter(x, has(x.actorMetadata)).size() <= 1",message="dataSources must contain at most one actorMetadata entry" + // +kubebuilder:validation:XValidation:rule="self.all(x, !has(x.trustBundle) || self.exists_one(y, has(y.trustBundle) && y.trustBundle.path == x.trustBundle.path))",message="dataSources must not contain duplicate paths" + // +kubebuilder:validation:XValidation:rule="!self.exists(x, has(x.trustBundle) && self.exists(y, has(y.actorMetadata) && y.actorMetadata.items.exists(i, i.path == x.trustBundle.path)))",message="dataSources must not contain duplicate paths" DataSources []SystemInfoDataSource `json:"dataSources,omitempty"` } diff --git a/pkg/api/v1alpha1/actortemplate_validation_test.go b/pkg/api/v1alpha1/actortemplate_validation_test.go index a35fa59af..24f9ced18 100644 --- a/pkg/api/v1alpha1/actortemplate_validation_test.go +++ b/pkg/api/v1alpha1/actortemplate_validation_test.go @@ -913,7 +913,7 @@ func TestActorTemplateValidation(t *testing.T) { } }, wantErr: true, - errMsg: "exactly one of the fields in [actorMetadata] must be set", + errMsg: "exactly one of the fields in [actorMetadata trustBundle] must be set", }, { name: "Volumes: SystemInfo actorMetadata with no items is invalid", mutate: func(at *ActorTemplate) { @@ -1063,6 +1063,120 @@ func TestActorTemplateValidation(t *testing.T) { }, wantErr: true, errMsg: "items must not contain duplicate paths", + }, { + name: "Volumes: SystemInfo trustBundle data source is valid", + mutate: func(at *ActorTemplate) { + at.Spec.Volumes = []Volume{ + { + Name: "system-info", + VolumeSource: VolumeSource{ + SystemInfo: &SystemInfoVolumeSource{ + DataSources: []SystemInfoDataSource{ + {TrustBundle: &TrustBundleDataSource{Name: "egress-trust", Path: "trust/ca.pem"}}, + }, + }, + }, + }, + } + at.Spec.Containers[0].VolumeMounts = []VolumeMount{ + {Name: "system-info", MountPath: "/run/substrate/certs"}, + } + }, + wantErr: false, + }, { + name: "Volumes: SystemInfo clusterTrustBundle with empty name is invalid", + mutate: func(at *ActorTemplate) { + at.Spec.Volumes = []Volume{ + { + Name: "system-info", + VolumeSource: VolumeSource{ + SystemInfo: &SystemInfoVolumeSource{ + DataSources: []SystemInfoDataSource{ + {TrustBundle: &TrustBundleDataSource{Name: "", Path: "ca.pem"}}, + }, + }, + }, + }, + } + }, + wantErr: true, + }, { + name: "Volumes: SystemInfo clusterTrustBundle with absolute path is invalid", + mutate: func(at *ActorTemplate) { + at.Spec.Volumes = []Volume{ + { + Name: "system-info", + VolumeSource: VolumeSource{ + SystemInfo: &SystemInfoVolumeSource{ + DataSources: []SystemInfoDataSource{ + {TrustBundle: &TrustBundleDataSource{Name: "egress-trust", Path: "/etc/ca.pem"}}, + }, + }, + }, + }, + } + }, + wantErr: true, + errMsg: "path must be a clean relative Unix path", + }, { + name: "Volumes: SystemInfo data source with both members set is invalid", + mutate: func(at *ActorTemplate) { + at.Spec.Volumes = []Volume{ + { + Name: "system-info", + VolumeSource: VolumeSource{ + SystemInfo: &SystemInfoVolumeSource{ + DataSources: []SystemInfoDataSource{ + { + ActorMetadata: &ActorMetadataDataSource{Items: []ActorMetadataItem{{Field: ActorMetadataFieldName, Path: "actor-name"}}}, + TrustBundle: &TrustBundleDataSource{Name: "egress-trust", Path: "ca.pem"}, + }, + }, + }, + }, + }, + } + }, + wantErr: true, + errMsg: "exactly one of the fields in [actorMetadata trustBundle] must be set", + }, { + name: "Volumes: SystemInfo clusterTrustBundles with duplicate paths are invalid", + mutate: func(at *ActorTemplate) { + at.Spec.Volumes = []Volume{ + { + Name: "system-info", + VolumeSource: VolumeSource{ + SystemInfo: &SystemInfoVolumeSource{ + DataSources: []SystemInfoDataSource{ + {TrustBundle: &TrustBundleDataSource{Name: "bundle-a", Path: "ca.pem"}}, + {TrustBundle: &TrustBundleDataSource{Name: "bundle-b", Path: "ca.pem"}}, + }, + }, + }, + }, + } + }, + wantErr: true, + errMsg: "dataSources must not contain duplicate paths", + }, { + name: "Volumes: SystemInfo clusterTrustBundle path colliding with an actorMetadata item is invalid", + mutate: func(at *ActorTemplate) { + at.Spec.Volumes = []Volume{ + { + Name: "system-info", + VolumeSource: VolumeSource{ + SystemInfo: &SystemInfoVolumeSource{ + DataSources: []SystemInfoDataSource{ + {ActorMetadata: &ActorMetadataDataSource{Items: []ActorMetadataItem{{Field: ActorMetadataFieldName, Path: "shared-path"}}}}, + {TrustBundle: &TrustBundleDataSource{Name: "egress-trust", Path: "shared-path"}}, + }, + }, + }, + }, + } + }, + wantErr: true, + errMsg: "dataSources must not contain duplicate paths", }, { name: "Volumes: SystemInfo with two actorMetadata entries is invalid", mutate: func(at *ActorTemplate) { diff --git a/pkg/api/v1alpha1/zz_generated.deepcopy.go b/pkg/api/v1alpha1/zz_generated.deepcopy.go index 2b9a56e84..a100320fb 100644 --- a/pkg/api/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/api/v1alpha1/zz_generated.deepcopy.go @@ -585,6 +585,11 @@ func (in *SystemInfoDataSource) DeepCopyInto(out *SystemInfoDataSource) { *out = new(ActorMetadataDataSource) (*in).DeepCopyInto(*out) } + if in.TrustBundle != nil { + in, out := &in.TrustBundle, &out.TrustBundle + *out = new(TrustBundleDataSource) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SystemInfoDataSource. @@ -619,6 +624,21 @@ func (in *SystemInfoVolumeSource) DeepCopy() *SystemInfoVolumeSource { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TrustBundleDataSource) DeepCopyInto(out *TrustBundleDataSource) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TrustBundleDataSource. +func (in *TrustBundleDataSource) DeepCopy() *TrustBundleDataSource { + if in == nil { + return nil + } + out := new(TrustBundleDataSource) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Volume) DeepCopyInto(out *Volume) { *out = *in