From a48d33e9425cd81a15fc49612398f968e62fae94 Mon Sep 17 00:00:00 2001 From: Max Thompson Date: Thu, 13 Aug 2026 13:21:36 -0700 Subject: [PATCH 1/2] Add trustBundle as a SystemInfo volume data source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A trustBundle data source projects the trust anchors of a named trust bundle to a PEM file in the volume — inspired by the Kubernetes clusterTrustBundle projected volume source, but source-neutral: the template names a bundle, and where it is fetched from is a substrate decision, not part of the API (#932). Resolution happens on the node, per review: the wire spec carries {name, path}, and atelet resolves the name against its supported-bundle allowlist and reads the backing ClusterTrustBundle through an informer at write time — the same watch dynamic refresh will hang off. Supported names are allowlisted in code rather than the CRD schema, so the eventual configurable backend registry widens them without an API change. Initially the only supported bundle is egress-mitm.ate.dev (the egress gateway CA bundle, #823), backed by the ClusterTrustBundle that atecontroller's EgressMITMTrustReconciler (#946) derives from the egress-mitm-ca-pool Secret; the signer-linked object name is a backend detail the allowlist mapping keeps out of the template API. Sanitization matches kubelet's for projections — CERTIFICATE blocks only, deduplicated, headers stripped, and the anchors deliberately shuffled (kubelet-style) so consumers cannot grow a dependence on order (internal/pemutil). Files land at stable paths with the per-file temp+rename discipline from #803 (find-paths safe) and refresh on every Run/Restore. Fail-closed, naming the bundle: unsupported names, missing or unusable bundles, and an unavailable backend all fail actor start. atelet's startup probe is a one-shot authorized List (certificates.k8s.io/ v1beta1 is feature-gated): a genuinely unserved API degrades with a warning, stale RBAC fails startup naming the missing rule rather than hanging cache sync, and transient apiserver errors retry briefly then fail startup. The atelet ClusterRole gains clustertrustbundles read access. The identity e2e drives the real chain end to end: it provisions the egress-mitm-ca-pool Secret, waits for the reconciler to publish the derived bundle, asserts the projected file in both CI lanes, then rotates the pool and asserts a resumed actor observes the new contents at the same path. e2e.DeployProbe ensures the bundle exists for whatever suite deploys the shared probe fixture. --- .../internal/controlapi/workload_spec.go | 12 + .../internal/controlapi/workload_spec_test.go | 56 +++ cmd/atelet/main.go | 80 +++- cmd/atelet/main_test.go | 46 ++- cmd/atelet/trustbundle.go | 143 ++++++++ cmd/atelet/trustbundle_test.go | 128 +++++++ docs/api-guide.md | 24 ++ internal/e2e/fixtures/probe/main.go | 7 +- internal/e2e/fixtures/probe/probe.yaml.tmpl | 12 +- internal/e2e/probe.go | 6 + internal/e2e/suites/identity/identity_test.go | 31 ++ internal/e2e/trustbundle.go | 148 ++++++++ internal/pemutil/pemutil.go | 75 ++++ internal/pemutil/pemutil_test.go | 142 +++++++ internal/proto/ateletpb/atelet.pb.go | 345 +++++++++++------- internal/proto/ateletpb/atelet.proto | 12 + manifests/ate-install/atelet.yaml | 6 + .../generated/ate.dev_actortemplates.yaml | 85 ++++- pkg/api/v1alpha1/actortemplate_types.go | 68 +++- .../v1alpha1/actortemplate_validation_test.go | 116 +++++- pkg/api/v1alpha1/zz_generated.deepcopy.go | 20 + 21 files changed, 1389 insertions(+), 173 deletions(-) create mode 100644 cmd/atelet/trustbundle.go create mode 100644 cmd/atelet/trustbundle_test.go create mode 100644 internal/e2e/trustbundle.go create mode 100644 internal/pemutil/pemutil.go create mode 100644 internal/pemutil/pemutil_test.go diff --git a/cmd/ateapi/internal/controlapi/workload_spec.go b/cmd/ateapi/internal/controlapi/workload_spec.go index cfb6a5084..3651c3cc4 100644 --- a/cmd/ateapi/internal/controlapi/workload_spec.go +++ b/cmd/ateapi/internal/controlapi/workload_spec.go @@ -56,6 +56,18 @@ func workloadSpecFromActorTemplate(actorTemplate *atev1alpha1.ActorTemplate, act ActorMetadata: actorMetadata, }, }) + case dataSource.TrustBundle != nil: + // The NAME crosses the wire: atelet resolves it against + // its allowlist and ClusterTrustBundle informer at write + // time (see cmd/atelet/trustbundle.go). + 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 56a5bec52..906c4c7ad 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,45 @@ func main() { ateFactory := externalversions.NewSharedInformerFactory(ateClient, 0) csiDriverConfigLister := ateFactory.Api().V1alpha1().CSIDriverConfigs().Lister() + // trustBundle SystemInfo data sources resolve on the node through an + // informer on ClusterTrustBundles (see trustbundle.go) — the same watch + // dynamic refresh will hang off. The v1beta1 API is feature-gated, so + // probe before registering: registering against a cluster that does not + // serve it — or one where atelet may not list it — would hang + // WaitForCacheSync below and with it atelet startup. Only a genuine + // not-served verdict degrades (the lister stays nil and referencing + // actors fail start with a clear error); everything else fails startup. + // The watch is scoped to the one bundle the allowlist can resolve: this + // informer runs on EVERY node, so an unfiltered watch would fan every + // ClusterTrustBundle in the cluster (e.g. the podcert signers' rotating + // bundles) out to every atelet for no benefit. RBAC cannot express this + // (resourceNames does not apply to list/watch), so the field selector is + // the enforcement point. metadata.name selectors cannot OR multiple + // names: if the allowlist grows, this becomes one informer per backing + // object — or an unfiltered watch, as kubelet accepts for arbitrary pod + // projections. NOTE: the tweak applies to every informer created from + // this factory; keep it ClusterTrustBundle-only. + coreFactory := informers.NewSharedInformerFactoryWithOptions(k8sClient, 0, + informers.WithTweakListOptions(func(o *metav1.ListOptions) { + o.FieldSelector = fields.OneTermEqualSelector("metadata.name", supportedTrustBundles[EgressTrustBundleName]).String() + })) + clusterTrustBundleLister := certlisters.ClusterTrustBundleLister(nil) + ctbServed, err := clusterTrustBundleAPIAvailable(ctx, k8sClient) + if err != nil { + serverboot.Fatal(ctx, "Failed to probe the ClusterTrustBundle API", err) + } + if ctbServed { + clusterTrustBundleLister = coreFactory.Certificates().V1beta1().ClusterTrustBundles().Lister() + } else { + slog.WarnContext(ctx, "certificates.k8s.io/v1beta1 ClusterTrustBundles not served by this cluster; SystemInfo trustBundle data sources will fail actor start") + } + stopCh := make(chan struct{}) defer close(stopCh) ateFactory.Start(stopCh) + coreFactory.Start(stopCh) ateFactory.WaitForCacheSync(stopCh) + coreFactory.WaitForCacheSync(stopCh) wmService := NewService( ctx, @@ -290,6 +329,7 @@ func main() { instruments, volPlugins, csiDriverConfigLister, + clusterTrustBundleLister, ) dialOpts, err := ateapiauth.DialOptions(ateapiauth.ClientConfig{ K8sClient: k8sClient, @@ -411,6 +451,10 @@ type AteomHerder struct { mu sync.RWMutex volumePlugins map[string]volume.VolumePluginWorkerPlane csiDriverConfigLister listersv1alpha1.CSIDriverConfigLister + // clusterTrustBundleLister backs trustBundle SystemInfo data sources + // (resolveTrustBundle); nil when the cluster does not serve the API, in + // which case referencing actors fail start with a clear error. + clusterTrustBundleLister certlisters.ClusterTrustBundleLister } var _ ateletpb.AteomHerderServer = (*AteomHerder)(nil) @@ -425,15 +469,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 } @@ -1446,7 +1492,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) } } @@ -1545,13 +1591,29 @@ 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 + // Resolved here, on the node, at write time (see trustbundle.go): + // the informer-backed lister serves the current bundle, and any + // resolution failure fails the actor start rather than write an + // empty or stale trust file. + 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 761abe4f4..e74fa6b21 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..d1d66470a --- /dev/null +++ b/cmd/atelet/trustbundle.go @@ -0,0 +1,143 @@ +// 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 ( + "context" + "fmt" + "slices" + "strings" + "time" + + "github.com/agent-substrate/substrate/internal/pemutil" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + 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 is the allowlist of bundle names the trustBundle +// SystemInfo data source may reference, mapping each to the Kubernetes +// ClusterTrustBundle object backing it. The template API deliberately names +// a bundle without saying where it comes from; this map is where substrate +// decides that. It is enforced here rather than in the CRD schema so a +// future configurable backend registry (#932) widens it without a template +// API change. +// +// The egress bundle's backing object is named by atecontroller's +// EgressMITMTrustReconciler, which derives it from the egress-mitm-ca-pool +// Secret; the k8s signer-linked naming convention +// (::) is a backend detail the template +// name deliberately does not leak. +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 (per the +// discussion on #941, resolution lives on the node: the same informer is +// what dynamic refresh will hang off). Sanitization matches kubelet's for +// projections: CERTIFICATE blocks only, deduplicated, headers stripped. +// +// Every error fails the actor start, naming the bundle: an actor that +// declared a trust bundle must not start without one. A nil lister means the +// cluster does not serve the ClusterTrustBundle API (see +// clusterTrustBundleAPIAvailable). +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: bundle backend unavailable in this deployment (the cluster does not serve certificates.k8s.io/v1beta1)", 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 +} + +// clusterTrustBundleAPIAvailable reports whether atelet can use the +// clustertrustbundles resource (certificates.k8s.io/v1beta1 is feature-gated +// as of Kubernetes v1.36; hack/create-kind-cluster.sh enables it). +// +// The probe is a one-shot List with the informer's own credentials, which +// checks BOTH properties informer registration depends on — the API is +// served AND atelet is authorized to list it. Plain discovery cannot: it +// needs no RBAC, so a new atelet running against stale RBAC (version skew +// during rollout) would pass discovery, get Forbidden on list/watch forever, +// and hang startup on cache sync. +// +// Verdicts: +// - nil error: the API is usable, register the informer. +// - persistent NotFound: the feature-gated group/version is genuinely +// absent — (false, nil), the caller degrades with a warning and +// trustBundle actors fail start with a clear error. A SINGLE NotFound is +// not authoritative: during a mixed-version HA apiserver rollout that is +// enabling the gate, a 404 can be transient, so NotFound consumes the +// same retry budget as any other error and only a consistent final +// NotFound degrades. +// - Forbidden: the API exists but RBAC is stale — fail startup naming the +// missing rule rather than hang on cache sync or silently degrade. +// - anything else (apiserver busy during a rollout, transient network): +// retried briefly, then fails startup; a restart heals it, whereas a +// permanently degraded lister would blame the cluster's API support in +// every actor-start error until someone thought to bounce atelet. +func clusterTrustBundleAPIAvailable(ctx context.Context, clientset kubernetes.Interface) (bool, error) { + const attempts = 5 + var lastErr error + for i := 0; i < attempts; i++ { + _, err := clientset.CertificatesV1beta1().ClusterTrustBundles().List(ctx, metav1.ListOptions{Limit: 1}) + switch { + case err == nil: + return true, nil + case apierrors.IsForbidden(err): + return false, fmt.Errorf("atelet is not authorized to list clustertrustbundles.certificates.k8s.io (stale RBAC? the atelet ClusterRole needs get/list/watch on clustertrustbundles): %w", err) + } + lastErr = err + select { + case <-ctx.Done(): + return false, ctx.Err() + case <-time.After(2 * time.Second): + } + } + if apierrors.IsNotFound(lastErr) { + return false, nil + } + return false, fmt.Errorf("probing certificates.k8s.io/v1beta1 failed %d times: %w", attempts, lastErr) +} diff --git a/cmd/atelet/trustbundle_test.go b/cmd/atelet/trustbundle_test.go new file mode 100644 index 000000000..6e67be431 --- /dev/null +++ b/cmd/atelet/trustbundle_test.go @@ -0,0 +1,128 @@ +// 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) { + // nil lister = atelet booted on a cluster without the feature-gated + // ClusterTrustBundle API (see clusterTrustBundleAPIAvailable). + _, err := resolveTrustBundle(nil, EgressTrustBundleName) + if err == nil || !strings.Contains(err.Error(), "backend unavailable in this deployment") { + t.Errorf("error = %v, want backend-unavailable error", err) + } + }) +} diff --git a/docs/api-guide.md b/docs/api-guide.md index b310b2ff9..0cf474a1b 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..758acbf49 100644 --- a/internal/e2e/fixtures/probe/main.go +++ b/internal/e2e/fixtures/probe/main.go @@ -33,12 +33,14 @@ import ( "strings" ) -// 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 +172,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) diff --git a/internal/e2e/fixtures/probe/probe.yaml.tmpl b/internal/e2e/fixtures/probe/probe.yaml.tmpl index 6bf31e64b..1f08ce334 100644 --- a/internal/e2e/fixtures/probe/probe.yaml.tmpl +++ b/internal/e2e/fixtures/probe/probe.yaml.tmpl @@ -57,13 +57,23 @@ ${TEMPLATE_SANDBOX_CLASS} path: atespace - field: uid path: actor-uid + # e2e.DeployProbe ensures this bundle's CA pool exists (and with it the + # reconciler-derived bundle) before applying this template, whatever + # suite is deploying: actors — including the golden boot — fail to + # start while the bundle is missing. The identity suite additionally + # replaces and rotates the pool to own the contents it asserts on. The + # name is not configurable: it must be on atelet's supported-bundle + # allowlist, which today contains only the egress gateway CA bundle. + - 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/identity/identity_test.go b/internal/e2e/suites/identity/identity_test.go index 99bfa908a..1810e26cd 100644 --- a/internal/e2e/suites/identity/identity_test.go +++ b/internal/e2e/suites/identity/identity_test.go @@ -35,10 +35,18 @@ const probeTemplate = "probe" // its actors live in. var probeNamespace string +// trustBundleName is the trust bundle probe.yaml.tmpl projects. The name is +// not a choice: it must be on atelet's supported-bundle allowlist, which +// today contains only the egress gateway CA bundle. The suite replaces the +// bundle's CA pool before deploying the fixture (e2e.ReplaceEgressTrustPool) +// so its assertions compare against a CA it owns. +const trustBundleName = "egress-mitm.ate.dev" + 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 +82,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 +129,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 +157,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 +189,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..4d09ca169 --- /dev/null +++ b/internal/e2e/trustbundle.go @@ -0,0 +1,148 @@ +// 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 "". Projections +// resolve from this object, so gating here keeps the reconcile latency out +// of later assertions (an actor started before the bundle updates would +// legitimately observe the previous contents). +// +// Known (accepted) race: this polls the apiserver directly, while ateapi +// resolves from its informer cache, so an actor started immediately after +// this returns could in principle still see the previous contents until +// watch delivery catches up. The suites only start or resume actors after +// this — operations that take seconds against a watch pipeline that takes +// milliseconds — so the window is effectively unhittable; if a +// rotated-bundle assertion ever flakes anyway, 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..00dbc2e12 --- /dev/null +++ b/internal/pemutil/pemutil.go @@ -0,0 +1,75 @@ +// 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, mirroring kubelet's +// clustertrustbundle manager: trust-anchor order carries no meaning, and a +// scrambled order keeps consumers from ever growing a dependence on it. +// kubelet's order is stable per process via its normalization cache and +// changes on restart; ours changes on every sanitize, which under the +// SystemInfo model means every Run/Restore — same property, same cadence as +// the rest of the volume's regeneration. +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, then + // scramble (as kubelet does via sets.List + rand.Shuffle). + 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 4b978c61b..6aca22c44 100644 --- a/internal/proto/ateletpb/atelet.pb.go +++ b/internal/proto/ateletpb/atelet.pb.go @@ -947,11 +947,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[13] + 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[13] + 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{13} +} + +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 @@ -959,7 +1018,7 @@ type SystemInfoDataSource struct { func (x *SystemInfoDataSource) Reset() { *x = SystemInfoDataSource{} - mi := &file_atelet_proto_msgTypes[13] + mi := &file_atelet_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -971,7 +1030,7 @@ func (x *SystemInfoDataSource) String() string { func (*SystemInfoDataSource) ProtoMessage() {} func (x *SystemInfoDataSource) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[13] + mi := &file_atelet_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -984,7 +1043,7 @@ func (x *SystemInfoDataSource) ProtoReflect() protoreflect.Message { // Deprecated: Use SystemInfoDataSource.ProtoReflect.Descriptor instead. func (*SystemInfoDataSource) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{13} + return file_atelet_proto_rawDescGZIP(), []int{14} } func (x *SystemInfoDataSource) GetDataSource() isSystemInfoDataSource_DataSource { @@ -1003,6 +1062,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() } @@ -1011,8 +1079,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. @@ -1025,7 +1099,7 @@ type SystemInfoVolume struct { func (x *SystemInfoVolume) Reset() { *x = SystemInfoVolume{} - mi := &file_atelet_proto_msgTypes[14] + mi := &file_atelet_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1037,7 +1111,7 @@ func (x *SystemInfoVolume) String() string { func (*SystemInfoVolume) ProtoMessage() {} func (x *SystemInfoVolume) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[14] + mi := &file_atelet_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1050,7 +1124,7 @@ func (x *SystemInfoVolume) ProtoReflect() protoreflect.Message { // Deprecated: Use SystemInfoVolume.ProtoReflect.Descriptor instead. func (*SystemInfoVolume) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{14} + return file_atelet_proto_rawDescGZIP(), []int{15} } func (x *SystemInfoVolume) GetDataSources() []*SystemInfoDataSource { @@ -1076,7 +1150,7 @@ type Volume struct { func (x *Volume) Reset() { *x = Volume{} - mi := &file_atelet_proto_msgTypes[15] + mi := &file_atelet_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1088,7 +1162,7 @@ func (x *Volume) String() string { func (*Volume) ProtoMessage() {} func (x *Volume) 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 { @@ -1101,7 +1175,7 @@ func (x *Volume) ProtoReflect() protoreflect.Message { // Deprecated: Use Volume.ProtoReflect.Descriptor instead. func (*Volume) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{15} + return file_atelet_proto_rawDescGZIP(), []int{16} } func (x *Volume) GetName() string { @@ -1192,7 +1266,7 @@ type VolumeMount struct { func (x *VolumeMount) Reset() { *x = VolumeMount{} - mi := &file_atelet_proto_msgTypes[16] + mi := &file_atelet_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1204,7 +1278,7 @@ func (x *VolumeMount) String() string { func (*VolumeMount) ProtoMessage() {} func (x *VolumeMount) 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 { @@ -1217,7 +1291,7 @@ func (x *VolumeMount) ProtoReflect() protoreflect.Message { // Deprecated: Use VolumeMount.ProtoReflect.Descriptor instead. func (*VolumeMount) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{16} + return file_atelet_proto_rawDescGZIP(), []int{17} } func (x *VolumeMount) GetName() string { @@ -1250,7 +1324,7 @@ type Container struct { func (x *Container) Reset() { *x = Container{} - mi := &file_atelet_proto_msgTypes[17] + mi := &file_atelet_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1262,7 +1336,7 @@ func (x *Container) String() string { func (*Container) ProtoMessage() {} func (x *Container) 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 { @@ -1275,7 +1349,7 @@ func (x *Container) ProtoReflect() protoreflect.Message { // Deprecated: Use Container.ProtoReflect.Descriptor instead. func (*Container) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{17} + return file_atelet_proto_rawDescGZIP(), []int{18} } func (x *Container) GetName() string { @@ -1344,7 +1418,7 @@ type SecurityContext struct { func (x *SecurityContext) Reset() { *x = SecurityContext{} - mi := &file_atelet_proto_msgTypes[18] + mi := &file_atelet_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1356,7 +1430,7 @@ func (x *SecurityContext) String() string { func (*SecurityContext) ProtoMessage() {} func (x *SecurityContext) 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 { @@ -1369,7 +1443,7 @@ func (x *SecurityContext) ProtoReflect() protoreflect.Message { // Deprecated: Use SecurityContext.ProtoReflect.Descriptor instead. func (*SecurityContext) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{18} + return file_atelet_proto_rawDescGZIP(), []int{19} } func (x *SecurityContext) GetCapabilities() *Capabilities { @@ -1391,7 +1465,7 @@ type Capabilities struct { func (x *Capabilities) Reset() { *x = Capabilities{} - mi := &file_atelet_proto_msgTypes[19] + mi := &file_atelet_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1403,7 +1477,7 @@ func (x *Capabilities) String() string { func (*Capabilities) ProtoMessage() {} func (x *Capabilities) 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 { @@ -1416,7 +1490,7 @@ func (x *Capabilities) ProtoReflect() protoreflect.Message { // Deprecated: Use Capabilities.ProtoReflect.Descriptor instead. func (*Capabilities) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{19} + return file_atelet_proto_rawDescGZIP(), []int{20} } func (x *Capabilities) GetAdd() []string { @@ -1443,7 +1517,7 @@ type EnvEntry struct { func (x *EnvEntry) Reset() { *x = EnvEntry{} - mi := &file_atelet_proto_msgTypes[20] + mi := &file_atelet_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1455,7 +1529,7 @@ func (x *EnvEntry) String() string { func (*EnvEntry) ProtoMessage() {} func (x *EnvEntry) 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 { @@ -1468,7 +1542,7 @@ func (x *EnvEntry) ProtoReflect() protoreflect.Message { // Deprecated: Use EnvEntry.ProtoReflect.Descriptor instead. func (*EnvEntry) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{20} + return file_atelet_proto_rawDescGZIP(), []int{21} } func (x *EnvEntry) GetName() string { @@ -1499,7 +1573,7 @@ type Readyz struct { func (x *Readyz) Reset() { *x = Readyz{} - mi := &file_atelet_proto_msgTypes[21] + mi := &file_atelet_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1511,7 +1585,7 @@ func (x *Readyz) String() string { func (*Readyz) ProtoMessage() {} func (x *Readyz) 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 { @@ -1524,7 +1598,7 @@ func (x *Readyz) ProtoReflect() protoreflect.Message { // Deprecated: Use Readyz.ProtoReflect.Descriptor instead. func (*Readyz) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{21} + return file_atelet_proto_rawDescGZIP(), []int{22} } func (x *Readyz) GetHttpGet() *HTTPGetAction { @@ -1554,7 +1628,7 @@ type HTTPGetAction struct { func (x *HTTPGetAction) Reset() { *x = HTTPGetAction{} - mi := &file_atelet_proto_msgTypes[22] + mi := &file_atelet_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1566,7 +1640,7 @@ func (x *HTTPGetAction) String() string { func (*HTTPGetAction) ProtoMessage() {} func (x *HTTPGetAction) 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 { @@ -1579,7 +1653,7 @@ func (x *HTTPGetAction) ProtoReflect() protoreflect.Message { // Deprecated: Use HTTPGetAction.ProtoReflect.Descriptor instead. func (*HTTPGetAction) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{22} + return file_atelet_proto_rawDescGZIP(), []int{23} } func (x *HTTPGetAction) GetPath() string { @@ -1604,7 +1678,7 @@ type RunResponse struct { func (x *RunResponse) Reset() { *x = RunResponse{} - mi := &file_atelet_proto_msgTypes[23] + mi := &file_atelet_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1616,7 +1690,7 @@ func (x *RunResponse) String() string { func (*RunResponse) ProtoMessage() {} func (x *RunResponse) 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 { @@ -1629,7 +1703,7 @@ func (x *RunResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RunResponse.ProtoReflect.Descriptor instead. func (*RunResponse) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{23} + return file_atelet_proto_rawDescGZIP(), []int{24} } type LocalCheckpointConfiguration struct { @@ -1645,7 +1719,7 @@ type LocalCheckpointConfiguration struct { func (x *LocalCheckpointConfiguration) Reset() { *x = LocalCheckpointConfiguration{} - mi := &file_atelet_proto_msgTypes[24] + mi := &file_atelet_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1657,7 +1731,7 @@ func (x *LocalCheckpointConfiguration) String() string { func (*LocalCheckpointConfiguration) ProtoMessage() {} func (x *LocalCheckpointConfiguration) 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 { @@ -1670,7 +1744,7 @@ func (x *LocalCheckpointConfiguration) ProtoReflect() protoreflect.Message { // Deprecated: Use LocalCheckpointConfiguration.ProtoReflect.Descriptor instead. func (*LocalCheckpointConfiguration) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{24} + return file_atelet_proto_rawDescGZIP(), []int{25} } func (x *LocalCheckpointConfiguration) GetSnapshotName() string { @@ -1691,7 +1765,7 @@ type ExternalCheckpointConfiguration struct { func (x *ExternalCheckpointConfiguration) Reset() { *x = ExternalCheckpointConfiguration{} - mi := &file_atelet_proto_msgTypes[25] + mi := &file_atelet_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1703,7 +1777,7 @@ func (x *ExternalCheckpointConfiguration) String() string { func (*ExternalCheckpointConfiguration) ProtoMessage() {} func (x *ExternalCheckpointConfiguration) 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 { @@ -1716,7 +1790,7 @@ func (x *ExternalCheckpointConfiguration) ProtoReflect() protoreflect.Message { // Deprecated: Use ExternalCheckpointConfiguration.ProtoReflect.Descriptor instead. func (*ExternalCheckpointConfiguration) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{25} + return file_atelet_proto_rawDescGZIP(), []int{26} } func (x *ExternalCheckpointConfiguration) GetSnapshotUri() string { @@ -1754,7 +1828,7 @@ type CheckpointRequest struct { func (x *CheckpointRequest) Reset() { *x = CheckpointRequest{} - mi := &file_atelet_proto_msgTypes[26] + mi := &file_atelet_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1766,7 +1840,7 @@ func (x *CheckpointRequest) String() string { func (*CheckpointRequest) ProtoMessage() {} func (x *CheckpointRequest) 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 { @@ -1779,7 +1853,7 @@ func (x *CheckpointRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CheckpointRequest.ProtoReflect.Descriptor instead. func (*CheckpointRequest) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{26} + return file_atelet_proto_rawDescGZIP(), []int{27} } func (x *CheckpointRequest) GetTargetAteomUid() string { @@ -1894,7 +1968,7 @@ type CheckpointResponse struct { func (x *CheckpointResponse) Reset() { *x = CheckpointResponse{} - mi := &file_atelet_proto_msgTypes[27] + mi := &file_atelet_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1906,7 +1980,7 @@ func (x *CheckpointResponse) String() string { func (*CheckpointResponse) ProtoMessage() {} func (x *CheckpointResponse) 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 { @@ -1919,7 +1993,7 @@ func (x *CheckpointResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CheckpointResponse.ProtoReflect.Descriptor instead. func (*CheckpointResponse) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{27} + return file_atelet_proto_rawDescGZIP(), []int{28} } type UploadPausedCheckpointRequest struct { @@ -1947,7 +2021,7 @@ type UploadPausedCheckpointRequest struct { func (x *UploadPausedCheckpointRequest) Reset() { *x = UploadPausedCheckpointRequest{} - mi := &file_atelet_proto_msgTypes[28] + mi := &file_atelet_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1959,7 +2033,7 @@ func (x *UploadPausedCheckpointRequest) String() string { func (*UploadPausedCheckpointRequest) ProtoMessage() {} func (x *UploadPausedCheckpointRequest) 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 { @@ -1972,7 +2046,7 @@ func (x *UploadPausedCheckpointRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UploadPausedCheckpointRequest.ProtoReflect.Descriptor instead. func (*UploadPausedCheckpointRequest) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{28} + return file_atelet_proto_rawDescGZIP(), []int{29} } func (x *UploadPausedCheckpointRequest) GetAtespace() string { @@ -2039,7 +2113,7 @@ type UploadPausedCheckpointResponse struct { func (x *UploadPausedCheckpointResponse) Reset() { *x = UploadPausedCheckpointResponse{} - mi := &file_atelet_proto_msgTypes[29] + mi := &file_atelet_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2051,7 +2125,7 @@ func (x *UploadPausedCheckpointResponse) String() string { func (*UploadPausedCheckpointResponse) ProtoMessage() {} func (x *UploadPausedCheckpointResponse) 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 { @@ -2064,7 +2138,7 @@ func (x *UploadPausedCheckpointResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UploadPausedCheckpointResponse.ProtoReflect.Descriptor instead. func (*UploadPausedCheckpointResponse) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{29} + return file_atelet_proto_rawDescGZIP(), []int{30} } type RestoreRequest struct { @@ -2110,7 +2184,7 @@ type RestoreRequest struct { func (x *RestoreRequest) Reset() { *x = RestoreRequest{} - mi := &file_atelet_proto_msgTypes[30] + mi := &file_atelet_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2122,7 +2196,7 @@ func (x *RestoreRequest) String() string { func (*RestoreRequest) ProtoMessage() {} func (x *RestoreRequest) 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 { @@ -2135,7 +2209,7 @@ func (x *RestoreRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RestoreRequest.ProtoReflect.Descriptor instead. func (*RestoreRequest) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{30} + return file_atelet_proto_rawDescGZIP(), []int{31} } func (x *RestoreRequest) GetTargetAteomUid() string { @@ -2278,7 +2352,7 @@ type RestoreResponse struct { func (x *RestoreResponse) Reset() { *x = RestoreResponse{} - mi := &file_atelet_proto_msgTypes[31] + mi := &file_atelet_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2290,7 +2364,7 @@ func (x *RestoreResponse) String() string { func (*RestoreResponse) ProtoMessage() {} func (x *RestoreResponse) 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 { @@ -2303,7 +2377,7 @@ func (x *RestoreResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RestoreResponse.ProtoReflect.Descriptor instead. func (*RestoreResponse) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{31} + return file_atelet_proto_rawDescGZIP(), []int{32} } var File_atelet_proto protoreflect.FileDescriptor @@ -2372,9 +2446,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" + @@ -2503,7 +2581,7 @@ func file_atelet_proto_rawDescGZIP() []byte { } var file_atelet_proto_enumTypes = make([]protoimpl.EnumInfo, 3) -var file_atelet_proto_msgTypes = make([]protoimpl.MessageInfo, 35) +var file_atelet_proto_msgTypes = make([]protoimpl.MessageInfo, 36) var file_atelet_proto_goTypes = []any{ (ActorMetadataField)(0), // 0: atelet.ActorMetadataField (CheckpointType)(0), // 1: atelet.CheckpointType @@ -2521,81 +2599,83 @@ var file_atelet_proto_goTypes = []any{ (*ImageVolumeSource)(nil), // 13: atelet.ImageVolumeSource (*ActorMetadataItem)(nil), // 14: atelet.ActorMetadataItem (*ActorMetadataDataSource)(nil), // 15: atelet.ActorMetadataDataSource - (*SystemInfoDataSource)(nil), // 16: atelet.SystemInfoDataSource - (*SystemInfoVolume)(nil), // 17: atelet.SystemInfoVolume - (*Volume)(nil), // 18: atelet.Volume - (*VolumeMount)(nil), // 19: atelet.VolumeMount - (*Container)(nil), // 20: atelet.Container - (*SecurityContext)(nil), // 21: atelet.SecurityContext - (*Capabilities)(nil), // 22: atelet.Capabilities - (*EnvEntry)(nil), // 23: atelet.EnvEntry - (*Readyz)(nil), // 24: atelet.Readyz - (*HTTPGetAction)(nil), // 25: atelet.HTTPGetAction - (*RunResponse)(nil), // 26: atelet.RunResponse - (*LocalCheckpointConfiguration)(nil), // 27: atelet.LocalCheckpointConfiguration - (*ExternalCheckpointConfiguration)(nil), // 28: atelet.ExternalCheckpointConfiguration - (*CheckpointRequest)(nil), // 29: atelet.CheckpointRequest - (*CheckpointResponse)(nil), // 30: atelet.CheckpointResponse - (*UploadPausedCheckpointRequest)(nil), // 31: atelet.UploadPausedCheckpointRequest - (*UploadPausedCheckpointResponse)(nil), // 32: atelet.UploadPausedCheckpointResponse - (*RestoreRequest)(nil), // 33: atelet.RestoreRequest - (*RestoreResponse)(nil), // 34: atelet.RestoreResponse - nil, // 35: atelet.ArchAssets.FilesEntry - nil, // 36: atelet.SandboxAssets.AssetsEntry - nil, // 37: atelet.ExternalVolumeSource.VolumeContextEntry + (*TrustBundleDataSource)(nil), // 16: atelet.TrustBundleDataSource + (*SystemInfoDataSource)(nil), // 17: atelet.SystemInfoDataSource + (*SystemInfoVolume)(nil), // 18: atelet.SystemInfoVolume + (*Volume)(nil), // 19: atelet.Volume + (*VolumeMount)(nil), // 20: atelet.VolumeMount + (*Container)(nil), // 21: atelet.Container + (*SecurityContext)(nil), // 22: atelet.SecurityContext + (*Capabilities)(nil), // 23: atelet.Capabilities + (*EnvEntry)(nil), // 24: atelet.EnvEntry + (*Readyz)(nil), // 25: atelet.Readyz + (*HTTPGetAction)(nil), // 26: atelet.HTTPGetAction + (*RunResponse)(nil), // 27: atelet.RunResponse + (*LocalCheckpointConfiguration)(nil), // 28: atelet.LocalCheckpointConfiguration + (*ExternalCheckpointConfiguration)(nil), // 29: atelet.ExternalCheckpointConfiguration + (*CheckpointRequest)(nil), // 30: atelet.CheckpointRequest + (*CheckpointResponse)(nil), // 31: atelet.CheckpointResponse + (*UploadPausedCheckpointRequest)(nil), // 32: atelet.UploadPausedCheckpointRequest + (*UploadPausedCheckpointResponse)(nil), // 33: atelet.UploadPausedCheckpointResponse + (*RestoreRequest)(nil), // 34: atelet.RestoreRequest + (*RestoreResponse)(nil), // 35: atelet.RestoreResponse + nil, // 36: atelet.ArchAssets.FilesEntry + nil, // 37: atelet.SandboxAssets.AssetsEntry + nil, // 38: atelet.ExternalVolumeSource.VolumeContextEntry } var file_atelet_proto_depIdxs = []int32{ 10, // 0: atelet.RunRequest.spec:type_name -> atelet.WorkloadSpec 9, // 1: atelet.RunRequest.sandbox_assets:type_name -> atelet.SandboxAssets 6, // 2: atelet.RunRequest.egress_gateway:type_name -> atelet.EgressGateway - 35, // 3: atelet.ArchAssets.files:type_name -> atelet.ArchAssets.FilesEntry - 36, // 4: atelet.SandboxAssets.assets:type_name -> atelet.SandboxAssets.AssetsEntry - 20, // 5: atelet.WorkloadSpec.containers:type_name -> atelet.Container - 18, // 6: atelet.WorkloadSpec.volumes:type_name -> atelet.Volume - 37, // 7: atelet.ExternalVolumeSource.volume_context:type_name -> atelet.ExternalVolumeSource.VolumeContextEntry + 36, // 3: atelet.ArchAssets.files:type_name -> atelet.ArchAssets.FilesEntry + 37, // 4: atelet.SandboxAssets.assets:type_name -> atelet.SandboxAssets.AssetsEntry + 21, // 5: atelet.WorkloadSpec.containers:type_name -> atelet.Container + 19, // 6: atelet.WorkloadSpec.volumes:type_name -> atelet.Volume + 38, // 7: atelet.ExternalVolumeSource.volume_context:type_name -> atelet.ExternalVolumeSource.VolumeContextEntry 0, // 8: atelet.ActorMetadataItem.field:type_name -> atelet.ActorMetadataField 14, // 9: atelet.ActorMetadataDataSource.items:type_name -> atelet.ActorMetadataItem 15, // 10: atelet.SystemInfoDataSource.actor_metadata:type_name -> atelet.ActorMetadataDataSource - 16, // 11: atelet.SystemInfoVolume.data_sources:type_name -> atelet.SystemInfoDataSource - 11, // 12: atelet.Volume.durable_dir:type_name -> atelet.DurableDirVolume - 12, // 13: atelet.Volume.external:type_name -> atelet.ExternalVolumeSource - 17, // 14: atelet.Volume.system_info:type_name -> atelet.SystemInfoVolume - 13, // 15: atelet.Volume.image:type_name -> atelet.ImageVolumeSource - 23, // 16: atelet.Container.env:type_name -> atelet.EnvEntry - 24, // 17: atelet.Container.readyz:type_name -> atelet.Readyz - 19, // 18: atelet.Container.volume_mounts:type_name -> atelet.VolumeMount - 21, // 19: atelet.Container.security_context:type_name -> atelet.SecurityContext - 22, // 20: atelet.SecurityContext.capabilities:type_name -> atelet.Capabilities - 25, // 21: atelet.Readyz.http_get:type_name -> atelet.HTTPGetAction - 10, // 22: atelet.CheckpointRequest.spec:type_name -> atelet.WorkloadSpec - 1, // 23: atelet.CheckpointRequest.type:type_name -> atelet.CheckpointType - 27, // 24: atelet.CheckpointRequest.local_config:type_name -> atelet.LocalCheckpointConfiguration - 28, // 25: atelet.CheckpointRequest.external_config:type_name -> atelet.ExternalCheckpointConfiguration - 2, // 26: atelet.CheckpointRequest.scope:type_name -> atelet.SnapshotScope - 2, // 27: atelet.UploadPausedCheckpointRequest.desired_scope:type_name -> atelet.SnapshotScope - 10, // 28: atelet.RestoreRequest.spec:type_name -> atelet.WorkloadSpec - 1, // 29: atelet.RestoreRequest.type:type_name -> atelet.CheckpointType - 27, // 30: atelet.RestoreRequest.local_config:type_name -> atelet.LocalCheckpointConfiguration - 28, // 31: atelet.RestoreRequest.external_config:type_name -> atelet.ExternalCheckpointConfiguration - 2, // 32: atelet.RestoreRequest.scope:type_name -> atelet.SnapshotScope - 6, // 33: atelet.RestoreRequest.egress_gateway:type_name -> atelet.EgressGateway - 7, // 34: atelet.ArchAssets.FilesEntry.value:type_name -> atelet.AssetFile - 8, // 35: atelet.SandboxAssets.AssetsEntry.value:type_name -> atelet.ArchAssets - 3, // 36: atelet.CredentialBroker.MintActorCertificate:input_type -> atelet.MintActorCertificateRequest - 5, // 37: atelet.AteomHerder.Run:input_type -> atelet.RunRequest - 29, // 38: atelet.AteomHerder.Checkpoint:input_type -> atelet.CheckpointRequest - 33, // 39: atelet.AteomHerder.Restore:input_type -> atelet.RestoreRequest - 31, // 40: atelet.AteomHerder.UploadPausedCheckpoint:input_type -> atelet.UploadPausedCheckpointRequest - 4, // 41: atelet.CredentialBroker.MintActorCertificate:output_type -> atelet.MintActorCertificateResponse - 26, // 42: atelet.AteomHerder.Run:output_type -> atelet.RunResponse - 30, // 43: atelet.AteomHerder.Checkpoint:output_type -> atelet.CheckpointResponse - 34, // 44: atelet.AteomHerder.Restore:output_type -> atelet.RestoreResponse - 32, // 45: atelet.AteomHerder.UploadPausedCheckpoint:output_type -> atelet.UploadPausedCheckpointResponse - 41, // [41:46] is the sub-list for method output_type - 36, // [36:41] is the sub-list for method input_type - 36, // [36:36] is the sub-list for extension type_name - 36, // [36:36] is the sub-list for extension extendee - 0, // [0:36] is the sub-list for field type_name + 16, // 11: atelet.SystemInfoDataSource.trust_bundle:type_name -> atelet.TrustBundleDataSource + 17, // 12: atelet.SystemInfoVolume.data_sources:type_name -> atelet.SystemInfoDataSource + 11, // 13: atelet.Volume.durable_dir:type_name -> atelet.DurableDirVolume + 12, // 14: atelet.Volume.external:type_name -> atelet.ExternalVolumeSource + 18, // 15: atelet.Volume.system_info:type_name -> atelet.SystemInfoVolume + 13, // 16: atelet.Volume.image:type_name -> atelet.ImageVolumeSource + 24, // 17: atelet.Container.env:type_name -> atelet.EnvEntry + 25, // 18: atelet.Container.readyz:type_name -> atelet.Readyz + 20, // 19: atelet.Container.volume_mounts:type_name -> atelet.VolumeMount + 22, // 20: atelet.Container.security_context:type_name -> atelet.SecurityContext + 23, // 21: atelet.SecurityContext.capabilities:type_name -> atelet.Capabilities + 26, // 22: atelet.Readyz.http_get:type_name -> atelet.HTTPGetAction + 10, // 23: atelet.CheckpointRequest.spec:type_name -> atelet.WorkloadSpec + 1, // 24: atelet.CheckpointRequest.type:type_name -> atelet.CheckpointType + 28, // 25: atelet.CheckpointRequest.local_config:type_name -> atelet.LocalCheckpointConfiguration + 29, // 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 + 10, // 29: atelet.RestoreRequest.spec:type_name -> atelet.WorkloadSpec + 1, // 30: atelet.RestoreRequest.type:type_name -> atelet.CheckpointType + 28, // 31: atelet.RestoreRequest.local_config:type_name -> atelet.LocalCheckpointConfiguration + 29, // 32: atelet.RestoreRequest.external_config:type_name -> atelet.ExternalCheckpointConfiguration + 2, // 33: atelet.RestoreRequest.scope:type_name -> atelet.SnapshotScope + 6, // 34: atelet.RestoreRequest.egress_gateway:type_name -> atelet.EgressGateway + 7, // 35: atelet.ArchAssets.FilesEntry.value:type_name -> atelet.AssetFile + 8, // 36: atelet.SandboxAssets.AssetsEntry.value:type_name -> atelet.ArchAssets + 3, // 37: atelet.CredentialBroker.MintActorCertificate:input_type -> atelet.MintActorCertificateRequest + 5, // 38: atelet.AteomHerder.Run:input_type -> atelet.RunRequest + 30, // 39: atelet.AteomHerder.Checkpoint:input_type -> atelet.CheckpointRequest + 34, // 40: atelet.AteomHerder.Restore:input_type -> atelet.RestoreRequest + 32, // 41: atelet.AteomHerder.UploadPausedCheckpoint:input_type -> atelet.UploadPausedCheckpointRequest + 4, // 42: atelet.CredentialBroker.MintActorCertificate:output_type -> atelet.MintActorCertificateResponse + 27, // 43: atelet.AteomHerder.Run:output_type -> atelet.RunResponse + 31, // 44: atelet.AteomHerder.Checkpoint:output_type -> atelet.CheckpointResponse + 35, // 45: atelet.AteomHerder.Restore:output_type -> atelet.RestoreResponse + 33, // 46: atelet.AteomHerder.UploadPausedCheckpoint:output_type -> atelet.UploadPausedCheckpointResponse + 42, // [42:47] is the sub-list for method output_type + 37, // [37:42] 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 } func init() { file_atelet_proto_init() } @@ -2604,20 +2684,21 @@ func file_atelet_proto_init() { return } file_atelet_proto_msgTypes[2].OneofWrappers = []any{} - file_atelet_proto_msgTypes[13].OneofWrappers = []any{ + file_atelet_proto_msgTypes[14].OneofWrappers = []any{ (*SystemInfoDataSource_ActorMetadata)(nil), + (*SystemInfoDataSource_TrustBundle)(nil), } - file_atelet_proto_msgTypes[15].OneofWrappers = []any{ + file_atelet_proto_msgTypes[16].OneofWrappers = []any{ (*Volume_DurableDir)(nil), (*Volume_External)(nil), (*Volume_SystemInfo)(nil), (*Volume_Image)(nil), } - file_atelet_proto_msgTypes[26].OneofWrappers = []any{ + file_atelet_proto_msgTypes[27].OneofWrappers = []any{ (*CheckpointRequest_LocalConfig)(nil), (*CheckpointRequest_ExternalConfig)(nil), } - file_atelet_proto_msgTypes[30].OneofWrappers = []any{ + file_atelet_proto_msgTypes[31].OneofWrappers = []any{ (*RestoreRequest_LocalConfig)(nil), (*RestoreRequest_ExternalConfig)(nil), } @@ -2627,7 +2708,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: 35, + NumMessages: 36, NumExtensions: 0, NumServices: 2, }, diff --git a/internal/proto/ateletpb/atelet.proto b/internal/proto/ateletpb/atelet.proto index c3b31ee1b..cea26cc28 100644 --- a/internal/proto/ateletpb/atelet.proto +++ b/internal/proto/ateletpb/atelet.proto @@ -166,9 +166,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 From f14ea87a2bcd084c3efe554f9720b55c8ae95c63 Mon Sep 17 00:00:00 2001 From: Max Thompson Date: Thu, 20 Aug 2026 13:12:44 -0700 Subject: [PATCH 2/2] e2e: prove actor TLS through the MITM egress gateway with the projected bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The identity suite verifies trust-anchor DELIVERY (pool -> reconciler -> bundle -> resolution -> projection); this adds the CONSUMPTION half for #871: an actor completes a TLS handshake with the sdsmint egress gateway's per-SNI minted leaf using ONLY the anchors projected through its trustBundle SystemInfo volume — on both sandbox classes, since delivery differs per class (gVisor RO bind vs the micro-VM unified virtio-fs share). The probe gains /fetch?url=&roots=bundle|system, which GETs over the actor's normal egress path with TLS roots from the projected bundle or the image's system roots. The new egressmitm suite asserts the pair that makes the result unambiguous: roots=bundle succeeds (and would fail under a passthrough gateway, whose relayed public certificates the bundle cannot validate — so a pass also certifies interception is on), while roots=system fails certificate verification (the minted leaf chains to no public CA; under passthrough it would succeed). The sdsmint gateway variant replaces the passthrough gateway cluster-wide, so CI deploys it as a separate step after both standard lanes and runs only this suite against it — once per sandbox class, gated by E2E_EGRESS_MITM. The suite ensures (never replaces) the CA pool: sdsmintd signs with the pool mounted into the gateway pod, and replacing it would race kubelet's Secret propagation into that mount. --- .github/workflows/pr-workflow.yaml | 26 ++ internal/e2e/fixtures/probe/main.go | 70 ++++++ .../e2e/suites/egressmitm/egressmitm_test.go | 237 ++++++++++++++++++ .../e2e/suites/egressmitm/testmain_test.go | 24 ++ 4 files changed, 357 insertions(+) create mode 100644 internal/e2e/suites/egressmitm/egressmitm_test.go create mode 100644 internal/e2e/suites/egressmitm/testmain_test.go 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/internal/e2e/fixtures/probe/main.go b/internal/e2e/fixtures/probe/main.go index 758acbf49..3db1a14ba 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,6 +33,7 @@ import ( "runtime" "strconv" "strings" + "time" ) // The systemInfo volume data-source files that probe.yaml.tmpl mounts at @@ -290,6 +293,72 @@ 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. +// Through a MITM egress gateway the handshake succeeds only if those +// anchors validate the per-SNI leaf the gateway mints — the e2e's +// positive case. Under a passthrough gateway the same fetch FAILS (the +// bundle holds no public CAs), so a pass also proves interception. +// - "system" uses the image's system roots: the negative control that must +// fail under interception (the minted leaf chains to no public CA) and +// would succeed under passthrough. +// +// Failures land in the "error" field rather than the HTTP status: a TLS +// 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 { @@ -309,6 +378,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/suites/egressmitm/egressmitm_test.go b/internal/e2e/suites/egressmitm/egressmitm_test.go new file mode 100644 index 000000000..f16aa4083 --- /dev/null +++ b/internal/e2e/suites/egressmitm/egressmitm_test.go @@ -0,0 +1,237 @@ +// 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 through a SystemInfo volume +// can complete a TLS handshake with the sdsmint egress gateway's per-SNI +// minted leaf, using ONLY the projected anchors. +// +// This is the consumption side of the chain the identity suite's trust +// assertions stop short of: pool -> reconciler -> bundle -> projection is +// delivery; this suite proves the delivered anchors actually validate what +// the gateway serves. +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)) }