diff --git a/.github/workflows/pr-workflow.yaml b/.github/workflows/pr-workflow.yaml index cc55c9276..bf3dcb6b2 100644 --- a/.github/workflows/pr-workflow.yaml +++ b/.github/workflows/pr-workflow.yaml @@ -107,6 +107,30 @@ 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). 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-ate-system --experimental-use-sdsmint + - name: Run E2E tests (egress MITM trust) + # The consumption half of the trust-bundle chain: an actor does TLS with + # the MITM gateway's minted leaf using ONLY the projected bundle, plus a + # system-roots negative control proving interception is real (see + # internal/e2e/suites/egressmitm). + env: + E2E_EGRESS_MITM: "1" + run: hack/run-e2e-kind.sh ./internal/e2e/suites/egressmitm -v -args --no-color + - name: Run E2E tests (egress MITM trust, micro-VM) + # The same proof with the probe on the micro-VM runtime. Trust DELIVERY + # differs per sandbox class (gVisor RO bind vs the micro-VM unified + # virtio-fs share), so the handshake is proven on both. Uses the + # micro-VM deps staged earlier in this job. + env: + E2E_EGRESS_MITM: "1" + E2E_SANDBOX_CLASS: microvm + run: hack/run-e2e-kind.sh ./internal/e2e/suites/egressmitm -v -args --no-color - name: Dump diagnostics on failure if: failure() run: | diff --git a/cmd/ateapi/internal/controlapi/actor_identity_token.go b/cmd/ateapi/internal/controlapi/actor_identity_token.go new file mode 100644 index 000000000..130b01ede --- /dev/null +++ b/cmd/ateapi/internal/controlapi/actor_identity_token.go @@ -0,0 +1,139 @@ +// 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 controlapi + +import ( + "crypto/rand" + "fmt" + "os" + "time" + + "github.com/agent-substrate/substrate/cmd/ateapi/internal/actoridjwt" + "github.com/agent-substrate/substrate/internal/localjwtauthority" + "github.com/agent-substrate/substrate/internal/proto/ateletpb" + atev1alpha1 "github.com/agent-substrate/substrate/pkg/api/v1alpha1" + "github.com/agent-substrate/substrate/pkg/proto/ateapipb" +) + +// actorIdentityTokenIssuer mirrors the issuer actoridentity.MintJWT stamps; +// the TODO there about making it a real, OIDC-discoverable DNS name applies +// here identically — the two must move together so verifiers see one issuer. +const actorIdentityTokenIssuer = "https://api.ate-system.svc" + +// defaultActorIdentityTokenTTL matches the CRD default for +// expirationSeconds; the guard here covers templates admitted before the +// defaulting webhook stamped them. +const defaultActorIdentityTokenTTL = 3600 * time.Second + +// mintActorIdentityTokens fills the Token bytes of every actorIdentityToken +// data source in workloadSpec, minting one JWT per source with the +// template's audience and TTL bound into the claims alongside the actor's +// identity (atespace, name, uid — the same claim shape actoridentity.MintJWT +// produces, so verifiers need one code path). +// +// Minting happens here — on the resume path, immediately before the spec is +// sent to atelet — rather than in atelet, because the mint IS part of the +// activation: ateapi is placing this actor at this moment, so the token is +// inherently bound to the activation without a separate authorization +// exchange. Tokens therefore refresh on every Run/Restore; live renewal for +// long-running actors is deliberately out of scope here and lands with the +// live-refresh mechanism (#932 PR 2 tracks the transport). +// +// Fails closed, naming the problem, when the deployment has no signing pool: +// an actor that declared a token must not start without one. +func mintActorIdentityTokens(jwtPoolFile string, template *atev1alpha1.ActorTemplate, workloadSpec *ateletpb.WorkloadSpec, actor *ateapipb.Actor) error { + if template == nil { + return nil + } + + // Wire entries indexed by (volume name, path) for filling in place. + type key struct{ volume, path string } + wire := map[key]*ateletpb.ActorIdentityTokenDataSource{} + for _, vol := range workloadSpec.GetVolumes() { + for _, ds := range vol.GetSystemInfo().GetDataSources() { + if tok := ds.GetActorIdentityToken(); tok != nil { + wire[key{vol.GetName(), tok.GetPath()}] = tok + } + } + } + if len(wire) == 0 { + return nil + } + + if jwtPoolFile == "" { + return fmt.Errorf("this deployment does not issue actor identity tokens (ateapi runs without --actor-id-jwt-pool), required by this actor's SystemInfo volumes") + } + poolBytes, err := os.ReadFile(jwtPoolFile) + if err != nil { + return fmt.Errorf("while reading the actor JWT signing pool: %w", err) + } + pool, err := localjwtauthority.Unmarshal(poolBytes) + if err != nil { + return fmt.Errorf("while unmarshaling the actor JWT signing pool: %w", err) + } + if len(pool.Authorities) == 0 { + return fmt.Errorf("the actor JWT signing pool contains no authorities") + } + authority := pool.Authorities[0] + + meta := actor.GetMetadata() + now := time.Now() + for _, vol := range template.Spec.Volumes { + if vol.VolumeSource.SystemInfo == nil { + continue + } + for _, ds := range vol.VolumeSource.SystemInfo.DataSources { + if ds.ActorIdentityToken == nil { + continue + } + + ttl := defaultActorIdentityTokenTTL + if ds.ActorIdentityToken.ExpirationSeconds != nil { + ttl = time.Duration(*ds.ActorIdentityToken.ExpirationSeconds) * time.Second + } + claims := &actoridjwt.Claims{ + Issuer: actorIdentityTokenIssuer, + Subject: fmt.Sprintf("atespaces:%s:actors:%s", meta.GetAtespace(), meta.GetName()), + Audiences: []string{ds.ActorIdentityToken.Audience}, + Expiration: now.Add(ttl), + NotBefore: now.Add(-5 * time.Minute), + IssuedAt: now, + JTI: rand.Text(), + Substrate: actoridjwt.SubstrateClaims{ + Atespace: meta.GetAtespace(), + ActorName: meta.GetName(), + ActorUid: meta.GetUid(), + }, + } + wireClaims, err := actoridjwt.ClaimsToWire(claims) + if err != nil { + return fmt.Errorf("while building actor identity token claims (volume %q): %w", vol.Name, err) + } + token, err := actoridjwt.Sign(wireClaims, authority.SigningKey, authority.Algorithm, authority.ID) + if err != nil { + return fmt.Errorf("while signing actor identity token (volume %q): %w", vol.Name, err) + } + + entry, ok := wire[key{vol.Name, ds.ActorIdentityToken.Path}] + if !ok { + // The wire spec is built from this same template, so a missing + // entry means the two views diverged — a bug, not user error. + return fmt.Errorf("internal error: no wire entry for actor identity token at volume %q path %q", vol.Name, ds.ActorIdentityToken.Path) + } + entry.Token = []byte(token) + } + } + return nil +} diff --git a/cmd/ateapi/internal/controlapi/actor_identity_token_test.go b/cmd/ateapi/internal/controlapi/actor_identity_token_test.go new file mode 100644 index 000000000..e51909588 --- /dev/null +++ b/cmd/ateapi/internal/controlapi/actor_identity_token_test.go @@ -0,0 +1,187 @@ +// 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 controlapi + +import ( + "encoding/base64" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/agent-substrate/substrate/internal/localjwtauthority" + atev1alpha1 "github.com/agent-substrate/substrate/pkg/api/v1alpha1" + "github.com/agent-substrate/substrate/pkg/proto/ateapipb" + "k8s.io/utils/ptr" +) + +// writeTestJWTPool generates a single-authority signing pool on disk, the +// shape `kubectl-ate admin make-jwt-pool` provisions for ateapi. +func writeTestJWTPool(t *testing.T) string { + t.Helper() + authority, err := localjwtauthority.GenerateECDSAP256Authority("test-authority") + if err != nil { + t.Fatalf("generating JWT authority: %v", err) + } + poolBytes, err := localjwtauthority.Marshal(&localjwtauthority.Pool{Authorities: []*localjwtauthority.Authority{authority}}) + if err != nil { + t.Fatalf("marshaling JWT pool: %v", err) + } + path := filepath.Join(t.TempDir(), "pool.json") + if err := os.WriteFile(path, poolBytes, 0o600); err != nil { + t.Fatalf("writing JWT pool: %v", err) + } + return path +} + +func tokenTemplate(volumeName, audience, path string, expirationSeconds *int64) *atev1alpha1.ActorTemplate { + return &atev1alpha1.ActorTemplate{ + Spec: atev1alpha1.ActorTemplateSpec{ + Volumes: []atev1alpha1.Volume{{ + Name: volumeName, + VolumeSource: atev1alpha1.VolumeSource{ + SystemInfo: &atev1alpha1.SystemInfoVolumeSource{ + DataSources: []atev1alpha1.SystemInfoDataSource{ + {ActorIdentityToken: &atev1alpha1.ActorIdentityTokenDataSource{ + Audience: audience, + ExpirationSeconds: expirationSeconds, + Path: path, + }}, + }, + }, + }, + }}, + }, + } +} + +// decodeJWTPayload returns the (unverified) claims of a compact JWS. The mint +// test asserts claim contents; signature correctness is actoridjwt's own +// test territory. +func decodeJWTPayload(t *testing.T, token string) map[string]any { + t.Helper() + parts := strings.Split(token, ".") + if len(parts) != 3 { + t.Fatalf("token is not a compact JWS (%d parts)", len(parts)) + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + t.Fatalf("decoding JWT payload: %v", err) + } + claims := map[string]any{} + if err := json.Unmarshal(payload, &claims); err != nil { + t.Fatalf("unmarshaling JWT claims: %v", err) + } + return claims +} + +func TestMintActorIdentityTokens(t *testing.T) { + poolFile := writeTestJWTPool(t) + template := tokenTemplate("system-info", "verifier.example.com", "identity/token", ptr.To(int64(900))) + actor := &ateapipb.Actor{Metadata: &ateapipb.ResourceMetadata{ + Atespace: "team-a", Name: "actor-1", Uid: "uid-1", + }} + + spec, err := workloadSpecFromActorTemplate(template, nil) + if err != nil { + t.Fatalf("workloadSpecFromActorTemplate: %v", err) + } + + t.Run("mints a token into the wire spec with the requested binding", func(t *testing.T) { + before := time.Now() + if err := mintActorIdentityTokens(poolFile, template, spec, actor); err != nil { + t.Fatalf("mintActorIdentityTokens: %v", err) + } + entry := spec.GetVolumes()[0].GetSystemInfo().GetDataSources()[0].GetActorIdentityToken() + if entry.GetPath() != "identity/token" { + t.Errorf("path = %q, want %q", entry.GetPath(), "identity/token") + } + if len(entry.GetToken()) == 0 { + t.Fatal("token is empty") + } + + claims := decodeJWTPayload(t, string(entry.GetToken())) + if got := claims["sub"]; got != "atespaces:team-a:actors:actor-1" { + t.Errorf("sub = %v, want atespaces:team-a:actors:actor-1", got) + } + aud, _ := claims["aud"].([]any) + if len(aud) != 1 || aud[0] != "verifier.example.com" { + t.Errorf("aud = %v, want [verifier.example.com]", claims["aud"]) + } + exp, _ := claims["exp"].(float64) + iat, _ := claims["iat"].(float64) + if got := exp - iat; got != 900 { + t.Errorf("exp-iat = %v, want the requested 900s TTL", got) + } + if got := time.Unix(int64(iat), 0); got.Before(before.Add(-time.Minute)) || got.After(time.Now().Add(time.Minute)) { + t.Errorf("iat = %v, want approximately now", got) + } + // The substrate claims ride under the "ate.dev" key (see + // actoridjwt.WireClaims), the same shape MintJWT produces, so + // verifiers need one code path for both. + sub, _ := claims["ate.dev"].(map[string]any) + if sub == nil { + t.Fatalf("no ate.dev claims object found in %v", claims) + } + if sub["actorUid"] != "uid-1" { + t.Errorf("ate.dev actorUid = %v, want uid-1", sub["actorUid"]) + } + if sub["atespace"] != "team-a" || sub["actorName"] != "actor-1" { + t.Errorf("ate.dev identity = %v/%v, want team-a/actor-1", sub["atespace"], sub["actorName"]) + } + }) + + t.Run("two mints for the same actor differ (fresh JTI per activation)", func(t *testing.T) { + specA, _ := workloadSpecFromActorTemplate(template, nil) + specB, _ := workloadSpecFromActorTemplate(template, nil) + if err := mintActorIdentityTokens(poolFile, template, specA, actor); err != nil { + t.Fatalf("mint A: %v", err) + } + if err := mintActorIdentityTokens(poolFile, template, specB, actor); err != nil { + t.Fatalf("mint B: %v", err) + } + a := specA.GetVolumes()[0].GetSystemInfo().GetDataSources()[0].GetActorIdentityToken().GetToken() + b := specB.GetVolumes()[0].GetSystemInfo().GetDataSources()[0].GetActorIdentityToken().GetToken() + if string(a) == string(b) { + t.Error("two mints produced identical tokens; JTI should make every mint unique") + } + }) + + t.Run("no token sources is a no-op even without a pool", func(t *testing.T) { + plain := &atev1alpha1.ActorTemplate{} + spec, _ := workloadSpecFromActorTemplate(plain, nil) + if err := mintActorIdentityTokens("", plain, spec, actor); err != nil { + t.Fatalf("mintActorIdentityTokens: %v", err) + } + }) + + t.Run("token sources without a pool fail closed naming the flag", func(t *testing.T) { + spec, _ := workloadSpecFromActorTemplate(template, nil) + err := mintActorIdentityTokens("", template, spec, actor) + if err == nil || !strings.Contains(err.Error(), "actor-id-jwt-pool") { + t.Errorf("error = %v, want no-signing-pool error naming the flag", err) + } + }) + + t.Run("unreadable pool fails closed", func(t *testing.T) { + spec, _ := workloadSpecFromActorTemplate(template, nil) + err := mintActorIdentityTokens(filepath.Join(t.TempDir(), "missing.json"), template, spec, actor) + if err == nil || !strings.Contains(err.Error(), "signing pool") { + t.Errorf("error = %v, want unreadable-pool error", err) + } + }) +} diff --git a/cmd/ateapi/internal/controlapi/functionaltest/common_test.go b/cmd/ateapi/internal/controlapi/functionaltest/common_test.go index 1cd642a03..c0894e23f 100644 --- a/cmd/ateapi/internal/controlapi/functionaltest/common_test.go +++ b/cmd/ateapi/internal/controlapi/functionaltest/common_test.go @@ -136,6 +136,7 @@ func setupTestWithVolumePlugins(t *testing.T, ns string, plugins map[string]volu ateletFactory, ateletInformer := controlapi.AteletInformer(k8sClient) scFactory := informers.NewSharedInformerFactory(k8sClient, 0) scLister := scFactory.Storage().V1().StorageClasses().Lister() + ctbLister := scFactory.Certificates().V1beta1().ClusterTrustBundles().Lister() substrateInformerFactory := externalversions.NewSharedInformerFactory(substrateClient, 0) actorTemplateLister := substrateInformerFactory.Api().V1alpha1().ActorTemplates().Lister() @@ -191,7 +192,7 @@ func setupTestWithVolumePlugins(t *testing.T, ns string, plugins map[string]volu mockDriverName: mockPlugin, } } - service := controlapi.NewService(persistence, wc, actorTemplateLister, workerPoolLister, sandboxConfigLister, csiDriverConfigLister, scLister, dialer, instruments, "", volPlugins) + service := controlapi.NewService(persistence, wc, actorTemplateLister, workerPoolLister, sandboxConfigLister, csiDriverConfigLister, scLister, ctbLister, dialer, instruments, "", "", volPlugins) // 5. Start REAL gRPC Server for ATE API grpcServer := grpc.NewServer(grpc.UnaryInterceptor(ateinterceptors.ServerUnaryInterceptor)) diff --git a/cmd/ateapi/internal/controlapi/service.go b/cmd/ateapi/internal/controlapi/service.go index 8dc154c2d..5c39007df 100644 --- a/cmd/ateapi/internal/controlapi/service.go +++ b/cmd/ateapi/internal/controlapi/service.go @@ -25,6 +25,7 @@ import ( "github.com/agent-substrate/substrate/internal/volume/csi" listersv1alpha1 "github.com/agent-substrate/substrate/pkg/client/listers/api/v1alpha1" "github.com/agent-substrate/substrate/pkg/proto/ateapipb" + certlisters "k8s.io/client-go/listers/certificates/v1beta1" storagev1listers "k8s.io/client-go/listers/storage/v1" ) @@ -60,9 +61,11 @@ func NewService( sandboxConfigLister listersv1alpha1.SandboxConfigLister, csiDriverConfigLister listersv1alpha1.CSIDriverConfigLister, storageClassLister storagev1listers.StorageClassLister, + clusterTrustBundleLister certlisters.ClusterTrustBundleLister, dialer *AteletDialer, instruments *Instruments, egressGatewayAddress string, + actorIDJWTPoolFile string, volumePlugins map[string]volume.VolumePluginControlPlane, ) *Service { s := &Service{ @@ -76,7 +79,7 @@ func NewService( instruments: instruments, volumePlugins: volumePlugins, } - s.actorWorkflow = NewActorWorkflow(persistence, workerCache, dialer, actorTemplateLister, workerPoolLister, sandboxConfigLister, storageClassLister, instruments, egressGatewayAddress, s) + s.actorWorkflow = NewActorWorkflow(persistence, workerCache, dialer, actorTemplateLister, workerPoolLister, sandboxConfigLister, storageClassLister, clusterTrustBundleLister, instruments, egressGatewayAddress, actorIDJWTPoolFile, s) return s } diff --git a/cmd/ateapi/internal/controlapi/trust_bundle.go b/cmd/ateapi/internal/controlapi/trust_bundle.go new file mode 100644 index 000000000..35b336500 --- /dev/null +++ b/cmd/ateapi/internal/controlapi/trust_bundle.go @@ -0,0 +1,153 @@ +// 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 controlapi + +import ( + "fmt" + "slices" + "strings" + + "github.com/agent-substrate/substrate/internal/pemutil" + "github.com/agent-substrate/substrate/internal/proto/ateletpb" + atev1alpha1 "github.com/agent-substrate/substrate/pkg/api/v1alpha1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + certlisters "k8s.io/client-go/listers/certificates/v1beta1" +) + +// EgressTrustBundleName is the well-known name of the egress gateway CA +// bundle (#823): the trust anchors for the per-SNI leaves the egress gateway +// mints, maintained by atecontroller from the egress-mitm-ca-pool. +const EgressTrustBundleName = "egress-mitm.ate.dev" + +// supportedTrustBundles 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, ", ") +} + +// resolveTrustBundles fills the PemBundle bytes of every trustBundle data +// source in workloadSpec, resolving each named bundle through its backend and +// sanitizing its PEM the way kubelet does for projections (CERTIFICATE +// blocks only, deduplicated). +// +// The wire spec carries only {path, pem_bundle}; the bundle NAME lives in the +// ActorTemplate, so resolution walks the template and locates the matching +// wire entry by volume name + path. It must run on the resume path, before +// the spec is sent to atelet: an unsupported name, an unavailable backend, +// or a missing, empty, or unparseable bundle fails the actor start with an +// error naming the bundle, per the SystemInfo volume contract. atelet never +// talks to any bundle backend. +func resolveTrustBundles(lister certlisters.ClusterTrustBundleLister, template *atev1alpha1.ActorTemplate, workloadSpec *ateletpb.WorkloadSpec) error { + if template == nil { + return nil + } + + // Wire entries indexed by (volume name, path) for filling in place. + type key struct{ volume, path string } + wire := map[key]*ateletpb.TrustBundleDataSource{} + for _, vol := range workloadSpec.GetVolumes() { + for _, ds := range vol.GetSystemInfo().GetDataSources() { + if tb := ds.GetTrustBundle(); tb != nil { + wire[key{vol.GetName(), tb.GetPath()}] = tb + } + } + } + if len(wire) == 0 { + return nil + } + + // One resolution per distinct bundle name, shared across references. + resolved := map[string][]byte{} + for _, vol := range template.Spec.Volumes { + if vol.VolumeSource.SystemInfo == nil { + continue + } + for _, ds := range vol.VolumeSource.SystemInfo.DataSources { + if ds.TrustBundle == nil { + continue + } + name := ds.TrustBundle.Name + objectName, supported := supportedTrustBundles[name] + if !supported { + return fmt.Errorf("trust bundle %q is not supported by this deployment (supported: %s; referenced by volume %q)", name, supportedTrustBundleNames(), vol.Name) + } + + pemBundle, ok := resolved[name] + if !ok { + var err error + pemBundle, err = fetchClusterTrustBundle(lister, objectName) + if err != nil { + return fmt.Errorf("trust bundle %q (referenced by volume %q): %w", name, vol.Name, err) + } + resolved[name] = pemBundle + } + + entry, ok := wire[key{vol.Name, ds.TrustBundle.Path}] + if !ok { + // The wire spec is built from this same template, so a missing + // entry means the two views diverged — a bug, not user error. + return fmt.Errorf("internal error: no wire entry for trust bundle %q at volume %q path %q", name, vol.Name, ds.TrustBundle.Path) + } + entry.PemBundle = pemBundle + } + } + return nil +} + +// fetchClusterTrustBundle is the Kubernetes backend for supported trust +// bundles: the bundle is read from the ClusterTrustBundle +// (certificates.k8s.io/v1beta1) named objectName, per the allowlist mapping. +// This is currently the only backend; the eventual backend registry (#932) +// slots in here. +func fetchClusterTrustBundle(lister certlisters.ClusterTrustBundleLister, objectName string) ([]byte, error) { + // A nil lister means ateapi found the ClusterTrustBundle API unavailable + // at startup (certificates.k8s.io/v1beta1 is feature-gated; see + // cmd/ateapi/main.go). Fail the start rather than panic on the lister. + if lister == nil { + return nil, fmt.Errorf("bundle backend unavailable in this deployment (the cluster does not serve certificates.k8s.io/v1beta1)") + } + bundle, err := lister.Get(objectName) + if apierrors.IsNotFound(err) { + return nil, fmt.Errorf("ClusterTrustBundle %q not found", objectName) + } else if err != nil { + return nil, fmt.Errorf("while reading ClusterTrustBundle %q: %w", objectName, err) + } + pemBundle, err := pemutil.SanitizeCertificateBundle([]byte(bundle.Spec.TrustBundle)) + if err != nil { + return nil, fmt.Errorf("unusable trust bundle: %w", err) + } + return pemBundle, nil +} diff --git a/cmd/ateapi/internal/controlapi/trust_bundle_test.go b/cmd/ateapi/internal/controlapi/trust_bundle_test.go new file mode 100644 index 000000000..90fcf8cff --- /dev/null +++ b/cmd/ateapi/internal/controlapi/trust_bundle_test.go @@ -0,0 +1,169 @@ +// 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 controlapi + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "strings" + "testing" + "time" + + atev1alpha1 "github.com/agent-substrate/substrate/pkg/api/v1alpha1" + 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) +} + +func trustBundleTemplate(volumeName, bundleName, path string) *atev1alpha1.ActorTemplate { + return &atev1alpha1.ActorTemplate{ + Spec: atev1alpha1.ActorTemplateSpec{ + Volumes: []atev1alpha1.Volume{{ + Name: volumeName, + VolumeSource: atev1alpha1.VolumeSource{ + SystemInfo: &atev1alpha1.SystemInfoVolumeSource{ + DataSources: []atev1alpha1.SystemInfoDataSource{ + {TrustBundle: &atev1alpha1.TrustBundleDataSource{Name: bundleName, Path: path}}, + }, + }, + }, + }}, + }, + } +} + +// 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 TestResolveTrustBundles(t *testing.T) { + certPEM := testCertPEM(t) + junk := "garbage\n" + string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: []byte("x")})) + + template := trustBundleTemplate("system-info", EgressTrustBundleName, "trust/ca.pem") + spec, err := workloadSpecFromActorTemplate(template, nil) + if err != nil { + t.Fatalf("workloadSpecFromActorTemplate: %v", err) + } + + t.Run("resolves and sanitizes into the wire spec", 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)}, + }) + if err := resolveTrustBundles(lister, template, spec); err != nil { + t.Fatalf("resolveTrustBundles: %v", err) + } + got := spec.GetVolumes()[0].GetSystemInfo().GetDataSources()[0].GetTrustBundle() + if got.GetPath() != "trust/ca.pem" { + t.Errorf("path = %q, want %q", got.GetPath(), "trust/ca.pem") + } + if string(got.GetPemBundle()) != string(certPEM) { + t.Errorf("pem bundle = %q, want the sanitized certificate", got.GetPemBundle()) + } + }) + + t.Run("unsupported bundle name fails naming it and the allowlist", func(t *testing.T) { + other := trustBundleTemplate("system-info", "my-own-bundle", "trust/ca.pem") + spec, _ := workloadSpecFromActorTemplate(other, nil) + // 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 := resolveTrustBundles(lister, other, spec) + 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) { + spec, _ := workloadSpecFromActorTemplate(template, nil) + err := resolveTrustBundles(ctbLister(t), template, spec) + if err == nil || !strings.Contains(err.Error(), egressTrustBundleObjectName) || !strings.Contains(err.Error(), "not found") { + t.Errorf("error = %v, want not-found naming the bundle", err) + } + }) + + t.Run("unusable bundle fails naming it", func(t *testing.T) { + spec, _ := workloadSpecFromActorTemplate(template, nil) + lister := ctbLister(t, &certsv1beta1.ClusterTrustBundle{ + ObjectMeta: metav1.ObjectMeta{Name: egressTrustBundleObjectName}, + Spec: certsv1beta1.ClusterTrustBundleSpec{TrustBundle: junk}, + }) + err := resolveTrustBundles(lister, template, spec) + if err == nil || !strings.Contains(err.Error(), EgressTrustBundleName) || !strings.Contains(err.Error(), "unusable") { + t.Errorf("error = %v, want unusable-bundle naming the bundle", err) + } + }) + + t.Run("no trustBundle sources is a no-op even with a nil lister", func(t *testing.T) { + plain := &atev1alpha1.ActorTemplate{} + spec, _ := workloadSpecFromActorTemplate(plain, nil) + if err := resolveTrustBundles(nil, plain, spec); err != nil { + t.Fatalf("resolveTrustBundles: %v", err) + } + }) + + t.Run("trustBundle sources with a nil lister fail with a clear error, not a panic", func(t *testing.T) { + // nil lister = ateapi booted on a cluster without the feature-gated + // ClusterTrustBundle API (see cmd/ateapi/main.go) — the initial + // Kubernetes backend is unavailable in this deployment. + spec, _ := workloadSpecFromActorTemplate(template, nil) + err := resolveTrustBundles(nil, template, spec) + if err == nil || !strings.Contains(err.Error(), "backend unavailable in this deployment") { + t.Errorf("error = %v, want backend-unavailable error", err) + } + }) +} diff --git a/cmd/ateapi/internal/controlapi/workflow.go b/cmd/ateapi/internal/controlapi/workflow.go index 7954e7ef5..5450d4358 100644 --- a/cmd/ateapi/internal/controlapi/workflow.go +++ b/cmd/ateapi/internal/controlapi/workflow.go @@ -31,6 +31,7 @@ import ( "go.opentelemetry.io/otel/trace" grpcCodes "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + certlisters "k8s.io/client-go/listers/certificates/v1beta1" storagev1listers "k8s.io/client-go/listers/storage/v1" ) @@ -68,17 +69,22 @@ func markSkipped(ctx context.Context, reason string) { // ActorWorkflow handles the workflows for actor's resume / suspend operations. type ActorWorkflow struct { - store actorWorkflowStore - workerCache *workercache.Cache - scheduler scheduling.Scheduler - dialer *AteletDialer - actorTemplateLister listersv1alpha1.ActorTemplateLister - workerPoolLister listersv1alpha1.WorkerPoolLister - sandboxConfigLister listersv1alpha1.SandboxConfigLister - storageClassLister storagev1listers.StorageClassLister - instruments *Instruments - egressGatewayAddress string - pluginRegistry VolumePluginRegistry + store actorWorkflowStore + workerCache *workercache.Cache + scheduler scheduling.Scheduler + dialer *AteletDialer + actorTemplateLister listersv1alpha1.ActorTemplateLister + workerPoolLister listersv1alpha1.WorkerPoolLister + sandboxConfigLister listersv1alpha1.SandboxConfigLister + storageClassLister storagev1listers.StorageClassLister + clusterTrustBundleLister certlisters.ClusterTrustBundleLister + instruments *Instruments + egressGatewayAddress string + // actorIDJWTPoolFile is the signing pool for actorIdentityToken data + // sources (mintActorIdentityTokens); empty means the deployment does not + // issue actor identity tokens and referencing templates fail actor start. + actorIDJWTPoolFile string + pluginRegistry VolumePluginRegistry } // NewActorWorkflow creates a new ActorWorkflow. instruments may be nil. @@ -90,22 +96,26 @@ func NewActorWorkflow( workerPoolLister listersv1alpha1.WorkerPoolLister, sandboxConfigLister listersv1alpha1.SandboxConfigLister, storageClassLister storagev1listers.StorageClassLister, + clusterTrustBundleLister certlisters.ClusterTrustBundleLister, instruments *Instruments, egressGatewayAddress string, + actorIDJWTPoolFile string, pluginRegistry VolumePluginRegistry, ) *ActorWorkflow { return &ActorWorkflow{ - store: store, - workerCache: workerCache, - scheduler: scheduling.New(workerCache, scheduling.WithMeter(otel.Meter("ateapi"))), - dialer: dialer, - actorTemplateLister: actorTemplateLister, - workerPoolLister: workerPoolLister, - sandboxConfigLister: sandboxConfigLister, - storageClassLister: storageClassLister, - instruments: instruments, - egressGatewayAddress: egressGatewayAddress, - pluginRegistry: pluginRegistry, + store: store, + workerCache: workerCache, + scheduler: scheduling.New(workerCache, scheduling.WithMeter(otel.Meter("ateapi"))), + dialer: dialer, + actorTemplateLister: actorTemplateLister, + workerPoolLister: workerPoolLister, + sandboxConfigLister: sandboxConfigLister, + storageClassLister: storageClassLister, + clusterTrustBundleLister: clusterTrustBundleLister, + instruments: instruments, + egressGatewayAddress: egressGatewayAddress, + actorIDJWTPoolFile: actorIDJWTPoolFile, + pluginRegistry: pluginRegistry, } } diff --git a/cmd/ateapi/internal/controlapi/workflow_resume.go b/cmd/ateapi/internal/controlapi/workflow_resume.go index 2480cb344..d3ac2063e 100644 --- a/cmd/ateapi/internal/controlapi/workflow_resume.go +++ b/cmd/ateapi/internal/controlapi/workflow_resume.go @@ -636,6 +636,17 @@ func (w *ActorWorkflow) ensureAteletRestored(ctx context.Context, actorRef resou if err != nil { return tele, err } + // The spec is about to be sent to atelet, so resolve referenced + // trust bundles into it now; a missing or unusable bundle fails the + // actor start here, with an error naming the bundle. + if err := resolveTrustBundles(w.clusterTrustBundleLister, actorTemplate, workloadSpec); err != nil { + return tele, err + } + // Likewise mint referenced actor identity tokens into the spec: the mint + // is part of this activation, and a missing signing pool fails the start. + if err := mintActorIdentityTokens(w.actorIDJWTPoolFile, actorTemplate, workloadSpec, actor); err != nil { + return tele, err + } egressGateway := w.egressGateway() // The actor's declared limits ride the RPC down to the sandbox so it is sized diff --git a/cmd/ateapi/internal/controlapi/workflow_suspend_test.go b/cmd/ateapi/internal/controlapi/workflow_suspend_test.go index 57c1ca3c2..f44f48f50 100644 --- a/cmd/ateapi/internal/controlapi/workflow_suspend_test.go +++ b/cmd/ateapi/internal/controlapi/workflow_suspend_test.go @@ -703,7 +703,7 @@ func TestSuspendActor_PausedWithoutLocalSnapshotCrashes(t *testing.T) { }); err != nil { t.Fatalf("add template to indexer: %v", err) } - w := NewActorWorkflow(st, nil, nil, listersv1alpha1.NewActorTemplateLister(indexer), nil, nil, nil, nil, "", nil) + w := NewActorWorkflow(st, nil, nil, listersv1alpha1.NewActorTemplateLister(indexer), nil, nil, nil, nil, nil, "", "", nil) seedWorkflowActor(t, ctx, st, resources.ActorRef{Atespace: "team-a", Name: "id1"}, "ns", "tmpl1", ateapipb.ActorState_ACTOR_STATE_PAUSED) diff --git a/cmd/ateapi/internal/controlapi/workflow_testutil_test.go b/cmd/ateapi/internal/controlapi/workflow_testutil_test.go index c8da2fed8..8e15ad05a 100644 --- a/cmd/ateapi/internal/controlapi/workflow_testutil_test.go +++ b/cmd/ateapi/internal/controlapi/workflow_testutil_test.go @@ -49,7 +49,7 @@ func newTestActorWorkflow(t *testing.T, st store.Interface, tmplNamespace, tmplN }); err != nil { t.Fatalf("add template to indexer: %v", err) } - return NewActorWorkflow(st, nil, nil, listersv1alpha1.NewActorTemplateLister(indexer), nil, nil, nil, nil, "", nil) + return NewActorWorkflow(st, nil, nil, listersv1alpha1.NewActorTemplateLister(indexer), nil, nil, nil, nil, nil, "", "", nil) } // seedWorkflowActor stores an actor with the given state, bound to the given diff --git a/cmd/ateapi/internal/controlapi/workload_spec.go b/cmd/ateapi/internal/controlapi/workload_spec.go index cfb6a5084..c2a8be88c 100644 --- a/cmd/ateapi/internal/controlapi/workload_spec.go +++ b/cmd/ateapi/internal/controlapi/workload_spec.go @@ -56,6 +56,28 @@ func workloadSpecFromActorTemplate(actorTemplate *atev1alpha1.ActorTemplate, act ActorMetadata: actorMetadata, }, }) + case dataSource.TrustBundle != nil: + // PemBundle stays empty here: resolveTrustBundles + // fills it on the resume path, immediately before the spec + // is sent to atelet. Pause/suspend specs keep it empty — + // nothing reads system-info contents at checkpoint time. + ateletSystemInfo.DataSources = append(ateletSystemInfo.DataSources, &ateletpb.SystemInfoDataSource{ + DataSource: &ateletpb.SystemInfoDataSource_TrustBundle{ + TrustBundle: &ateletpb.TrustBundleDataSource{ + Path: dataSource.TrustBundle.Path, + }, + }, + }) + case dataSource.ActorIdentityToken != nil: + // Token stays empty here: mintActorIdentityTokens fills + // it on the resume path, like resolveTrustBundles above. + ateletSystemInfo.DataSources = append(ateletSystemInfo.DataSources, &ateletpb.SystemInfoDataSource{ + DataSource: &ateletpb.SystemInfoDataSource_ActorIdentityToken{ + ActorIdentityToken: &ateletpb.ActorIdentityTokenDataSource{ + Path: dataSource.ActorIdentityToken.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..1a00529c1 100644 --- a/cmd/ateapi/internal/controlapi/workload_spec_test.go +++ b/cmd/ateapi/internal/controlapi/workload_spec_test.go @@ -180,6 +180,118 @@ func TestWorkloadSpecFromActorTemplate(t *testing.T) { }, }, }, + { + name: "converts trustBundle data sources with unresolved bytes", + 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 bundle NAME does not cross the wire, and PemBundle stays + // empty: resolveTrustBundles fills it on the resume path. + 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{Path: "trust/ca.pem"}, + }}, + }, + }, + }, + }, + }, + Containers: []*ateletpb.Container{ + { + Name: "main", + Image: "main", + VolumeMounts: []*ateletpb.VolumeMount{ + {Name: "system-info", MountPath: "/run/substrate/certs"}, + }, + }, + }, + }, + }, + { + name: "converts actorIdentityToken data sources with unminted bytes", + 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{ + {ActorIdentityToken: &atev1alpha1.ActorIdentityTokenDataSource{Audience: "verifier.example.com", Path: "identity/token"}}, + }, + }, + }, + }, + }, + Containers: []atev1alpha1.Container{ + { + Name: "main", + Image: "main", + VolumeMounts: []atev1alpha1.VolumeMount{ + {Name: "system-info", MountPath: "/run/ate"}, + }, + }, + }, + }, + }, + // Audience and TTL do not cross the wire, and Token stays empty: + // mintActorIdentityTokens fills it on the resume path. + want: &ateletpb.WorkloadSpec{ + Volumes: []*ateletpb.Volume{ + { + Name: "system-info", + Source: &ateletpb.Volume_SystemInfo{ + SystemInfo: &ateletpb.SystemInfoVolume{ + DataSources: []*ateletpb.SystemInfoDataSource{ + {DataSource: &ateletpb.SystemInfoDataSource_ActorIdentityToken{ + ActorIdentityToken: &ateletpb.ActorIdentityTokenDataSource{Path: "identity/token"}, + }}, + }, + }, + }, + }, + }, + Containers: []*ateletpb.Container{ + { + Name: "main", + Image: "main", + VolumeMounts: []*ateletpb.VolumeMount{ + {Name: "system-info", MountPath: "/run/ate"}, + }, + }, + }, + }, + }, { name: "skips non-DurableDir volumes", template: &atev1alpha1.ActorTemplate{ diff --git a/cmd/ateapi/main.go b/cmd/ateapi/main.go index 5d1d3e0d5..0f4b006c1 100644 --- a/cmd/ateapi/main.go +++ b/cmd/ateapi/main.go @@ -52,8 +52,11 @@ import ( "google.golang.org/grpc/credentials" "google.golang.org/grpc/keepalive" "google.golang.org/grpc/reflection" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/informers" "k8s.io/client-go/kubernetes" + certlisters "k8s.io/client-go/listers/certificates/v1beta1" "k8s.io/client-go/rest" ) @@ -163,6 +166,26 @@ func main() { ateletPodInformerFactory, ateletPodInformer := controlapi.AteletInformer(clientset) scInformerFactory := informers.NewSharedInformerFactory(clientset, 0) storageClassLister := scInformerFactory.Storage().V1().StorageClasses().Lister() + // ClusterTrustBundles are resolved into SystemInfo volumes at actor + // start; the lister keeps that off the per-resume apiserver path. The + // v1beta1 API is feature-gated, so probe before registering the informer: + // registering against a cluster that does not serve the API — or one + // where ateapi may not list it — would hang the factory's + // WaitForCacheSync below and with it ateapi startup. Only a genuine + // not-served verdict degrades (the lister stays nil and actors that + // reference a trustBundle data source fail to start with a clear error); + // everything else fails startup, because a permanently degraded lister + // on a capable cluster would mislead every later actor-start error. + clusterTrustBundleLister := certlisters.ClusterTrustBundleLister(nil) + ctbServed, err := clusterTrustBundleAPIAvailable(ctx, clientset) + if err != nil { + serverboot.Fatal(ctx, "Failed to probe the ClusterTrustBundle API", err) + } + if ctbServed { + clusterTrustBundleLister = scInformerFactory.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") + } syncer := controlapi.NewWorkerPoolSyncer(persistence, workerPodInformer, workerPoolLister) syncer.Start(ctx) @@ -193,7 +216,7 @@ func main() { volPlugins := make(map[string]volume.VolumePluginControlPlane) ateletDialer := controlapi.NewAteletDialer(workerPodInformer.GetIndexer(), ateletPodInformer.GetIndexer(), *ateletClientCredBundle, *podIdentityCACerts) - sm := controlapi.NewService(persistence, workerCache, actorTemplateLister, workerPoolLister, sandboxConfigLister, csiDriverConfigLister, storageClassLister, ateletDialer, instruments, *egressGatewayAddress, volPlugins) + sm := controlapi.NewService(persistence, workerCache, actorTemplateLister, workerPoolLister, sandboxConfigLister, csiDriverConfigLister, storageClassLister, clusterTrustBundleLister, ateletDialer, instruments, *egressGatewayAddress, *actorIDJWTPoolFile, volPlugins) actorIdentitySrv := actoridentity.New(actorIdentityJWTIssuer, *actorIDJWTPoolFile, *actorIDCAPoolFile, persistence, workerCache) debugSrv := debugapi.NewService(persistence) @@ -498,3 +521,47 @@ func buildJWTProviders(ctx context.Context, cfg *ateapiauth.AuthenticationConfig } return serverCfg, actorIdentityIssuer, nil } + +// clusterTrustBundleAPIAvailable reports whether ateapi 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 ateapi is authorized to list it. Plain discovery cannot: it +// needs no RBAC, so on a cluster running a new ateapi against stale RBAC +// (version skew during rollout) discovery passes, list/watch then gets +// Forbidden forever, and WaitForCacheSync hangs startup. +// +// Verdicts: +// - nil error: the API is usable, register the informer. +// - NotFound: the feature-gated group/version is genuinely absent — +// (false, nil), the caller degrades with a warning. +// - 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 ateapi. +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.IsNotFound(err): + return false, nil + case apierrors.IsForbidden(err): + return false, fmt.Errorf("ateapi is not authorized to list clustertrustbundles.certificates.k8s.io (stale RBAC? the ate-api-server 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): + } + } + return false, fmt.Errorf("probing certificates.k8s.io/v1beta1 failed %d times: %w", attempts, lastErr) +} diff --git a/cmd/atelet/main.go b/cmd/atelet/main.go index 56a5bec52..ad0edbaf4 100644 --- a/cmd/atelet/main.go +++ b/cmd/atelet/main.go @@ -1545,6 +1545,9 @@ 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. +// +// 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, si *ateletpb.SystemInfoVolume) error { if err := os.MkdirAll(rootPath, 0o755); err != nil { return fmt.Errorf("while creating %q: %w", rootPath, err) @@ -1552,6 +1555,28 @@ func writeSystemInfoVolume(ctx context.Context, rootPath string, actorRef resour for _, dataSourceAny := range si.GetDataSources() { switch dataSource := dataSourceAny.GetDataSource().(type) { + case *ateletpb.SystemInfoDataSource_TrustBundle: + tb := dataSource.TrustBundle + // ateapi resolves the bundle before sending the spec; empty bytes + // mean it did not, and an empty trust file would fail the workload + // in far more confusing ways than failing the start does. + if len(tb.GetPemBundle()) == 0 { + return fmt.Errorf("trust bundle projection %q has no resolved PEM bytes", tb.GetPath()) + } + if err := writeSystemInfoFile(rootPath, tb.GetPath(), tb.GetPemBundle()); err != nil { + return err + } + case *ateletpb.SystemInfoDataSource_ActorIdentityToken: + tok := dataSource.ActorIdentityToken + // Same contract as trust bundles: ateapi mints before sending the + // spec, and an empty token file would fail the workload in far + // more confusing ways than failing the start does. + if len(tok.GetToken()) == 0 { + return fmt.Errorf("actor identity token projection %q has no minted token", tok.GetPath()) + } + if err := writeSystemInfoFile(rootPath, tok.GetPath(), tok.GetToken()); 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..4fb8d55d0 100644 --- a/cmd/atelet/main_test.go +++ b/cmd/atelet/main_test.go @@ -240,6 +240,84 @@ func TestWriteSystemInfoVolume_StableRealPaths(t *testing.T) { } } +func TestWriteSystemInfoVolume_ActorIdentityToken(t *testing.T) { + ctx := context.Background() + token := []byte("eyJoZWFkZXIK.eyJwYXlsb2FkCg.c2lnbmF0dXJlCg") + si := &ateletpb.SystemInfoVolume{ + DataSources: []*ateletpb.SystemInfoDataSource{ + {DataSource: &ateletpb.SystemInfoDataSource_ActorIdentityToken{ + ActorIdentityToken: &ateletpb.ActorIdentityTokenDataSource{Path: "identity/token", Token: token}, + }}, + }, + } + + 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", si); err != nil { + t.Fatalf("writeSystemInfoVolume: %v", err) + } + got, err := os.ReadFile(filepath.Join(root, "identity/token")) + if err != nil { + t.Fatalf("reading projected token: %v", err) + } + if string(got) != string(token) { + t.Errorf("content = %q, want %q", got, token) + } + + t.Run("unminted token fails rather than write an empty file", func(t *testing.T) { + unminted := &ateletpb.SystemInfoVolume{ + DataSources: []*ateletpb.SystemInfoDataSource{ + {DataSource: &ateletpb.SystemInfoDataSource_ActorIdentityToken{ + ActorIdentityToken: &ateletpb.ActorIdentityTokenDataSource{Path: "token"}, + }}, + }, + } + err := writeSystemInfoVolume(ctx, filepath.Join(t.TempDir(), "vol2"), ref, "uid-1", unminted) + if err == nil || !strings.Contains(err.Error(), "no minted token") { + t.Errorf("writeSystemInfoVolume = %v, want no-minted-token error", err) + } + }) +} + +func TestWriteSystemInfoVolume_TrustBundle(t *testing.T) { + ctx := context.Background() + pemBundle := []byte("-----BEGIN CERTIFICATE-----\nZmFrZQ==\n-----END CERTIFICATE-----\n") + si := &ateletpb.SystemInfoVolume{ + DataSources: []*ateletpb.SystemInfoDataSource{ + {DataSource: &ateletpb.SystemInfoDataSource_TrustBundle{ + TrustBundle: &ateletpb.TrustBundleDataSource{Path: "trust/ca.pem", PemBundle: pemBundle}, + }}, + }, + } + + 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", 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(pemBundle) { + t.Errorf("content = %q, want %q", got, pemBundle) + } + + t.Run("unresolved bytes fail rather than write an empty trust file", func(t *testing.T) { + unresolved := &ateletpb.SystemInfoVolume{ + DataSources: []*ateletpb.SystemInfoDataSource{ + {DataSource: &ateletpb.SystemInfoDataSource_TrustBundle{ + TrustBundle: &ateletpb.TrustBundleDataSource{Path: "ca.pem"}, + }}, + }, + } + err := writeSystemInfoVolume(ctx, filepath.Join(t.TempDir(), "vol2"), ref, "uid-1", unresolved) + if err == nil || !strings.Contains(err.Error(), "no resolved PEM bytes") { + t.Errorf("writeSystemInfoVolume = %v, want no-resolved-PEM error", err) + } + }) +} + func TestWriteFileAtomic(t *testing.T) { dir := t.TempDir() target := filepath.Join(dir, "actor-id") diff --git a/docs/api-guide.md b/docs/api-guide.md index b310b2ff9..ecd2c15aa 100644 --- a/docs/api-guide.md +++ b/docs/api-guide.md @@ -226,6 +226,53 @@ 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. 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 +``` + +ateapi resolves the bundle when the actor starts and sanitizes it the way kubelet does for projections: only `CERTIFICATE` PEM blocks are kept, deduplicated, with block headers stripped. 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. + +#### actorIdentityToken +The actorIdentityToken data source projects a signed JWT attesting the actor's identity to a single file — analogous to the [Kubernetes serviceAccountToken projected volume source](https://kubernetes.io/docs/concepts/storage/projected-volumes/#serviceaccounttoken). + +```yaml +spec: + volumes: + - name: identity + systemInfo: + dataSources: + - actorIdentityToken: + audience: some-verifier.example.com + expirationSeconds: 3600 # optional; default 3600, min 600, max 86400 + path: token + containers: + - name: main + # ... + volumeMounts: + - name: identity + mountPath: /run/ate # the actor reads /run/ate/token +``` + +ateapi mints the token when the actor starts, so the mint is inherently bound to the activation: the claims carry the actor's identity (`sub: atespaces::actors:`, plus the `ate.dev` claim object with `atespace`/`actorName`/`actorUid`), the requested `audience`, and standard `exp`/`iat`/`jti`. Tokens are re-minted on every Run/Restore — a resumed actor always carries a token for its own, current activation, and a token captured into a snapshot expires quickly and cannot be renewed from elsewhere (renewal is never a bearer operation). Verifiers must check `aud` and validate against the deployment's actor JWT authority. Workloads should re-read the file at time of use rather than caching it; live re-minting for actors that run longer than `expirationSeconds` is not yet implemented. Starting the actor fails, with a clear error, if the deployment has no actor JWT signing pool. + ### 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..439ab5399 100644 --- a/internal/e2e/fixtures/probe/main.go +++ b/internal/e2e/fixtures/probe/main.go @@ -22,6 +22,8 @@ package main import ( "bufio" + "crypto/tls" + "crypto/x509" "encoding/json" "fmt" "io" @@ -31,14 +33,18 @@ import ( "runtime" "strconv" "strings" + "time" ) -// The actorMetadata data-source files of the systemInfo volume that -// probe.yaml.tmpl mounts at /run/ate. +// The systemInfo volume data-source files that probe.yaml.tmpl mounts at +// /run/ate: the actorMetadata projections plus a trustBundle +// projection. const ( identityFile = "/run/ate/actor-id" atespaceFile = "/run/ate/atespace" uidFile = "/run/ate/actor-uid" + trustFile = "/run/ate/trust-bundle.pem" + tokenFile = "/run/ate/token" ) // procStatus is where the kernel reports this process's capability sets. Asking @@ -170,6 +176,8 @@ func whoami(w http.ResponseWriter, _ *http.Request) { "file": identityFile, "atespace": atespaceFile, "uid": uidFile, + "trust": trustFile, + "token": tokenFile, } { if b, err := os.ReadFile(path); err == nil { resp[key] = string(b) @@ -287,6 +295,61 @@ 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 + } + tlsCfg := &tls.Config{} + if roots := r.URL.Query().Get("roots"); roots != "system" { + b, err := os.ReadFile(trustFile) + if err != nil { + resp["error"] = "reading trust bundle: " + err.Error() + writeJSON(w, resp) + return + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(b) { + resp["error"] = "no certificates parsed from " + trustFile + writeJSON(w, resp) + return + } + tlsCfg.RootCAs = pool + } + client := &http.Client{ + Timeout: 20 * time.Second, + Transport: &http.Transport{TLSClientConfig: tlsCfg}, + } + res, err := client.Get(url) + if err != nil { + resp["error"] = err.Error() + writeJSON(w, resp) + return + } + defer res.Body.Close() + _, _ = io.Copy(io.Discard, res.Body) + resp["status"] = strconv.Itoa(res.StatusCode) + writeJSON(w, resp) +} + func writeJSON(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(v); err != nil { @@ -306,6 +369,7 @@ func main() { mux := http.NewServeMux() mux.HandleFunc("/whoami", whoami) + mux.HandleFunc("/fetch", fetch) mux.HandleFunc("/readfile", readfile) mux.HandleFunc("/writefile", writefile) mux.HandleFunc("/resources", resources) diff --git a/internal/e2e/fixtures/probe/probe.yaml.tmpl b/internal/e2e/fixtures/probe/probe.yaml.tmpl index 6bf31e64b..83206dbb1 100644 --- a/internal/e2e/fixtures/probe/probe.yaml.tmpl +++ b/internal/e2e/fixtures/probe/probe.yaml.tmpl @@ -57,6 +57,21 @@ ${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 ateapi's supported-bundle + # allowlist, which today contains only the egress gateway CA bundle. + - trustBundle: + name: egress-mitm.ate.dev + path: trust-bundle.pem + # Minted fresh by ateapi on every Run/Restore; the identity suite + # asserts the claims match the actor and that a resume re-mints. + - actorIdentityToken: + audience: ate-e2e.example.com + path: token containers: - name: probe image: ko://github.com/agent-substrate/substrate/internal/e2e/fixtures/probe diff --git a/internal/e2e/probe.go b/internal/e2e/probe.go index 56d7ab05f..3ee570f16 100644 --- a/internal/e2e/probe.go +++ b/internal/e2e/probe.go @@ -15,6 +15,7 @@ package e2e import ( + "context" "path/filepath" "testing" ) @@ -36,6 +37,11 @@ func DeployProbe(t *testing.T, bucket, name string) string { t.Fatalf("FindRepoRoot: %v", err) } + // The probe template projects the egress trust bundle, and every actor — + // including the fixture's golden boot — fails closed while the bundle is + // missing, so make sure it exists whatever suite is deploying. + EnsureEgressTrustBundle(t, context.Background(), GetClients()) + // One manifest, rendered for the sandbox class under test, so both apply // and delete consume the same file without any shell involved. manifest := RenderFixtureManifest(t, "internal/e2e/fixtures/probe/probe.yaml.tmpl", bucket, name) diff --git a/internal/e2e/suites/egressmitm/egressmitm_test.go b/internal/e2e/suites/egressmitm/egressmitm_test.go new file mode 100644 index 000000000..20d5692dd --- /dev/null +++ b/internal/e2e/suites/egressmitm/egressmitm_test.go @@ -0,0 +1,222 @@ +// 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 ateapi 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-ate-system --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-ate-system --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/" + + pos := probeFetch(t, ctx, rc, id, origin, "bundle") + if pos.Error != "" { + t.Fatalf("TLS through the MITM egress gateway with the projected trust bundle failed: %s — the projected anchors did not validate the gateway's minted leaf (or interception/minting is broken)", pos.Error) + } + if pos.Status != "200" { + t.Fatalf("fetch %s via projected bundle: status %s, want 200", origin, pos.Status) + } + + neg := probeFetch(t, ctx, rc, id, origin, "system") + if neg.Error == "" { + t.Errorf("fetch with system roots unexpectedly succeeded (status %s): the minted leaf should chain to no public CA — is the sdsmint (MITM) gateway actually deployed, or is egress running in passthrough mode?", neg.Status) + } else if !strings.Contains(neg.Error, "certificate") && !strings.Contains(neg.Error, "x509") { + t.Errorf("fetch with system roots failed, but not with a certificate-verification error: %s", neg.Error) + } +} + +type fetchResponse struct { + Status string `json:"status"` + Error string `json:"error"` +} + +// probeFetch asks the probe to fetch origin with the given roots mode. +// Router-level failures are retried for up to 30s (a resume can return +// before the route reaches the router's xDS snapshot); probe-level TLS +// failures are results, returned for the caller to assert on. +func probeFetch(t *testing.T, ctx context.Context, rc *e2e.RouterClient, id, origin, roots string) fetchResponse { + t.Helper() + path := "/fetch?roots=" + roots + "&url=" + url.QueryEscape(origin) + ref := resources.ActorRef{Atespace: probeNamespace, Name: id} + + deadline := time.Now().Add(30 * time.Second) + for { + resp, err := rc.Get(ctx, ref, path) + if err != nil { + t.Fatalf("GET %s for %q: %v", path, id, err) + } + body, readErr := io.ReadAll(resp.Body) + resp.Body.Close() + if readErr != nil { + t.Fatalf("reading %s response for %q: %v", path, id, readErr) + } + if resp.StatusCode == http.StatusOK { + var out fetchResponse + if err := json.Unmarshal(body, &out); err != nil { + t.Fatalf("decoding %s response for %q: %v (body %q)", path, id, err, body) + } + return out + } + if time.Now().After(deadline) { + t.Fatalf("GET %s for %q: status %d, body %q", path, id, resp.StatusCode, body) + } + time.Sleep(2 * time.Second) + } +} + +// The helpers below mirror the identity suite's: fixture golden wait and a +// self-healing actor lifecycle (actor records outlive the fixture namespace). + +func waitForGolden(t *testing.T, ctx context.Context, clients *e2e.Clients) { + t.Helper() + deadline := time.Now().Add(e2e.TemplateReadyTimeout(t)) + for time.Now().Before(deadline) { + at, err := clients.SubstrateK8s.ApiV1alpha1().ActorTemplates(probeNamespace).Get(ctx, probeTemplate, metav1.GetOptions{}) + if err == nil { + switch at.Status.Phase { + case v1alpha1.PhaseReady: + return + case v1alpha1.PhaseFailed: + t.Fatalf("probe ActorTemplate entered PhaseFailed") + } + } + time.Sleep(2 * time.Second) + } + t.Fatalf("timed out waiting for probe ActorTemplate to be Ready") +} + +func createAndResumeActor(t *testing.T, ctx context.Context, clients *e2e.Clients, id string) { + t.Helper() + _, _ = clients.SubstrateAPI.CreateAtespace(ctx, &ateapipb.CreateAtespaceRequest{Atespace: &ateapipb.Atespace{Metadata: &ateapipb.ResourceMetadata{Name: probeNamespace}}}) + ref := &ateapipb.ObjectRef{Atespace: probeNamespace, Name: id} + _, _ = clients.SubstrateAPI.SuspendActor(ctx, &ateapipb.SuspendActorRequest{Actor: ref}) + _, _ = clients.SubstrateAPI.DeleteActor(ctx, &ateapipb.DeleteActorRequest{Actor: ref}) + if _, err := clients.SubstrateAPI.CreateActor(ctx, &ateapipb.CreateActorRequest{Actor: &ateapipb.Actor{ + Metadata: &ateapipb.ResourceMetadata{Atespace: probeNamespace, Name: id}, + ActorTemplateNamespace: probeNamespace, + ActorTemplateName: probeTemplate, + }}); err != nil { + t.Fatalf("CreateActor %q: %v", id, err) + } + t.Cleanup(func() { + _, _ = clients.SubstrateAPI.SuspendActor(ctx, &ateapipb.SuspendActorRequest{Actor: ref}) + if _, err := clients.SubstrateAPI.DeleteActor(ctx, &ateapipb.DeleteActorRequest{Actor: ref}); err != nil { + t.Logf("cleanup: DeleteActor %q failed, actor leaked (remove with: kubectl ate delete actor %s -a %s): %v", id, id, probeNamespace, err) + } + }) + if _, err := clients.SubstrateAPI.ResumeActor(ctx, &ateapipb.ResumeActorRequest{Actor: ref}); err != nil { + t.Fatalf("ResumeActor %q: %v", id, err) + } +} + +func waitForActorState(t *testing.T, ctx context.Context, clients *e2e.Clients, actorName string, want ateapipb.ActorState) { + t.Helper() + deadline := time.Now().Add(60 * time.Second) + for time.Now().Before(deadline) { + resp, err := clients.SubstrateAPI.GetActor(ctx, &ateapipb.GetActorRequest{ + Actor: &ateapipb.ObjectRef{Atespace: probeNamespace, Name: actorName}, + }) + if err == nil && resp.GetStatus().GetState() == want { + return + } + time.Sleep(1 * time.Second) + } + t.Fatalf("timed out waiting for actor %q to reach state %v", actorName, want) +} diff --git a/internal/e2e/suites/egressmitm/testmain_test.go b/internal/e2e/suites/egressmitm/testmain_test.go new file mode 100644 index 000000000..0fd795832 --- /dev/null +++ b/internal/e2e/suites/egressmitm/testmain_test.go @@ -0,0 +1,24 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package egressmitm + +import ( + "os" + "testing" + + "github.com/agent-substrate/substrate/internal/e2e" +) + +func TestMain(m *testing.M) { os.Exit(e2e.RunTestMain(m)) } diff --git a/internal/e2e/suites/identity/identity_test.go b/internal/e2e/suites/identity/identity_test.go index 99bfa908a..09e70e566 100644 --- a/internal/e2e/suites/identity/identity_test.go +++ b/internal/e2e/suites/identity/identity_test.go @@ -16,9 +16,11 @@ package identity import ( "context" + "encoding/base64" "encoding/json" "io" "net/http" + "strings" "testing" "time" @@ -35,10 +37,19 @@ 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 ateapi'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"` + Token string `json:"token"` 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 +85,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 +132,19 @@ 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 ateapi (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 identity token must be a JWT minted for THIS actor and + // the template's audience (unverified decode: signature correctness is + // covered by ateapi's unit tests; the e2e pins the binding). + assertTokenClaims(t, id, got) + // 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,7 +165,16 @@ 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] + tokenBeforeResume := whoami(t, ctx, rc, id).Token ref := &ateapipb.ObjectRef{Atespace: probeNamespace, Name: id} if _, err := clients.SubstrateAPI.SuspendActor(ctx, &ateapipb.SuspendActorRequest{Actor: ref}); err != nil { t.Fatalf("SuspendActor %q: %v", id, err) @@ -161,6 +198,65 @@ 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) + } + // A resume is a new activation, and every activation mints a fresh token + // (new iat/jti) — a stale token here would mean the file rode the + // snapshot instead of being re-minted. + assertTokenClaims(t, id, got) + if got.Token == tokenBeforeResume { + t.Errorf("after suspend/resume: identity token unchanged; every Run/Restore must re-mint") + } +} + +// tokenAudience mirrors probe.yaml.tmpl's actorIdentityToken audience. +const tokenAudience = "ate-e2e.example.com" + +// assertTokenClaims decodes got.Token without verification and asserts it was +// minted for actor id and the fixture's audience. +func assertTokenClaims(t *testing.T, id string, got whoamiResponse) { + t.Helper() + if got.Token == "" { + t.Errorf("actor %q: /run/ate/token is empty (probe read error: %q)", id, got.Error) + return + } + parts := strings.Split(got.Token, ".") + if len(parts) != 3 { + t.Errorf("actor %q: token is not a compact JWS (%d parts)", id, len(parts)) + return + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + t.Errorf("actor %q: decoding token payload: %v", id, err) + return + } + var claims struct { + Sub string `json:"sub"` + Aud []string `json:"aud"` + Exp int64 `json:"exp"` + Ate struct { + Atespace string `json:"atespace"` + ActorName string `json:"actorName"` + ActorUID string `json:"actorUid"` + } `json:"ate.dev"` + } + if err := json.Unmarshal(payload, &claims); err != nil { + t.Errorf("actor %q: unmarshaling token claims: %v", id, err) + return + } + if want := "atespaces:" + probeNamespace + ":actors:" + id; claims.Sub != want { + t.Errorf("actor %q: token sub = %q, want %q", id, claims.Sub, want) + } + if len(claims.Aud) != 1 || claims.Aud[0] != tokenAudience { + t.Errorf("actor %q: token aud = %v, want [%s]", id, claims.Aud, tokenAudience) + } + if exp := time.Unix(claims.Exp, 0); !exp.After(time.Now()) { + t.Errorf("actor %q: token already expired at %v", id, exp) + } + if claims.Ate.Atespace != probeNamespace || claims.Ate.ActorName != id { + t.Errorf("actor %q: token ate.dev identity = %s/%s, want %s/%s", id, claims.Ate.Atespace, claims.Ate.ActorName, probeNamespace, id) + } } // 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..4012ab5a2 --- /dev/null +++ b/internal/pemutil/pemutil.go @@ -0,0 +1,54 @@ +// 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 ( + "crypto/sha256" + "encoding/pem" + "fmt" +) + +// SanitizeCertificateBundle re-encodes a PEM bundle keeping only CERTIFICATE +// blocks, with block headers stripped and exact duplicates (by DER bytes) +// removed, preserving first-seen order. 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. +func SanitizeCertificateBundle(in []byte) ([]byte, error) { + var out []byte + seen := map[[sha256.Size]byte]bool{} + rest := in + for { + var block *pem.Block + block, rest = pem.Decode(rest) + if block == nil { + break + } + if block.Type != "CERTIFICATE" { + continue + } + key := sha256.Sum256(block.Bytes) + if seen[key] { + continue + } + seen[key] = true + out = append(out, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: block.Bytes})...) + } + if len(out) == 0 { + return nil, fmt.Errorf("bundle contains no CERTIFICATE PEM blocks") + } + return out, nil +} diff --git a/internal/pemutil/pemutil_test.go b/internal/pemutil/pemutil_test.go new file mode 100644 index 000000000..612848630 --- /dev/null +++ b/internal/pemutil/pemutil_test.go @@ -0,0 +1,90 @@ +// 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) + } + want := append(append([]byte(nil), certA...), certB...) + if !bytes.Equal(got, want) { + t.Errorf("sanitized bundle mismatch:\ngot:\n%s\nwant:\n%s", got, want) + } + }) + + 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) + } + } + }) +} diff --git a/internal/proto/ateletpb/atelet.pb.go b/internal/proto/ateletpb/atelet.pb.go index 4b978c61b..0209aa6b0 100644 --- a/internal/proto/ateletpb/atelet.pb.go +++ b/internal/proto/ateletpb/atelet.pb.go @@ -947,11 +947,126 @@ func (x *ActorMetadataDataSource) GetItems() []*ActorMetadataItem { return nil } +// TrustBundleDataSource writes an already-resolved PEM certificate bundle to +// a file at the given path, relative to the root of the enclosing +// system-info volume. ateapi resolves and sanitizes the named trust bundle +// through its configured backend before sending the spec; atelet only writes +// the bytes and never talks to any bundle backend. +type TrustBundleDataSource struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + PemBundle []byte `protobuf:"bytes,2,opt,name=pem_bundle,json=pemBundle,proto3" json:"pem_bundle,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) GetPemBundle() []byte { + if x != nil { + return x.PemBundle + } + return nil +} + +// ActorIdentityTokenDataSource writes an already-minted actor identity JWT +// to a file at the given path, relative to the root of the enclosing +// system-info volume. ateapi mints the token (audience- and TTL-bound, per +// the template) when it builds the spec; atelet only writes the bytes. +type ActorIdentityTokenDataSource struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + Token []byte `protobuf:"bytes,2,opt,name=token,proto3" json:"token,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ActorIdentityTokenDataSource) Reset() { + *x = ActorIdentityTokenDataSource{} + mi := &file_atelet_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ActorIdentityTokenDataSource) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ActorIdentityTokenDataSource) ProtoMessage() {} + +func (x *ActorIdentityTokenDataSource) ProtoReflect() protoreflect.Message { + mi := &file_atelet_proto_msgTypes[14] + 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 ActorIdentityTokenDataSource.ProtoReflect.Descriptor instead. +func (*ActorIdentityTokenDataSource) Descriptor() ([]byte, []int) { + return file_atelet_proto_rawDescGZIP(), []int{14} +} + +func (x *ActorIdentityTokenDataSource) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *ActorIdentityTokenDataSource) GetToken() []byte { + if x != nil { + return x.Token + } + return nil +} + type SystemInfoDataSource struct { state protoimpl.MessageState `protogen:"open.v1"` // Types that are valid to be assigned to DataSource: // // *SystemInfoDataSource_ActorMetadata + // *SystemInfoDataSource_TrustBundle + // *SystemInfoDataSource_ActorIdentityToken DataSource isSystemInfoDataSource_DataSource `protobuf_oneof:"data_source"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -959,7 +1074,7 @@ type SystemInfoDataSource struct { func (x *SystemInfoDataSource) Reset() { *x = SystemInfoDataSource{} - mi := &file_atelet_proto_msgTypes[13] + mi := &file_atelet_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -971,7 +1086,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[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -984,7 +1099,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{15} } func (x *SystemInfoDataSource) GetDataSource() isSystemInfoDataSource_DataSource { @@ -1003,6 +1118,24 @@ 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 +} + +func (x *SystemInfoDataSource) GetActorIdentityToken() *ActorIdentityTokenDataSource { + if x != nil { + if x, ok := x.DataSource.(*SystemInfoDataSource_ActorIdentityToken); ok { + return x.ActorIdentityToken + } + } + return nil +} + type isSystemInfoDataSource_DataSource interface { isSystemInfoDataSource_DataSource() } @@ -1011,8 +1144,20 @@ 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"` +} + +type SystemInfoDataSource_ActorIdentityToken struct { + ActorIdentityToken *ActorIdentityTokenDataSource `protobuf:"bytes,3,opt,name=actor_identity_token,json=actorIdentityToken,proto3,oneof"` +} + func (*SystemInfoDataSource_ActorMetadata) isSystemInfoDataSource_DataSource() {} +func (*SystemInfoDataSource_TrustBundle) isSystemInfoDataSource_DataSource() {} + +func (*SystemInfoDataSource_ActorIdentityToken) 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 +1170,7 @@ type SystemInfoVolume struct { func (x *SystemInfoVolume) Reset() { *x = SystemInfoVolume{} - mi := &file_atelet_proto_msgTypes[14] + mi := &file_atelet_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1037,7 +1182,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[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1050,7 +1195,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{16} } func (x *SystemInfoVolume) GetDataSources() []*SystemInfoDataSource { @@ -1076,7 +1221,7 @@ type Volume struct { func (x *Volume) Reset() { *x = Volume{} - mi := &file_atelet_proto_msgTypes[15] + mi := &file_atelet_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1088,7 +1233,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[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1101,7 +1246,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{17} } func (x *Volume) GetName() string { @@ -1192,7 +1337,7 @@ type VolumeMount struct { func (x *VolumeMount) Reset() { *x = VolumeMount{} - mi := &file_atelet_proto_msgTypes[16] + mi := &file_atelet_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1204,7 +1349,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[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1217,7 +1362,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{18} } func (x *VolumeMount) GetName() string { @@ -1250,7 +1395,7 @@ type Container struct { func (x *Container) Reset() { *x = Container{} - mi := &file_atelet_proto_msgTypes[17] + mi := &file_atelet_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1262,7 +1407,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[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1275,7 +1420,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{19} } func (x *Container) GetName() string { @@ -1344,7 +1489,7 @@ type SecurityContext struct { func (x *SecurityContext) Reset() { *x = SecurityContext{} - mi := &file_atelet_proto_msgTypes[18] + mi := &file_atelet_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1356,7 +1501,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[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1369,7 +1514,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{20} } func (x *SecurityContext) GetCapabilities() *Capabilities { @@ -1391,7 +1536,7 @@ type Capabilities struct { func (x *Capabilities) Reset() { *x = Capabilities{} - mi := &file_atelet_proto_msgTypes[19] + mi := &file_atelet_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1403,7 +1548,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[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1416,7 +1561,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{21} } func (x *Capabilities) GetAdd() []string { @@ -1443,7 +1588,7 @@ type EnvEntry struct { func (x *EnvEntry) Reset() { *x = EnvEntry{} - mi := &file_atelet_proto_msgTypes[20] + mi := &file_atelet_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1455,7 +1600,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[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1468,7 +1613,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{22} } func (x *EnvEntry) GetName() string { @@ -1499,7 +1644,7 @@ type Readyz struct { func (x *Readyz) Reset() { *x = Readyz{} - mi := &file_atelet_proto_msgTypes[21] + mi := &file_atelet_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1511,7 +1656,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[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1524,7 +1669,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{23} } func (x *Readyz) GetHttpGet() *HTTPGetAction { @@ -1554,7 +1699,7 @@ type HTTPGetAction struct { func (x *HTTPGetAction) Reset() { *x = HTTPGetAction{} - mi := &file_atelet_proto_msgTypes[22] + mi := &file_atelet_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1566,7 +1711,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[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1579,7 +1724,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{24} } func (x *HTTPGetAction) GetPath() string { @@ -1604,7 +1749,7 @@ type RunResponse struct { func (x *RunResponse) Reset() { *x = RunResponse{} - mi := &file_atelet_proto_msgTypes[23] + mi := &file_atelet_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1616,7 +1761,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[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1629,7 +1774,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{25} } type LocalCheckpointConfiguration struct { @@ -1645,7 +1790,7 @@ type LocalCheckpointConfiguration struct { func (x *LocalCheckpointConfiguration) Reset() { *x = LocalCheckpointConfiguration{} - mi := &file_atelet_proto_msgTypes[24] + mi := &file_atelet_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1657,7 +1802,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[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1670,7 +1815,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{26} } func (x *LocalCheckpointConfiguration) GetSnapshotName() string { @@ -1691,7 +1836,7 @@ type ExternalCheckpointConfiguration struct { func (x *ExternalCheckpointConfiguration) Reset() { *x = ExternalCheckpointConfiguration{} - mi := &file_atelet_proto_msgTypes[25] + mi := &file_atelet_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1703,7 +1848,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[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1716,7 +1861,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{27} } func (x *ExternalCheckpointConfiguration) GetSnapshotUri() string { @@ -1754,7 +1899,7 @@ type CheckpointRequest struct { func (x *CheckpointRequest) Reset() { *x = CheckpointRequest{} - mi := &file_atelet_proto_msgTypes[26] + mi := &file_atelet_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1766,7 +1911,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[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1779,7 +1924,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{28} } func (x *CheckpointRequest) GetTargetAteomUid() string { @@ -1894,7 +2039,7 @@ type CheckpointResponse struct { func (x *CheckpointResponse) Reset() { *x = CheckpointResponse{} - mi := &file_atelet_proto_msgTypes[27] + mi := &file_atelet_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1906,7 +2051,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[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1919,7 +2064,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{29} } type UploadPausedCheckpointRequest struct { @@ -1947,7 +2092,7 @@ type UploadPausedCheckpointRequest struct { func (x *UploadPausedCheckpointRequest) Reset() { *x = UploadPausedCheckpointRequest{} - mi := &file_atelet_proto_msgTypes[28] + mi := &file_atelet_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1959,7 +2104,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[30] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1972,7 +2117,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{30} } func (x *UploadPausedCheckpointRequest) GetAtespace() string { @@ -2039,7 +2184,7 @@ type UploadPausedCheckpointResponse struct { func (x *UploadPausedCheckpointResponse) Reset() { *x = UploadPausedCheckpointResponse{} - mi := &file_atelet_proto_msgTypes[29] + mi := &file_atelet_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2051,7 +2196,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[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2064,7 +2209,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{31} } type RestoreRequest struct { @@ -2110,7 +2255,7 @@ type RestoreRequest struct { func (x *RestoreRequest) Reset() { *x = RestoreRequest{} - mi := &file_atelet_proto_msgTypes[30] + mi := &file_atelet_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2122,7 +2267,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[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2135,7 +2280,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{32} } func (x *RestoreRequest) GetTargetAteomUid() string { @@ -2278,7 +2423,7 @@ type RestoreResponse struct { func (x *RestoreResponse) Reset() { *x = RestoreResponse{} - mi := &file_atelet_proto_msgTypes[31] + mi := &file_atelet_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2290,7 +2435,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[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2303,7 +2448,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{33} } var File_atelet_proto protoreflect.FileDescriptor @@ -2372,9 +2517,18 @@ 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\"J\n" + + "\x15TrustBundleDataSource\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\x12\x1d\n" + + "\n" + + "pem_bundle\x18\x02 \x01(\fR\tpemBundle\"H\n" + + "\x1cActorIdentityTokenDataSource\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\x12\x14\n" + + "\x05token\x18\x02 \x01(\fR\x05token\"\x8d\x02\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\vtrustBundle\x12X\n" + + "\x14actor_identity_token\x18\x03 \x01(\v2$.atelet.ActorIdentityTokenDataSourceH\x00R\x12actorIdentityTokenB\r\n" + "\vdata_source\"S\n" + "\x10SystemInfoVolume\x12?\n" + "\fdata_sources\x18\x01 \x03(\v2\x1c.atelet.SystemInfoDataSourceR\vdataSources\"\x8f\x02\n" + @@ -2503,7 +2657,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, 37) var file_atelet_proto_goTypes = []any{ (ActorMetadataField)(0), // 0: atelet.ActorMetadataField (CheckpointType)(0), // 1: atelet.CheckpointType @@ -2521,81 +2675,85 @@ 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 + (*ActorIdentityTokenDataSource)(nil), // 17: atelet.ActorIdentityTokenDataSource + (*SystemInfoDataSource)(nil), // 18: atelet.SystemInfoDataSource + (*SystemInfoVolume)(nil), // 19: atelet.SystemInfoVolume + (*Volume)(nil), // 20: atelet.Volume + (*VolumeMount)(nil), // 21: atelet.VolumeMount + (*Container)(nil), // 22: atelet.Container + (*SecurityContext)(nil), // 23: atelet.SecurityContext + (*Capabilities)(nil), // 24: atelet.Capabilities + (*EnvEntry)(nil), // 25: atelet.EnvEntry + (*Readyz)(nil), // 26: atelet.Readyz + (*HTTPGetAction)(nil), // 27: atelet.HTTPGetAction + (*RunResponse)(nil), // 28: atelet.RunResponse + (*LocalCheckpointConfiguration)(nil), // 29: atelet.LocalCheckpointConfiguration + (*ExternalCheckpointConfiguration)(nil), // 30: atelet.ExternalCheckpointConfiguration + (*CheckpointRequest)(nil), // 31: atelet.CheckpointRequest + (*CheckpointResponse)(nil), // 32: atelet.CheckpointResponse + (*UploadPausedCheckpointRequest)(nil), // 33: atelet.UploadPausedCheckpointRequest + (*UploadPausedCheckpointResponse)(nil), // 34: atelet.UploadPausedCheckpointResponse + (*RestoreRequest)(nil), // 35: atelet.RestoreRequest + (*RestoreResponse)(nil), // 36: atelet.RestoreResponse + nil, // 37: atelet.ArchAssets.FilesEntry + nil, // 38: atelet.SandboxAssets.AssetsEntry + nil, // 39: atelet.ExternalVolumeSource.VolumeContextEntry } 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 + 37, // 3: atelet.ArchAssets.files:type_name -> atelet.ArchAssets.FilesEntry + 38, // 4: atelet.SandboxAssets.assets:type_name -> atelet.SandboxAssets.AssetsEntry + 22, // 5: atelet.WorkloadSpec.containers:type_name -> atelet.Container + 20, // 6: atelet.WorkloadSpec.volumes:type_name -> atelet.Volume + 39, // 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.SystemInfoDataSource.actor_identity_token:type_name -> atelet.ActorIdentityTokenDataSource + 18, // 13: atelet.SystemInfoVolume.data_sources:type_name -> atelet.SystemInfoDataSource + 11, // 14: atelet.Volume.durable_dir:type_name -> atelet.DurableDirVolume + 12, // 15: atelet.Volume.external:type_name -> atelet.ExternalVolumeSource + 19, // 16: atelet.Volume.system_info:type_name -> atelet.SystemInfoVolume + 13, // 17: atelet.Volume.image:type_name -> atelet.ImageVolumeSource + 25, // 18: atelet.Container.env:type_name -> atelet.EnvEntry + 26, // 19: atelet.Container.readyz:type_name -> atelet.Readyz + 21, // 20: atelet.Container.volume_mounts:type_name -> atelet.VolumeMount + 23, // 21: atelet.Container.security_context:type_name -> atelet.SecurityContext + 24, // 22: atelet.SecurityContext.capabilities:type_name -> atelet.Capabilities + 27, // 23: atelet.Readyz.http_get:type_name -> atelet.HTTPGetAction + 10, // 24: atelet.CheckpointRequest.spec:type_name -> atelet.WorkloadSpec + 1, // 25: atelet.CheckpointRequest.type:type_name -> atelet.CheckpointType + 29, // 26: atelet.CheckpointRequest.local_config:type_name -> atelet.LocalCheckpointConfiguration + 30, // 27: atelet.CheckpointRequest.external_config:type_name -> atelet.ExternalCheckpointConfiguration + 2, // 28: atelet.CheckpointRequest.scope:type_name -> atelet.SnapshotScope + 2, // 29: atelet.UploadPausedCheckpointRequest.desired_scope:type_name -> atelet.SnapshotScope + 10, // 30: atelet.RestoreRequest.spec:type_name -> atelet.WorkloadSpec + 1, // 31: atelet.RestoreRequest.type:type_name -> atelet.CheckpointType + 29, // 32: atelet.RestoreRequest.local_config:type_name -> atelet.LocalCheckpointConfiguration + 30, // 33: atelet.RestoreRequest.external_config:type_name -> atelet.ExternalCheckpointConfiguration + 2, // 34: atelet.RestoreRequest.scope:type_name -> atelet.SnapshotScope + 6, // 35: atelet.RestoreRequest.egress_gateway:type_name -> atelet.EgressGateway + 7, // 36: atelet.ArchAssets.FilesEntry.value:type_name -> atelet.AssetFile + 8, // 37: atelet.SandboxAssets.AssetsEntry.value:type_name -> atelet.ArchAssets + 3, // 38: atelet.CredentialBroker.MintActorCertificate:input_type -> atelet.MintActorCertificateRequest + 5, // 39: atelet.AteomHerder.Run:input_type -> atelet.RunRequest + 31, // 40: atelet.AteomHerder.Checkpoint:input_type -> atelet.CheckpointRequest + 35, // 41: atelet.AteomHerder.Restore:input_type -> atelet.RestoreRequest + 33, // 42: atelet.AteomHerder.UploadPausedCheckpoint:input_type -> atelet.UploadPausedCheckpointRequest + 4, // 43: atelet.CredentialBroker.MintActorCertificate:output_type -> atelet.MintActorCertificateResponse + 28, // 44: atelet.AteomHerder.Run:output_type -> atelet.RunResponse + 32, // 45: atelet.AteomHerder.Checkpoint:output_type -> atelet.CheckpointResponse + 36, // 46: atelet.AteomHerder.Restore:output_type -> atelet.RestoreResponse + 34, // 47: atelet.AteomHerder.UploadPausedCheckpoint:output_type -> atelet.UploadPausedCheckpointResponse + 43, // [43:48] is the sub-list for method output_type + 38, // [38:43] is the sub-list for method input_type + 38, // [38:38] is the sub-list for extension type_name + 38, // [38:38] is the sub-list for extension extendee + 0, // [0:38] is the sub-list for field type_name } func init() { file_atelet_proto_init() } @@ -2604,20 +2762,22 @@ func file_atelet_proto_init() { return } file_atelet_proto_msgTypes[2].OneofWrappers = []any{} - file_atelet_proto_msgTypes[13].OneofWrappers = []any{ + file_atelet_proto_msgTypes[15].OneofWrappers = []any{ (*SystemInfoDataSource_ActorMetadata)(nil), + (*SystemInfoDataSource_TrustBundle)(nil), + (*SystemInfoDataSource_ActorIdentityToken)(nil), } - file_atelet_proto_msgTypes[15].OneofWrappers = []any{ + file_atelet_proto_msgTypes[17].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[28].OneofWrappers = []any{ (*CheckpointRequest_LocalConfig)(nil), (*CheckpointRequest_ExternalConfig)(nil), } - file_atelet_proto_msgTypes[30].OneofWrappers = []any{ + file_atelet_proto_msgTypes[32].OneofWrappers = []any{ (*RestoreRequest_LocalConfig)(nil), (*RestoreRequest_ExternalConfig)(nil), } @@ -2627,7 +2787,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: 37, NumExtensions: 0, NumServices: 2, }, diff --git a/internal/proto/ateletpb/atelet.proto b/internal/proto/ateletpb/atelet.proto index c3b31ee1b..742ca10ae 100644 --- a/internal/proto/ateletpb/atelet.proto +++ b/internal/proto/ateletpb/atelet.proto @@ -166,9 +166,30 @@ message ActorMetadataDataSource { repeated ActorMetadataItem items = 1; } +// TrustBundleDataSource writes an already-resolved PEM certificate bundle to +// a file at the given path, relative to the root of the enclosing +// system-info volume. ateapi resolves and sanitizes the named trust bundle +// through its configured backend before sending the spec; atelet only writes +// the bytes and never talks to any bundle backend. +message TrustBundleDataSource { + string path = 1; + bytes pem_bundle = 2; +} + +// ActorIdentityTokenDataSource writes an already-minted actor identity JWT +// to a file at the given path, relative to the root of the enclosing +// system-info volume. ateapi mints the token (audience- and TTL-bound, per +// the template) when it builds the spec; atelet only writes the bytes. +message ActorIdentityTokenDataSource { + string path = 1; + bytes token = 2; +} + message SystemInfoDataSource { oneof data_source { ActorMetadataDataSource actor_metadata = 1; + TrustBundleDataSource trust_bundle = 2; + ActorIdentityTokenDataSource actor_identity_token = 3; } } diff --git a/internal/testenv/testenv.go b/internal/testenv/testenv.go index 3c0e2c8d6..ed162623f 100644 --- a/internal/testenv/testenv.go +++ b/internal/testenv/testenv.go @@ -62,6 +62,13 @@ func Start() (*rest.Config, func()) { CRDDirectoryPaths: []string{filepath.Join(root, "manifests", "ate-install", "generated")}, BinaryAssetsDirectory: binDir, } + // Serve the ClusterTrustBundle API, matching what hack/create-kind-cluster.sh + // enables on real clusters: ateapi registers a CTB informer at startup, and + // without the API its cache never syncs (see cmd/ateapi/main.go). + apiServer := env.ControlPlane.GetAPIServer() + apiServer.Configure(). + Append("feature-gates", "ClusterTrustBundle=true"). + Append("runtime-config", "certificates.k8s.io/v1beta1=true") cfg, err := env.Start() if err != nil { fatal(fmt.Errorf("envtest start: %w", err)) diff --git a/manifests/ate-install/ate-api-server.yaml b/manifests/ate-install/ate-api-server.yaml index 5cd6ed620..f5c5aa0ea 100644 --- a/manifests/ate-install/ate-api-server.yaml +++ b/manifests/ate-install/ate-api-server.yaml @@ -22,6 +22,11 @@ rules: - apiGroups: [""] resources: ["pods"] verbs: ["get", "watch", "list"] +# ClusterTrustBundles referenced by SystemInfo volume data sources are +# resolved by ateapi and projected into actors as PEM files. +- apiGroups: ["certificates.k8s.io"] + resources: ["clustertrustbundles"] + verbs: ["get", "watch", "list"] - apiGroups: ["ate.dev"] resources: ["actortemplates", "workerpools", "sandboxconfigs", "csidriverconfigs"] verbs: ["get", "watch", "list"] diff --git a/manifests/ate-install/generated/ate.dev_actortemplates.yaml b/manifests/ate-install/generated/ate.dev_actortemplates.yaml index 1bd443061..afee65c51 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 @@ -508,6 +508,58 @@ spec: Exactly one member must be set. properties: + actorIdentityToken: + description: |- + ActorIdentityTokenDataSource is a SystemInfo volume data source that + projects a signed JWT attesting the actor's identity to a single file — + analogous to the Kubernetes serviceAccountToken projected volume source. + + The token is minted by ateapi when the actor starts, carries the actor's + identity (atespace, name, uid) in its claims, and is bound to the + requested audience. It is re-minted on every Run/Restore, so a resumed + actor always carries a token for its own, current activation; a token + captured into a snapshot is useless elsewhere (short TTL, and renewal is + never a bearer operation — see the SystemInfo leakage notes in #802). + Workloads should re-read the file at time of use rather than caching it. + properties: + audience: + description: |- + Audience is the intended recipient of the token, bound into its aud + claim. Verifiers must reject tokens minted for other audiences. + maxLength: 255 + minLength: 1 + type: string + expirationSeconds: + default: 3600 + description: |- + ExpirationSeconds is the requested token lifetime. Tokens are only + refreshed on actor Run/Restore today, so a long-running actor holds + its token for up to this long; verifiers see standard exp semantics. + format: int64 + maximum: 86400 + minimum: 600 + type: integer + path: + description: |- + Relative path from the root of the SystemInfo volume at which the + token is written. Must be a clean relative Unix path: must not start + or end with '/', and contain no ':', '..', '.', '//', 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' + rule: '!self.startsWith(''/'') && !self.endsWith(''/'') + && !self.contains(''//'') && !self.contains('':'') + && !self.matches(''[\x00-\x1f\x7f]'') && !self.matches(''(^|/)[.][.]?(/|$)'')' + required: + - audience + - path + type: object actorMetadata: description: |- ActorMetadataDataSource is a SystemInfo volume data source that projects the @@ -568,18 +620,85 @@ 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 deployment concern behind ateapi, not + part of this API. + + Supported names are allowlisted in ateapi. 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 by ateapi when the actor starts (only + CERTIFICATE PEM blocks are kept, deduplicated); the actor 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: must not start + or end with '/', and contain no ':', '..', '.', '//', 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' + 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 actorIdentityToken] must be set + rule: '[has(self.actorMetadata),has(self.trustBundle),has(self.actorIdentityToken)].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)))' + - message: dataSources must not contain duplicate paths + rule: self.all(x, !has(x.actorIdentityToken) || self.exists_one(y, + has(y.actorIdentityToken) && y.actorIdentityToken.path + == x.actorIdentityToken.path)) + - message: dataSources must not contain duplicate paths + rule: '!self.exists(x, has(x.actorIdentityToken) && self.exists(y, + has(y.trustBundle) && y.trustBundle.path == x.actorIdentityToken.path))' + - message: dataSources must not contain duplicate paths + rule: '!self.exists(x, has(x.actorIdentityToken) && self.exists(y, + has(y.actorMetadata) && y.actorMetadata.items.exists(i, + i.path == x.actorIdentityToken.path)))' type: object required: - name diff --git a/pkg/api/v1alpha1/actortemplate_types.go b/pkg/api/v1alpha1/actortemplate_types.go index 579812b67..a9cf7ff91 100644 --- a/pkg/api/v1alpha1/actortemplate_types.go +++ b/pkg/api/v1alpha1/actortemplate_types.go @@ -109,29 +109,120 @@ 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 deployment concern behind ateapi, not +// part of this API. +// +// Supported names are allowlisted in ateapi. 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 by ateapi when the actor starts (only +// CERTIFICATE PEM blocks are kept, deduplicated); the actor 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: must not start + // or end with '/', and contain no ':', '..', '.', '//', 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" + Path string `json:"path"` +} + +// ActorIdentityTokenDataSource is a SystemInfo volume data source that +// projects a signed JWT attesting the actor's identity to a single file — +// analogous to the Kubernetes serviceAccountToken projected volume source. +// +// The token is minted by ateapi when the actor starts, carries the actor's +// identity (atespace, name, uid) in its claims, and is bound to the +// requested audience. It is re-minted on every Run/Restore, so a resumed +// actor always carries a token for its own, current activation; a token +// captured into a snapshot is useless elsewhere (short TTL, and renewal is +// never a bearer operation — see the SystemInfo leakage notes in #802). +// Workloads should re-read the file at time of use rather than caching it. +type ActorIdentityTokenDataSource struct { + // Audience is the intended recipient of the token, bound into its aud + // claim. Verifiers must reject tokens minted for other audiences. + // + // +required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=255 + Audience string `json:"audience"` + + // ExpirationSeconds is the requested token lifetime. Tokens are only + // refreshed on actor Run/Restore today, so a long-running actor holds + // its token for up to this long; verifiers see standard exp semantics. + // + // +optional + // +kubebuilder:default=3600 + // +kubebuilder:validation:Minimum=600 + // +kubebuilder:validation:Maximum=86400 + ExpirationSeconds *int64 `json:"expirationSeconds,omitempty"` + + // Relative path from the root of the SystemInfo volume at which the + // token is written. Must be a clean relative Unix path: must not start + // or end with '/', and contain no ':', '..', '.', '//', 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" + 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,actorIdentityToken} type SystemInfoDataSource struct { ActorMetadata *ActorMetadataDataSource `json:"actorMetadata,omitempty"` + + TrustBundle *TrustBundleDataSource `json:"trustBundle,omitempty"` + + ActorIdentityToken *ActorIdentityTokenDataSource `json:"actorIdentityToken,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" + // +kubebuilder:validation:XValidation:rule="self.all(x, !has(x.actorIdentityToken) || self.exists_one(y, has(y.actorIdentityToken) && y.actorIdentityToken.path == x.actorIdentityToken.path))",message="dataSources must not contain duplicate paths" + // +kubebuilder:validation:XValidation:rule="!self.exists(x, has(x.actorIdentityToken) && self.exists(y, has(y.trustBundle) && y.trustBundle.path == x.actorIdentityToken.path))",message="dataSources must not contain duplicate paths" + // +kubebuilder:validation:XValidation:rule="!self.exists(x, has(x.actorIdentityToken) && self.exists(y, has(y.actorMetadata) && y.actorMetadata.items.exists(i, i.path == x.actorIdentityToken.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..4fe820302 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 actorIdentityToken] must be set", }, { name: "Volumes: SystemInfo actorMetadata with no items is invalid", mutate: func(at *ActorTemplate) { @@ -1063,6 +1063,203 @@ 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 actorIdentityToken data source is valid", + mutate: func(at *ActorTemplate) { + exp := int64(900) + at.Spec.Volumes = []Volume{ + { + Name: "system-info", + VolumeSource: VolumeSource{ + SystemInfo: &SystemInfoVolumeSource{ + DataSources: []SystemInfoDataSource{ + {ActorIdentityToken: &ActorIdentityTokenDataSource{Audience: "verifier.example.com", ExpirationSeconds: &exp, Path: "identity/token"}}, + }, + }, + }, + }, + } + at.Spec.Containers[0].VolumeMounts = []VolumeMount{ + {Name: "system-info", MountPath: "/run/ate"}, + } + }, + wantErr: false, + }, { + name: "Volumes: SystemInfo actorIdentityToken with empty audience is invalid", + mutate: func(at *ActorTemplate) { + at.Spec.Volumes = []Volume{ + { + Name: "system-info", + VolumeSource: VolumeSource{ + SystemInfo: &SystemInfoVolumeSource{ + DataSources: []SystemInfoDataSource{ + {ActorIdentityToken: &ActorIdentityTokenDataSource{Audience: "", Path: "token"}}, + }, + }, + }, + }, + } + at.Spec.Containers[0].VolumeMounts = []VolumeMount{ + {Name: "system-info", MountPath: "/run/ate"}, + } + }, + wantErr: true, + }, { + name: "Volumes: SystemInfo actorIdentityToken expiration below the floor is invalid", + mutate: func(at *ActorTemplate) { + exp := int64(60) + at.Spec.Volumes = []Volume{ + { + Name: "system-info", + VolumeSource: VolumeSource{ + SystemInfo: &SystemInfoVolumeSource{ + DataSources: []SystemInfoDataSource{ + {ActorIdentityToken: &ActorIdentityTokenDataSource{Audience: "verifier.example.com", ExpirationSeconds: &exp, Path: "token"}}, + }, + }, + }, + }, + } + at.Spec.Containers[0].VolumeMounts = []VolumeMount{ + {Name: "system-info", MountPath: "/run/ate"}, + } + }, + wantErr: true, + }, { + name: "Volumes: SystemInfo actorIdentityToken path duplicating a trustBundle 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: "shared.pem"}}, + {ActorIdentityToken: &ActorIdentityTokenDataSource{Audience: "verifier.example.com", Path: "shared.pem"}}, + }, + }, + }, + }, + } + at.Spec.Containers[0].VolumeMounts = []VolumeMount{ + {Name: "system-info", MountPath: "/run/ate"}, + } + }, + wantErr: true, + }, { + 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 actorIdentityToken] 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..6987accde 100644 --- a/pkg/api/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/api/v1alpha1/zz_generated.deepcopy.go @@ -24,6 +24,26 @@ import ( runtime "k8s.io/apimachinery/pkg/runtime" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ActorIdentityTokenDataSource) DeepCopyInto(out *ActorIdentityTokenDataSource) { + *out = *in + if in.ExpirationSeconds != nil { + in, out := &in.ExpirationSeconds, &out.ExpirationSeconds + *out = new(int64) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ActorIdentityTokenDataSource. +func (in *ActorIdentityTokenDataSource) DeepCopy() *ActorIdentityTokenDataSource { + if in == nil { + return nil + } + out := new(ActorIdentityTokenDataSource) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ActorMetadataDataSource) DeepCopyInto(out *ActorMetadataDataSource) { *out = *in @@ -585,6 +605,16 @@ 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 + } + if in.ActorIdentityToken != nil { + in, out := &in.ActorIdentityToken, &out.ActorIdentityToken + *out = new(ActorIdentityTokenDataSource) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SystemInfoDataSource. @@ -619,6 +649,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