diff --git a/cmd/ateapi/internal/actoridentity/actoridentity.go b/cmd/ateapi/internal/actoridentity/actoridentity.go index 6fc47c5de..73c2b68d7 100644 --- a/cmd/ateapi/internal/actoridentity/actoridentity.go +++ b/cmd/ateapi/internal/actoridentity/actoridentity.go @@ -85,9 +85,10 @@ func New(clientJWTIssuer, clientJWTAudience, actorIDJWTPoolFile, actorIDCAPoolFi // imported so that this package does not depend on controlapi for three // strings; if a third pkg that need these constants appears, they should move to a shared package. const ( - ateletTrustDomain = "cluster.local" - ateletNamespace = "ate-system" - ateletSA = "atelet" + ateletTrustDomain = "cluster.local" + ateletNamespace = "ate-system" + ateletSA = "atelet" + actorCertificateLifetime = time.Hour ) func (s *Server) MintJWT(ctx context.Context, req *ateapipb.MintJWTRequest) (*ateapipb.MintJWTResponse, error) { @@ -125,7 +126,6 @@ func (s *Server) MintJWT(ctx context.Context, req *ateapipb.MintJWTRequest) (*at if err != nil { return nil, fmt.Errorf("while unmarshaling signing pool: %w", err) } - // We only issue tokens with audience bindings. if len(req.GetAudience()) == 0 { return nil, fmt.Errorf("at least one audience must be requested") @@ -179,7 +179,10 @@ func (s *Server) MintCert(ctx context.Context, req *ateapipb.MintCertRequest) (* } actorRef := resources.ActorRef{Atespace: atespace, Name: actorName} - actor, err := s.authorizeActor(ctx, caller, actorRef) + if req.GetWorkerPodUid() == "" { + return nil, status.Error(codes.InvalidArgument, "worker_pod_uid is required") + } + actor, err := s.authorizeActor(ctx, caller, actorRef, req.GetWorkerPodUid()) if err != nil { return nil, err } @@ -232,7 +235,7 @@ func (s *Server) MintCert(ctx context.Context, req *ateapipb.MintCertRequest) (* template := &x509.Certificate{ URIs: []*url.URL{spiffeURI}, NotBefore: time.Now().Add(-5 * time.Minute), - NotAfter: time.Now().Add(15 * time.Minute), + NotAfter: time.Now().Add(actorCertificateLifetime), KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, BasicConstraintsValid: true, @@ -269,7 +272,7 @@ func (s *Server) MintCert(ctx context.Context, req *ateapipb.MintCertRequest) (* }, nil } -// ateletCaller is the verified identity of an atelet that called MintCert. +// ateletCaller is the verified identity of an atelet requesting an actor credential. type ateletCaller struct { podName string nodeName string @@ -298,7 +301,7 @@ func authenticateAtelet(ctx context.Context) (*ateletCaller, error) { } leaf := tlsInfo.State.PeerCertificates[0] - // Only atelet may mint actor certificates. Everything else with a valid + // Only atelet may mint actor credentials. Everything else with a valid // pod-identity certificate — including the actor workloads themselves — is // rejected here. expected := (&url.URL{ @@ -307,19 +310,19 @@ func authenticateAtelet(ctx context.Context) (*ateletCaller, error) { Path: path.Join("ns", ateletNamespace, "sa", ateletSA), }).String() if len(leaf.URIs) == 0 || leaf.URIs[0].String() != expected { - slog.WarnContext(ctx, "MintCert denied: caller is not atelet", + slog.WarnContext(ctx, "ActorIdentity denied: caller is not atelet", slog.Any("uris", leaf.URIs), slog.String("expected", expected)) - return nil, status.Errorf(codes.PermissionDenied, "caller is not permitted to mint actor certificates") + return nil, status.Errorf(codes.PermissionDenied, "caller is not permitted to mint actor credentials") } identity, err := substratex509.PodIdentityFromCertificate(leaf) if err != nil { - slog.WarnContext(ctx, "MintCert denied: malformed PodIdentity extension", slog.Any("err", err)) - return nil, status.Errorf(codes.PermissionDenied, "caller is not permitted to mint actor certificates") + slog.WarnContext(ctx, "ActorIdentity denied: malformed PodIdentity extension", slog.Any("err", err)) + return nil, status.Errorf(codes.PermissionDenied, "caller is not permitted to mint actor credentials") } if identity == nil { - slog.WarnContext(ctx, "MintCert denied: certificate has no PodIdentity extension") - return nil, status.Errorf(codes.PermissionDenied, "caller is not permitted to mint actor certificates") + slog.WarnContext(ctx, "ActorIdentity denied: certificate has no PodIdentity extension") + return nil, status.Errorf(codes.PermissionDenied, "caller is not permitted to mint actor credentials") } return &ateletCaller{podName: identity.PodName, nodeName: identity.NodeName}, nil @@ -333,14 +336,15 @@ func authenticateAtelet(ctx context.Context) (*ateletCaller, error) { // An atelet is therefore confined to the actors it is actually hosting, and an // actor that has been suspended, paused or migrated elsewhere can no longer // have credentials minted for it. -func (s *Server) authorizeActor(ctx context.Context, caller *ateletCaller, actorRef resources.ActorRef) (*ateapipb.Actor, error) { +// expectedWorkerPodUID binds the request to the exact worker incarnation. +func (s *Server) authorizeActor(ctx context.Context, caller *ateletCaller, actorRef resources.ActorRef, expectedWorkerPodUID string) (*ateapipb.Actor, error) { // Denials are deliberately indistinguishable from each other: a caller that // is not entitled to an actor should not be able to use this RPC to learn // whether that actor exists, or where it is running. deny := func(reason string, args ...any) error { - slog.WarnContext(ctx, "MintCert denied: "+reason, + slog.WarnContext(ctx, "ActorIdentity denied: "+reason, append([]any{slog.Any("actor", actorRef), slog.String("callerPod", caller.podName), slog.String("callerNode", caller.nodeName)}, args...)...) - return status.Errorf(codes.PermissionDenied, "caller is not permitted to mint certificates for this actor") + return status.Errorf(codes.PermissionDenied, "caller is not permitted to mint credentials for this actor") } actor, err := s.store.GetActor(ctx, actorRef) @@ -348,7 +352,7 @@ func (s *Server) authorizeActor(ctx context.Context, caller *ateletCaller, actor if errors.Is(err, store.ErrNotFound) { return nil, deny("actor not found") } - slog.ErrorContext(ctx, "MintCert: failed to read actor", slog.Any("actor", actorRef), slog.Any("err", err)) + slog.ErrorContext(ctx, "ActorIdentity: failed to read actor", slog.Any("actor", actorRef), slog.Any("err", err)) return nil, status.Errorf(codes.Internal, "failed to look up actor") } @@ -356,7 +360,7 @@ func (s *Server) authorizeActor(ctx context.Context, caller *ateletCaller, actor // have already released the worker, so the assignment check below would // reject this too. It is kept because minting for better visbility and logging. if actor.GetStatus() == ateapipb.Actor_STATUS_DELETING { - slog.WarnContext(ctx, "MintCert refused: actor is being deleted", slog.Any("actor", actorRef)) + slog.WarnContext(ctx, "ActorIdentity refused: actor is being deleted", slog.Any("actor", actorRef)) return nil, status.Errorf(codes.FailedPrecondition, "actor is being deleted") } @@ -365,7 +369,7 @@ func (s *Server) authorizeActor(ctx context.Context, caller *ateletCaller, actor // folded into deny(). podNamespace, podName, pool := actor.GetAteomPodNamespace(), actor.GetAteomPodName(), actor.GetWorkerPoolName() if podNamespace == "" || podName == "" || pool == "" { - slog.ErrorContext(ctx, "MintCert: running actor has incomplete placement", + slog.ErrorContext(ctx, "ActorIdentity: running actor has incomplete placement", slog.Any("actor", actorRef), slog.String("podNamespace", podNamespace), slog.String("podName", podName), slog.String("workerPool", pool)) return nil, status.Errorf(codes.FailedPrecondition, "actor has no worker assigned") @@ -376,13 +380,16 @@ func (s *Server) authorizeActor(ctx context.Context, caller *ateletCaller, actor if errors.Is(err, store.ErrNotFound) { return nil, deny("worker hosting the actor not found", slog.String("workerPod", podNamespace+"/"+podName)) } - slog.ErrorContext(ctx, "MintCert: failed to read worker", slog.Any("actor", actorRef), slog.Any("err", err)) + slog.ErrorContext(ctx, "ActorIdentity: failed to read worker", slog.Any("actor", actorRef), slog.Any("err", err)) return nil, status.Errorf(codes.Internal, "failed to look up worker") } if worker.GetNodeName() != caller.nodeName { return nil, deny("actor is hosted on a different node", slog.String("actorNode", worker.GetNodeName())) } + if worker.GetWorkerPodUid() != expectedWorkerPodUID { + return nil, deny("worker Pod UID does not match", slog.String("workerPodUID", expectedWorkerPodUID)) + } // The worker must still agree that it is hosting this actor. if assigned := worker.GetAssignment().GetActor(); resources.ActorRefFromObjectRef(assigned) != actorRef { diff --git a/cmd/ateapi/internal/actoridentity/actoridentity_test.go b/cmd/ateapi/internal/actoridentity/actoridentity_test.go index 0385a1a58..5d92aff68 100644 --- a/cmd/ateapi/internal/actoridentity/actoridentity_test.go +++ b/cmd/ateapi/internal/actoridentity/actoridentity_test.go @@ -247,9 +247,10 @@ func TestMintCertAuthorization(t *testing.T) { fixture actorFixture - // atespace and actorName override the request fields when non-nil. - atespace *string - actorName *string + // Request fields override their defaults when non-nil. + atespace *string + actorName *string + workerPodUID *string wantCode codes.Code }{ @@ -308,6 +309,11 @@ func TestMintCertAuthorization(t *testing.T) { fixture: runningOnNode(testOtherNode), wantCode: codes.PermissionDenied, }, + "worker Pod UID does not match": { + fixture: runningOnNode(testNode), + workerPodUID: ptr("sibling-worker-uid"), + wantCode: codes.PermissionDenied, + }, "worker is assigned to a different actor": { fixture: actorFixture{ status: ateapipb.Actor_STATUS_RUNNING, @@ -376,10 +382,15 @@ func TestMintCertAuthorization(t *testing.T) { actorName = *tc.actorName } + workerPodUID := "worker-uid" + if tc.workerPodUID != nil { + workerPodUID = *tc.workerPodUID + } resp, err := srv.MintCert(ctxWithCert(callerCert), &ateapipb.MintCertRequest{ Atespace: atespace, ActorName: actorName, CertificateSigningRequest: newCSR(t), + WorkerPodUid: workerPodUID, }) if got := status.Code(err); got != tc.wantCode { t.Fatalf("MintCert() code = %v (err = %v), want %v", got, err, tc.wantCode) @@ -448,6 +459,7 @@ func TestMintCertEmbedsActorIdentity(t *testing.T) { Atespace: testAtespace, ActorName: testActorName, CertificateSigningRequest: newCSR(t), + WorkerPodUid: "worker-uid", } }) if err != nil { @@ -493,6 +505,7 @@ func TestMintCertActorUID(t *testing.T) { ActorName: testActorName, ActorUid: tc.requestUID(actorUID), CertificateSigningRequest: newCSR(t), + WorkerPodUid: "worker-uid", } }) if got := status.Code(err); got != tc.wantCode { @@ -549,6 +562,7 @@ func TestMintCertActorStatus(t *testing.T) { Atespace: testAtespace, ActorName: testActorName, CertificateSigningRequest: newCSR(t), + WorkerPodUid: "worker-uid", }) if got := status.Code(err); got != wantCode { t.Errorf("MintCert() code = %v (err = %v), want %v", got, err, wantCode) @@ -584,6 +598,7 @@ func TestMintCertDeniesUnassignedActorWhateverItsStatus(t *testing.T) { Atespace: testAtespace, ActorName: testActorName, CertificateSigningRequest: newCSR(t), + WorkerPodUid: "worker-uid", }) if got := status.Code(err); got != codes.PermissionDenied { t.Errorf("MintCert() code = %v (err = %v), want %v", got, err, codes.PermissionDenied) @@ -611,6 +626,7 @@ func TestMintCertAuthorizesBeforeSigning(t *testing.T) { Atespace: testAtespace, ActorName: testActorName, CertificateSigningRequest: []byte("not a CSR"), + WorkerPodUid: "worker-uid", }) if got := status.Code(err); got != codes.PermissionDenied { t.Errorf("MintCert() code = %v (err = %v), want %v", got, err, codes.PermissionDenied) diff --git a/cmd/ateapi/internal/controlapi/functional_test.go b/cmd/ateapi/internal/controlapi/functional_test.go index 646da16a0..bcacc127a 100644 --- a/cmd/ateapi/internal/controlapi/functional_test.go +++ b/cmd/ateapi/internal/controlapi/functional_test.go @@ -324,7 +324,7 @@ func setupTest(t *testing.T, ns string) *testContext { return insecure.NewCredentials(), nil } - service := NewService(persistence, wc, actorTemplateLister, workerPoolLister, sandboxConfigLister, dialer, k8sClient) + service := NewService(persistence, wc, actorTemplateLister, workerPoolLister, sandboxConfigLister, dialer, k8sClient, "") // 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 944ae441f..27c1a1536 100644 --- a/cmd/ateapi/internal/controlapi/service.go +++ b/cmd/ateapi/internal/controlapi/service.go @@ -43,13 +43,14 @@ func NewService( sandboxConfigLister listersv1alpha1.SandboxConfigLister, dialer *AteletDialer, kubeClient kubernetes.Interface, + egressGatewayAddress string, ) *Service { s := &Service{ persistence: persistence, actorTemplateLister: actorTemplateLister, workerPoolLister: workerPoolLister, dialer: dialer, - actorWorkflow: NewActorWorkflow(persistence, workerCache, dialer, actorTemplateLister, workerPoolLister, sandboxConfigLister, kubeClient), + actorWorkflow: NewActorWorkflow(persistence, workerCache, dialer, actorTemplateLister, workerPoolLister, sandboxConfigLister, kubeClient, egressGatewayAddress), } return s } diff --git a/cmd/ateapi/internal/controlapi/workflow.go b/cmd/ateapi/internal/controlapi/workflow.go index 8f591fc5a..359d233e6 100644 --- a/cmd/ateapi/internal/controlapi/workflow.go +++ b/cmd/ateapi/internal/controlapi/workflow.go @@ -130,15 +130,16 @@ func runStep[Params any, Context any](ctx context.Context, params Params, wCtx C // ActorWorkflow handles the workflows for actor's resume / suspend operations. type ActorWorkflow struct { - store store.Interface - workerCache *workercache.Cache - scheduler scheduling.Scheduler - dialer *AteletDialer - actorTemplateLister listersv1alpha1.ActorTemplateLister - workerPoolLister listersv1alpha1.WorkerPoolLister - sandboxConfigLister listersv1alpha1.SandboxConfigLister - kubeClient kubernetes.Interface - secretCache *envSecretCache + store store.Interface + workerCache *workercache.Cache + scheduler scheduling.Scheduler + dialer *AteletDialer + actorTemplateLister listersv1alpha1.ActorTemplateLister + workerPoolLister listersv1alpha1.WorkerPoolLister + sandboxConfigLister listersv1alpha1.SandboxConfigLister + kubeClient kubernetes.Interface + secretCache *envSecretCache + egressGatewayAddress string } // NewActorWorkflow creates a new ActorWorkflow. @@ -150,17 +151,19 @@ func NewActorWorkflow( workerPoolLister listersv1alpha1.WorkerPoolLister, sandboxConfigLister listersv1alpha1.SandboxConfigLister, kubeClient kubernetes.Interface, + egressGatewayAddress string, ) *ActorWorkflow { return &ActorWorkflow{ - store: store, - workerCache: workerCache, - scheduler: scheduling.New(workerCache), - dialer: dialer, - actorTemplateLister: actorTemplateLister, - workerPoolLister: workerPoolLister, - sandboxConfigLister: sandboxConfigLister, - kubeClient: kubeClient, - secretCache: newEnvSecretCache(envSecretCacheTTL), + store: store, + workerCache: workerCache, + scheduler: scheduling.New(workerCache), + dialer: dialer, + actorTemplateLister: actorTemplateLister, + workerPoolLister: workerPoolLister, + sandboxConfigLister: sandboxConfigLister, + kubeClient: kubeClient, + secretCache: newEnvSecretCache(envSecretCacheTTL), + egressGatewayAddress: egressGatewayAddress, } } @@ -183,7 +186,7 @@ func (w *ActorWorkflow) ResumeActor(ctx context.Context, actorRef resources.Acto &CreateVolumesStep{store: w.store}, &AssignWorkerStep{store: w.store, workerCache: w.workerCache, scheduler: w.scheduler}, &AttachVolumesStep{store: w.store}, - &CallAteletRestoreStep{store: w.store, dialer: w.dialer, kubeClient: w.kubeClient, secretCache: w.secretCache, workerPoolLister: w.workerPoolLister, sandboxConfigLister: w.sandboxConfigLister, scheduler: w.scheduler}, + &CallAteletRestoreStep{store: w.store, dialer: w.dialer, kubeClient: w.kubeClient, secretCache: w.secretCache, workerPoolLister: w.workerPoolLister, sandboxConfigLister: w.sandboxConfigLister, scheduler: w.scheduler, egressGatewayAddress: w.egressGatewayAddress}, &FinalizeRunningStep{store: w.store}, } diff --git a/cmd/ateapi/internal/controlapi/workflow_resume.go b/cmd/ateapi/internal/controlapi/workflow_resume.go index f24e91a49..272cae162 100644 --- a/cmd/ateapi/internal/controlapi/workflow_resume.go +++ b/cmd/ateapi/internal/controlapi/workflow_resume.go @@ -452,13 +452,14 @@ func (s *AttachVolumesStep) Execute(ctx context.Context, input *ResumeInput, sta func (s *AttachVolumesStep) RetryBackoff() *wait.Backoff { return nil } type CallAteletRestoreStep struct { - store store.Interface - dialer *AteletDialer - kubeClient kubernetes.Interface - secretCache *envSecretCache - workerPoolLister listersv1alpha1.WorkerPoolLister - sandboxConfigLister listersv1alpha1.SandboxConfigLister - scheduler scheduling.Scheduler + store store.Interface + dialer *AteletDialer + kubeClient kubernetes.Interface + secretCache *envSecretCache + workerPoolLister listersv1alpha1.WorkerPoolLister + sandboxConfigLister listersv1alpha1.SandboxConfigLister + scheduler scheduling.Scheduler + egressGatewayAddress string } func (s *CallAteletRestoreStep) Name() string { return "CallAteletRestore" } @@ -516,6 +517,7 @@ func (s *CallAteletRestoreStep) Execute(ctx context.Context, input *ResumeInput, if err != nil { return err } + egressGateway := s.egressGateway() if local := state.Actor.GetLocalSnapshotInfo(); local != nil { slog.InfoContext(ctx, "Actor has snapshot; Restoring from snapshot") @@ -528,6 +530,7 @@ func (s *CallAteletRestoreStep) Execute(ctx context.Context, input *ResumeInput, ActorTemplateName: state.Actor.GetActorTemplateName(), Spec: workloadSpec, ActorUid: state.Actor.GetMetadata().Uid, + EgressGateway: egressGateway, } req.Type = ateletpb.CheckpointType_CHECKPOINT_TYPE_LOCAL req.Config = &ateletpb.RestoreRequest_LocalConfig{ @@ -574,6 +577,7 @@ func (s *CallAteletRestoreStep) Execute(ctx context.Context, input *ResumeInput, // Empty unless this is a Golden data resume. GoldenSnapshotUriPrefix: state.GoldenSnapshotLocation, ActorUid: state.Actor.GetMetadata().Uid, + EgressGateway: egressGateway, } _, err = client.Restore(ctx, req) return maybeCrashActor(ctx, s.store, input.ActorRef, err, "while restoring durable snapshot") @@ -597,6 +601,7 @@ func (s *CallAteletRestoreStep) Execute(ctx context.Context, input *ResumeInput, SandboxAssets: sandboxAssets, Spec: workloadSpec, ActorUid: state.Actor.GetMetadata().Uid, + EgressGateway: egressGateway, } _, err = client.Run(ctx, req) return maybeCrashActor(ctx, s.store, input.ActorRef, err, "while creating workload from spec") @@ -606,6 +611,13 @@ func (s *CallAteletRestoreStep) Execute(ctx context.Context, input *ResumeInput, func (s *CallAteletRestoreStep) RetryBackoff() *wait.Backoff { return nil } +func (s *CallAteletRestoreStep) egressGateway() *ateletpb.EgressGateway { + if s.egressGatewayAddress == "" { + return nil + } + return &ateletpb.EgressGateway{Address: s.egressGatewayAddress} +} + type FinalizeRunningStep struct { store store.Interface } diff --git a/cmd/ateapi/internal/controlapi/workflow_testutil_test.go b/cmd/ateapi/internal/controlapi/workflow_testutil_test.go index eebcc09cf..d8a10523d 100644 --- a/cmd/ateapi/internal/controlapi/workflow_testutil_test.go +++ b/cmd/ateapi/internal/controlapi/workflow_testutil_test.go @@ -42,7 +42,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) + return NewActorWorkflow(st, nil, nil, listersv1alpha1.NewActorTemplateLister(indexer), nil, nil, nil, "") } // seedWorkflowActor stores an actor with the given status, bound to the given diff --git a/cmd/ateapi/main.go b/cmd/ateapi/main.go index 683ece541..45f4908b5 100644 --- a/cmd/ateapi/main.go +++ b/cmd/ateapi/main.go @@ -71,9 +71,10 @@ var ( redisTLSServerName = pflag.String("redis-tls-server-name", "", "The ServerName to use for Redis TLS hostname verification.") redisClientCert = pflag.String("redis-client-cert", "", "The file containing client TLS certificate/key credential bundle for Redis/Valkey.") - clientJWTIssuer = pflag.String("client-jwt-issuer", "", "The expected issuer URL for client JWTs.") - clientJWTAudience = pflag.String("client-jwt-audience", "", "The expected audience for client JWTs.") - actorIDJWTPoolFile = pflag.String("actor-id-jwt-pool", "", "The file that contains the serialized JWT authority pool for signing actor JWTs") + clientJWTIssuer = pflag.String("client-jwt-issuer", "", "The expected issuer URL for client JWTs.") + clientJWTAudience = pflag.String("client-jwt-audience", "", "The expected audience for client JWTs.") + actorIDJWTPoolFile = pflag.String("actor-id-jwt-pool", "", "The file that contains the serialized JWT authority pool for signing actor JWTs") + egressGatewayAddress = pflag.String("egress-gateway-address", "", "Address of the egress PEP. Empty disables tunneled egress.") actorIDCAPoolFile = pflag.String("actor-id-ca-pool", "", "The file that contains the CA pool for signing actor JWTs") podIdentityCACerts = pflag.String("pod-identity-ca-certs", "", "The file that contains the pod-identity CA bundle, used both for verifying client certificates presented to the gRPC server and for verifying atelet serving certificates when dialing atelet. If empty, client-cert verification is disabled and atelet dials will fail.") @@ -171,7 +172,7 @@ func main() { } ateletDialer := controlapi.NewAteletDialer(workerPodInformer.GetIndexer(), ateletPodInformer.GetIndexer(), *ateletClientCredBundle, *podIdentityCACerts) - sm := controlapi.NewService(redisPersistence, workerCache, actorTemplateLister, workerPoolLister, sandboxConfigLister, ateletDialer, clientset) + sm := controlapi.NewService(redisPersistence, workerCache, actorTemplateLister, workerPoolLister, sandboxConfigLister, ateletDialer, clientset, *egressGatewayAddress) jwtIssuerDiscoveryClient := buildK8sServiceAccountIssuerDiscoveryClient(ctx, *clientJWTCAFile, *clientJWTIssuer) diff --git a/cmd/atelet/credentialbroker.go b/cmd/atelet/credentialbroker.go new file mode 100644 index 000000000..9bdaf1dcf --- /dev/null +++ b/cmd/atelet/credentialbroker.go @@ -0,0 +1,121 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "crypto/tls" + "fmt" + + "github.com/agent-substrate/substrate/internal/proto/ateletpb" + "github.com/agent-substrate/substrate/internal/substratex509" + "github.com/agent-substrate/substrate/pkg/proto/ateapipb" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/peer" + "google.golang.org/grpc/status" +) + +type credentialBroker struct { + ateletpb.UnimplementedCredentialBrokerServer + // control resolves the authenticated worker Pod to its current assignment. + control ateapipb.ControlClient + // identity revalidates that assignment and signs the actor certificate. + identity ateapipb.ActorIdentityClient +} + +func (b *credentialBroker) MintActorCertificate(ctx context.Context, req *ateletpb.MintActorCertificateRequest) (*ateletpb.MintActorCertificateResponse, error) { + // TODO: Before release, request an atunnel-specific MintCert purpose and + // require the egress PEP to reject generic actor certificates. + // The request deliberately carries no actor identity. The authenticated Pod + // UID is the only input used to select a worker and its current assignment. + workerUID, err := authenticatedWorkerUID(ctx) + if err != nil { + return nil, err + } + var assigned *ateapipb.Worker + for pageToken := ""; ; { + resp, err := b.control.ListWorkers(ctx, &ateapipb.ListWorkersRequest{PageSize: 1000, PageToken: pageToken}) + if err != nil { + return nil, fmt.Errorf("list workers: %w", err) + } + for _, worker := range resp.GetWorkers() { + if worker.GetWorkerPodUid() == workerUID { + assigned = worker + break + } + } + if assigned != nil || resp.GetNextPageToken() == "" { + break + } + pageToken = resp.GetNextPageToken() + } + if assigned == nil { + return nil, status.Error(codes.PermissionDenied, "worker is not registered") + } + actorRef := assigned.GetAssignment().GetActor() + if actorRef.GetAtespace() == "" || actorRef.GetName() == "" { + return nil, status.Error(codes.PermissionDenied, "worker has no actor assignment") + } + actor, err := b.control.GetActor(ctx, &ateapipb.GetActorRequest{Actor: actorRef}) + if err != nil { + return nil, fmt.Errorf("get assigned actor: %w", err) + } + // MintCert revalidates this assignment in ateapi. That second check closes + // the race where the worker is reassigned after ListWorkers returns. + resp, err := b.identity.MintCert(ctx, &ateapipb.MintCertRequest{ + Atespace: actor.GetMetadata().GetAtespace(), + ActorName: actor.GetMetadata().GetName(), + ActorUid: actor.GetMetadata().GetUid(), + CertificateSigningRequest: req.GetCertificateSigningRequest(), + WorkerPodUid: workerUID, + }) + if err != nil { + return nil, fmt.Errorf("mint actor certificate: %w", err) + } + return &ateletpb.MintActorCertificateResponse{ActorCertificates: resp.GetActorCertificates()}, nil +} + +func authenticatedWorkerUID(ctx context.Context) (string, error) { + p, ok := peer.FromContext(ctx) + if !ok { + return "", status.Error(codes.Unauthenticated, "missing peer credentials") + } + tlsInfo, ok := p.AuthInfo.(credentials.TLSInfo) + if !ok || len(tlsInfo.State.PeerCertificates) == 0 { + return "", status.Error(codes.Unauthenticated, "missing peer certificate") + } + identity, err := substratex509.PodIdentityFromCertificate(tlsInfo.State.PeerCertificates[0]) + if err != nil || identity == nil { + return "", status.Error(codes.PermissionDenied, "invalid worker identity") + } + return identity.PodUID, nil +} + +func restrictClientToNode(node *substratex509.PodIdentity) func(tls.ConnectionState) error { + return func(state tls.ConnectionState) error { + if len(state.PeerCertificates) == 0 { + return fmt.Errorf("worker certificate is required") + } + identity, err := substratex509.PodIdentityFromCertificate(state.PeerCertificates[0]) + if err != nil { + return fmt.Errorf("parse worker Pod identity: %w", err) + } + if identity == nil || identity.NodeName != node.NodeName || identity.NodeUID != node.NodeUID { + return fmt.Errorf("worker is not on node %q (%s)", node.NodeName, node.NodeUID) + } + return nil + } +} diff --git a/cmd/atelet/credentialbroker_test.go b/cmd/atelet/credentialbroker_test.go new file mode 100644 index 000000000..a71c6fdd2 --- /dev/null +++ b/cmd/atelet/credentialbroker_test.go @@ -0,0 +1,133 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "math/big" + "testing" + "time" + + "github.com/agent-substrate/substrate/internal/proto/ateletpb" + "github.com/agent-substrate/substrate/internal/substratex509" + "github.com/agent-substrate/substrate/pkg/proto/ateapipb" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/peer" + "google.golang.org/protobuf/proto" +) + +type brokerControlClient struct { + ateapipb.ControlClient + worker *ateapipb.Worker + actor *ateapipb.Actor +} + +func (c *brokerControlClient) ListWorkers(context.Context, *ateapipb.ListWorkersRequest, ...grpc.CallOption) (*ateapipb.ListWorkersResponse, error) { + return &ateapipb.ListWorkersResponse{Workers: []*ateapipb.Worker{c.worker}}, nil +} + +func (c *brokerControlClient) GetActor(context.Context, *ateapipb.GetActorRequest, ...grpc.CallOption) (*ateapipb.Actor, error) { + return c.actor, nil +} + +type brokerIdentityClient struct { + ateapipb.ActorIdentityClient + request *ateapipb.MintCertRequest +} + +func (c *brokerIdentityClient) MintCert(_ context.Context, req *ateapipb.MintCertRequest, _ ...grpc.CallOption) (*ateapipb.MintCertResponse, error) { + c.request = req + return &ateapipb.MintCertResponse{ActorCertificates: [][]byte{{1, 2, 3}}}, nil +} + +func TestCredentialBrokerDerivesActorFromWorkerCertificate(t *testing.T) { + control := &brokerControlClient{ + worker: &ateapipb.Worker{ + WorkerPodUid: "worker-uid", + Assignment: &ateapipb.Assignment{Actor: &ateapipb.ObjectRef{Atespace: "team", Name: "actor"}}, + }, + actor: &ateapipb.Actor{Metadata: &ateapipb.ResourceMetadata{Atespace: "team", Name: "actor", Uid: "actor-uid"}}, + } + identity := &brokerIdentityClient{} + broker := &credentialBroker{control: control, identity: identity} + csr := []byte{4, 5, 6} + resp, err := broker.MintActorCertificate(workerContext(t, "worker-uid"), &ateletpb.MintActorCertificateRequest{CertificateSigningRequest: csr}) + if err != nil { + t.Fatal(err) + } + if !proto.Equal(resp, &ateletpb.MintActorCertificateResponse{ActorCertificates: [][]byte{{1, 2, 3}}}) { + t.Fatalf("response = %+v", resp) + } + want := &ateapipb.MintCertRequest{Atespace: "team", ActorName: "actor", ActorUid: "actor-uid", WorkerPodUid: "worker-uid", CertificateSigningRequest: csr} + if !proto.Equal(identity.request, want) { + t.Fatalf("MintCert request = %+v, want %+v", identity.request, want) + } +} + +func TestCredentialBrokerRejectsUnknownWorker(t *testing.T) { + broker := &credentialBroker{control: &brokerControlClient{}} + if _, err := broker.MintActorCertificate(workerContext(t, "unknown-worker"), &ateletpb.MintActorCertificateRequest{}); err == nil { + t.Fatal("unknown worker was accepted") + } +} + +func workerContext(t *testing.T, podUID string) context.Context { + t.Helper() + cert := workerCertificate(t, podUID, "node") + return peer.NewContext(context.Background(), &peer.Peer{AuthInfo: credentials.TLSInfo{State: tls.ConnectionState{PeerCertificates: []*x509.Certificate{cert}}}}) +} + +func TestRestrictClientToNode(t *testing.T) { + state := tls.ConnectionState{PeerCertificates: []*x509.Certificate{workerCertificate(t, "worker-uid", "node-a")}} + nodeA := &substratex509.PodIdentity{NodeName: "node-a", NodeUID: "node-uid"} + if err := restrictClientToNode(nodeA)(state); err != nil { + t.Fatalf("same-node worker rejected: %v", err) + } + if err := restrictClientToNode(&substratex509.PodIdentity{NodeName: "node-b", NodeUID: "node-uid"})(state); err == nil { + t.Fatal("cross-node worker accepted") + } + if err := restrictClientToNode(&substratex509.PodIdentity{NodeName: "node-a", NodeUID: "replacement-node"})(state); err == nil { + t.Fatal("replacement node accepted") + } +} + +func workerCertificate(t *testing.T, podUID, nodeName string) *x509.Certificate { + t.Helper() + _, key, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + template := &x509.Certificate{SerialNumber: big.NewInt(1), NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().Add(time.Hour)} + if err := substratex509.AddPodIdentityToCertificate(&substratex509.PodIdentity{ + Namespace: "workers", ServiceAccountName: "default", ServiceAccountUID: "sa-uid", + PodName: "worker", PodUID: podUID, NodeName: nodeName, NodeUID: "node-uid", + }, template); err != nil { + t.Fatal(err) + } + der, err := x509.CreateCertificate(rand.Reader, template, template, key.Public(), key) + if err != nil { + t.Fatal(err) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + t.Fatal(err) + } + return cert +} diff --git a/cmd/atelet/main.go b/cmd/atelet/main.go index b3f0241c9..778facaef 100644 --- a/cmd/atelet/main.go +++ b/cmd/atelet/main.go @@ -32,6 +32,7 @@ import ( "cloud.google.com/go/storage" "github.com/agent-substrate/substrate/cmd/atelet/internal/ategcs" + "github.com/agent-substrate/substrate/internal/ateapiauth" "github.com/agent-substrate/substrate/internal/ateerrors" "github.com/agent-substrate/substrate/internal/ateinterceptors" "github.com/agent-substrate/substrate/internal/ateompath" @@ -41,7 +42,9 @@ import ( "github.com/agent-substrate/substrate/internal/proto/ateompb" "github.com/agent-substrate/substrate/internal/resources" "github.com/agent-substrate/substrate/internal/serverboot" + "github.com/agent-substrate/substrate/internal/substratex509" "github.com/agent-substrate/substrate/internal/version" + "github.com/agent-substrate/substrate/pkg/proto/ateapipb" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/google/go-containerregistry/pkg/authn" @@ -71,6 +74,9 @@ var ( grpcServerCredBundle = pflag.String("grpc-server-cred-bundle", "/run/podidentity.podcert.ate.dev/credential-bundle.pem", "Credential bundle atelet presents as its gRPC serving certificate.") clientCACerts = pflag.String("client-ca-certs", "/run/podidentity.podcert.ate.dev/trust-bundle.pem", "CA bundle used to verify gRPC client certificates.") + ateapiAddress = pflag.String("ateapi-address", "dns:///api.ate-system.svc:443", "ateapi gRPC target used by the credential broker.") + ateapiCAFile = pflag.String("ateapi-ca-file", "/run/servicedns.podcert.ate.dev/trust-bundle.pem", "CA bundle used to verify ateapi.") + ateapiServerName = pflag.String("ateapi-server-name", "api.ate-system.svc", "DNS name expected on the ateapi certificate.") gcpAuthForImagePulls = pflag.Bool("gcp-auth-for-image-pulls", true, "Use GCP application default credentials mechanism.") localhostRegistryReplacement = pflag.String("localhost-registry-replacement", "", "The replacement registry endpoint for localhost and/or loopback IP addresses, useful for local development. for example kind-registry:5000") @@ -182,6 +188,19 @@ func main() { wrappedGCS, imageCache, ) + dialOpts, err := ateapiauth.DialOptions(ateapiauth.ClientConfig{ + CAFile: *ateapiCAFile, + ServerName: *ateapiServerName, + ClientCredBundle: *grpcServerCredBundle, + }) + if err != nil { + serverboot.Fatal(ctx, "Failed to build ateapi client credentials", err) + } + ateapiConn, err := grpc.NewClient(*ateapiAddress, dialOpts...) + if err != nil { + serverboot.Fatal(ctx, "Failed to create ateapi client", err) + } + defer ateapiConn.Close() lis, err := net.Listen("tcp", ":"+strconv.Itoa(*port)) if err != nil { @@ -192,6 +211,40 @@ func main() { if err != nil { serverboot.Fatal(ctx, "Failed to build server TLS config", err) } + ateletCert, err := credbundle.Parse(*grpcServerCredBundle) + if err != nil { + serverboot.Fatal(ctx, "Failed to load atelet Pod identity", err) + } + ateletIdentity, err := substratex509.PodIdentityFromCertificate(ateletCert.Leaf) + if err != nil { + serverboot.Fatal(ctx, "Failed to load atelet Pod identity", err) + } + if ateletIdentity == nil { + serverboot.Fatal(ctx, "Failed to load atelet Pod identity", fmt.Errorf("credential bundle has no Pod identity")) + } + brokerTLS := tlsCfg.Clone() + brokerTLS.VerifyConnection = restrictClientToNode(ateletIdentity) + if err := os.Remove(ateompath.CredentialBrokerSocket); err != nil && !errors.Is(err, os.ErrNotExist) { + serverboot.Fatal(ctx, "Failed to remove stale credential broker socket", err) + } + brokerLis, err := net.Listen("unix", ateompath.CredentialBrokerSocket) + if err != nil { + serverboot.Fatal(ctx, "Failed to listen for credential broker", err) + } + defer brokerLis.Close() + if err := os.Chmod(ateompath.CredentialBrokerSocket, 0o600); err != nil { + serverboot.Fatal(ctx, "Failed to restrict credential broker socket", err) + } + brokerServer := grpc.NewServer(grpc.Creds(credentials.NewTLS(brokerTLS))) + ateletpb.RegisterCredentialBrokerServer(brokerServer, &credentialBroker{ + control: ateapipb.NewControlClient(ateapiConn), + identity: ateapipb.NewActorIdentityClient(ateapiConn), + }) + go func() { + if err := brokerServer.Serve(brokerLis); err != nil { + serverboot.Fatal(ctx, "Failed to serve credential broker", err) + } + }() svr := grpc.NewServer( grpc.Creds(credentials.NewTLS(tlsCfg)), @@ -292,6 +345,7 @@ func (s *AteomHerder) Run(ctx context.Context, req *ateletpb.RunRequest) (resp * RuntimeAssetPaths: assetPaths, Spec: buildAteomWorkloadSpec(req.GetSpec()), ActorUid: actorUID, + EgressGateway: toAteomEgressGateway(req.GetEgressGateway()), }); err != nil { return nil, fmt.Errorf("while calling ateom.RunWorkload: %w", err) } @@ -667,6 +721,7 @@ func (s *AteomHerder) Restore(ctx context.Context, req *ateletpb.RestoreRequest) Spec: buildAteomWorkloadSpec(req.GetSpec()), Scope: toAteomSnapshotScope(req.GetScope()), ActorUid: req.GetActorUid(), + EgressGateway: toAteomEgressGateway(req.GetEgressGateway()), // Informational: for DATA_ON_GOLDEN the golden snapshot's files are // already staged into the restore dir by the combined download above; // ateom restores from the shared dir and never fetches this URI. @@ -937,6 +992,13 @@ func buildAteomWorkloadSpec(spec *ateletpb.WorkloadSpec) *ateompb.WorkloadSpec { return out } +func toAteomEgressGateway(gateway *ateletpb.EgressGateway) *ateompb.EgressGateway { + if gateway == nil { + return nil + } + return &ateompb.EgressGateway{Address: gateway.GetAddress()} +} + // toAteomReadyz converts an ateletpb readyz probe into the ateompb wire // type. Returns nil when the source is nil so containers without a probe // stay unchanged on the wire to ateom. diff --git a/cmd/atelet/main_test.go b/cmd/atelet/main_test.go index 50fe8a82d..fd936c4a2 100644 --- a/cmd/atelet/main_test.go +++ b/cmd/atelet/main_test.go @@ -666,6 +666,17 @@ func TestBuildAteomWorkloadSpecForwardsDurableDirMounts(t *testing.T) { } } +func TestToAteomEgressGateway(t *testing.T) { + if got := toAteomEgressGateway(nil); got != nil { + t.Fatalf("toAteomEgressGateway(nil) = %v, want nil", got) + } + want := &ateompb.EgressGateway{Address: "egress.example:443"} + got := toAteomEgressGateway(&ateletpb.EgressGateway{Address: want.Address}) + if diff := cmp.Diff(want, got, protocmp.Transform()); diff != "" { + t.Errorf("toAteomEgressGateway mismatch (-want +got):\n%s", diff) + } +} + func TestIsTerminalFileErr(t *testing.T) { tests := []struct { name string diff --git a/cmd/ateom-gvisor/main.go b/cmd/ateom-gvisor/main.go index 5e1a6440a..3429d9020 100644 --- a/cmd/ateom-gvisor/main.go +++ b/cmd/ateom-gvisor/main.go @@ -24,9 +24,11 @@ import ( "net" "net/url" "os" + "slices" "sort" "strings" "sync" + "time" "cloud.google.com/go/compute/metadata" "github.com/agent-substrate/substrate/internal/actorlog" @@ -56,11 +58,11 @@ var ( // TODO(liorlieberman) have a sub package for all atunnel releated things like that atunnelListenAddress = pflag.String("atunnel-listen-address", "0.0.0.0:443", "Address for actor ingress HTTPS") - atunnelCredentialBundle = pflag.String("atunnel-credential-bundle", "/run/podidentity.podcert.ate.dev/credential-bundle.pem", "PEM credential bundle for actor ingress HTTPS") - atunnelTrustBundle = pflag.String("atunnel-trust-bundle", "/run/podidentity.podcert.ate.dev/trust-bundle.pem", "PEM trust bundle for actor ingress clients") + workerCredentialBundle = pflag.String("atunnel-credential-bundle", "/run/podidentity.podcert.ate.dev/credential-bundle.pem", "Worker Pod credential bundle used by atunnel for inbound serving and outbound mTLS") + podIdentityTrustBundle = pflag.String("atunnel-trust-bundle", "/run/podidentity.podcert.ate.dev/trust-bundle.pem", "Pod identity trust bundle used for router clients and the node-local atelet") atunnelClientIdentity = pflag.String("atunnel-client-identity", "spiffe://cluster.local/ns/ate-system/sa/atenet-router", "SPIFFE identity allowed to call actor ingress HTTPS") atunnelEgressListenAddress = pflag.String("atunnel-egress-listen-address", "0.0.0.0:15001", "Address for transparently intercepted actor egress TCP") - atunnelEgressTrustBundle = pflag.String("atunnel-egress-trust-bundle", "/run/servicedns.podcert.ate.dev/trust-bundle.pem", "PEM trust bundle for the egress gateway") + egressGatewayTrustBundle = pflag.String("atunnel-egress-trust-bundle", "/run/servicedns.podcert.ate.dev/trust-bundle.pem", "Service DNS trust bundle for the remote egress gateway") showVersion = pflag.Bool("version", false, "Print version and exit.") logLevelFlag = pflag.String("log-level", "info", "Minimum log level: debug, info, warn, or error.") @@ -160,12 +162,12 @@ func do(ctx context.Context) error { if err != nil { return fmt.Errorf("while parsing atunnel upstream: %w", err) } - atunnelServer, atunnelEgress, atunnelEgressPort, err := runAtunnel(ctx, upstream) + atunnelIngress, atunnelEgress, egressProxyPort, err := runAtunnel(ctx, upstream) if err != nil { return err } - ateomService := NewService(interiorNetNS, actorLogger, atunnelServer, atunnelEgress, atunnelEgressPort, *atunnelCredentialBundle, *atunnelEgressTrustBundle) + ateomService := NewService(interiorNetNS, actorLogger, atunnelIngress, atunnelEgress, egressProxyPort, *workerCredentialBundle, *podIdentityTrustBundle, *egressGatewayTrustBundle) svr := grpc.NewServer( grpc.StatsHandler(otelgrpc.NewServerHandler()), @@ -183,9 +185,9 @@ func do(ctx context.Context) error { } func runAtunnel(ctx context.Context, upstream *url.URL) (*atunnel.Server, *atunnel.Egress, uint16, error) { - atunnelServer, err := atunnel.NewServer(atunnel.Config{ - CredentialBundlePath: *atunnelCredentialBundle, - TrustBundlePath: *atunnelTrustBundle, + atunnelIngress, err := atunnel.NewServer(atunnel.Config{ + CredentialBundlePath: *workerCredentialBundle, + TrustBundlePath: *podIdentityTrustBundle, AllowedClientID: *atunnelClientIdentity, Upstream: upstream, }) @@ -197,7 +199,7 @@ func runAtunnel(ctx context.Context, upstream *url.URL) (*atunnel.Server, *atunn return nil, nil, 0, fmt.Errorf("while opening atunnel listener: %w", err) } go func() { - if err := atunnelServer.Serve(ctx, atunnelListener); err != nil { + if err := atunnelIngress.Serve(ctx, atunnelListener); err != nil { serverboot.Fatal(ctx, "Failed to serve actor ingress", err) } }() @@ -216,14 +218,14 @@ func runAtunnel(ctx context.Context, upstream *url.URL) (*atunnel.Server, *atunn _ = egressListener.Close() return nil, nil, 0, fmt.Errorf("atunnel egress listener has invalid address %q", egressListener.Addr()) } - atunnelEgressPort := uint16(egressTCPAddr.Port) + egressProxyPort := uint16(egressTCPAddr.Port) go func() { if err := atunnelEgress.Serve(ctx, egressListener); err != nil { serverboot.Fatal(ctx, "Failed to serve actor egress", err) } }() slog.InfoContext(ctx, "atunnel egress serving", slog.String("address", *atunnelEgressListenAddress)) - return atunnelServer, atunnelEgress, atunnelEgressPort, nil + return atunnelIngress, atunnelEgress, egressProxyPort, nil } // AteomService is a service for shepherding single microvm. @@ -234,29 +236,36 @@ type AteomService struct { // subcommands are probably not safe to call concurrently. lock sync.Mutex - interiorNetNS netns.NsHandle - actorLogger *actorlog.ActorLogger - atunnel *atunnel.Server - atunnelEgress *atunnel.Egress - // atunnelEgressPort is zero when tunneled egress is disabled. Otherwise, - // actor TCP connections are transparently redirected to this local port. - atunnelEgressPort uint16 - atunnelCredentialBundle string - atunnelEgressTrustBundle string + interiorNetNS netns.NsHandle + actorLogger *actorlog.ActorLogger + atunnelIngress *atunnel.Server + atunnelEgress *atunnel.Egress + + // egressProxyPort is the local atunnel listener used as the target of the + // actor network's transparent TCP redirect. + egressProxyPort uint16 + // workerCredentialBundlePath contains the worker Pod certificate and key. + // Atunnel presents it to both the egress gateway and the atelet broker. + workerCredentialBundlePath string + // podIdentityTrustBundlePath verifies the node-local atelet's Pod identity. + podIdentityTrustBundlePath string + // egressGatewayTrustBundlePath verifies the remote gateway's serving cert. + egressGatewayTrustBundlePath string } var _ ateompb.AteomServer = (*AteomService)(nil) // NewService creates a new AteomService. -func NewService(interiorNetNS netns.NsHandle, actorLogger *actorlog.ActorLogger, atunnelServer *atunnel.Server, atunnelEgress *atunnel.Egress, atunnelEgressPort uint16, credentialBundle, egressTrustBundle string) *AteomService { +func NewService(interiorNetNS netns.NsHandle, actorLogger *actorlog.ActorLogger, atunnelIngress *atunnel.Server, atunnelEgress *atunnel.Egress, egressProxyPort uint16, workerCredentialBundlePath, podIdentityTrustBundlePath, egressGatewayTrustBundlePath string) *AteomService { return &AteomService{ - interiorNetNS: interiorNetNS, - actorLogger: actorLogger, - atunnel: atunnelServer, - atunnelEgress: atunnelEgress, - atunnelEgressPort: atunnelEgressPort, - atunnelCredentialBundle: credentialBundle, - atunnelEgressTrustBundle: egressTrustBundle, + interiorNetNS: interiorNetNS, + actorLogger: actorLogger, + atunnelIngress: atunnelIngress, + atunnelEgress: atunnelEgress, + egressProxyPort: egressProxyPort, + workerCredentialBundlePath: workerCredentialBundlePath, + podIdentityTrustBundlePath: podIdentityTrustBundlePath, + egressGatewayTrustBundlePath: egressGatewayTrustBundlePath, } } @@ -275,15 +284,30 @@ func (s *AteomService) RunWorkload(ctx context.Context, req *ateompb.RunWorkload // * Correct runsc version is downloaded and placed on disk. // * All OCI bundles are set up, including for "pause" container. + egress, err := s.prepareActorEgress(ctx, req.GetEgressGateway()) + if err != nil { + return nil, err + } if err := ateomnet.SetupActorNetwork(ctx, ateomnet.NetworkConfig{ InteriorNetNS: s.interiorNetNS, DumpNetInfo: true, - EgressRedirectPort: s.egressRedirectPort(req.GetEgressGatewayAddress() != ""), + EgressRedirectPort: s.egressRedirectPort(req.GetEgressGateway() != nil), }); err != nil { return nil, fmt.Errorf("while setting up actor network: %w", err) } + rcmd := &runsc{ + path: req.GetRunscPath(), + actorUID: req.GetActorUid(), + } + var containersToDelete []string defer func() { if retErr != nil { + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) + defer cancel() + if err := s.deactivateActorNetworking(cleanupCtx); err != nil { + slog.WarnContext(cleanupCtx, "Failed to deactivate actor networking after Run failure", slog.Any("err", err)) + } + deleteContainers(cleanupCtx, rcmd, containersToDelete, "Run") // Detach any bundle rootfs overlays a partially-completed setup // mounted, mirroring the post-checkpoint cleanup — otherwise they // linger in this namespace until atelet wipes the bundle dirs. @@ -292,17 +316,12 @@ func (s *AteomService) RunWorkload(ctx context.Context, req *ateompb.RunWorkload slog.WarnContext(ctx, "Failed to unmount bundle rootfs overlays after Run failure", "actorUID", req.GetActorUid(), "err", err) } - if err := ateomnet.CleanupActorNetwork(ctx, s.interiorNetNS); err != nil { - slog.WarnContext(ctx, "Failed to clean up actor network after Run failure", slog.Any("err", err)) + if err := ateomnet.CleanupActorNetwork(cleanupCtx, s.interiorNetNS); err != nil { + slog.WarnContext(cleanupCtx, "Failed to clean up actor network after Run failure", slog.Any("err", err)) } } }() - rcmd := &runsc{ - path: req.GetRunscPath(), - actorUID: req.GetActorUid(), - } - // Create and start pause container. The bundle rootfs is composed here — // an overlay of the node's cached image layers plus the bundle's private // upper — because mounting is ateom's job (atelet runs with no @@ -311,6 +330,7 @@ func (s *AteomService) RunWorkload(ctx context.Context, req *ateompb.RunWorkload if err := imagecache.SetupBundleRootfs(ateompath.OCIBundlePath(req.GetActorUid(), "pause")); err != nil { return nil, fmt.Errorf("while composing pause rootfs: %w", err) } + containersToDelete = append(containersToDelete, "pause") if err := rcmd.cmdCreate(ctx, os.Stdout, "pause", nil); err != nil { return nil, fmt.Errorf("while creating pause container: %w", err) } @@ -329,6 +349,7 @@ func (s *AteomService) RunWorkload(ctx context.Context, req *ateompb.RunWorkload if err := imagecache.SetupBundleRootfs(ateompath.OCIBundlePath(req.GetActorUid(), ac.GetName())); err != nil { return nil, fmt.Errorf("while composing %q rootfs: %w", ac.GetName(), err) } + containersToDelete = append(containersToDelete, ac.GetName()) if err := rcmd.cmdCreate(ctx, pw, ac.GetName(), nil); err != nil { return nil, fmt.Errorf("while creating %q application container: %w", ac.GetName(), err) } @@ -341,7 +362,7 @@ func (s *AteomService) RunWorkload(ctx context.Context, req *ateompb.RunWorkload if err := readyz.WaitAll(ctx, req.GetSpec().GetContainers(), ateomnet.ActorVethIP); err != nil { return nil, fmt.Errorf("while waiting for container readyz: %w", err) } - if err := s.activateActorNetworking(req.GetAtespace(), req.GetActorName(), req.GetActorVersion(), req.GetEgressGatewayAddress()); err != nil { + if err := s.activateActorNetworking(req.GetAtespace(), req.GetActorName(), egress); err != nil { return nil, err } @@ -496,31 +517,41 @@ func (s *AteomService) RestoreWorkload(ctx context.Context, req *ateompb.Restore // * All OCI bundles are set up, including for "pause" container. // * Checkpoint downloaded and placed on disk + egress, err := s.prepareActorEgress(ctx, req.GetEgressGateway()) + if err != nil { + return nil, err + } if err := ateomnet.SetupActorNetwork(ctx, ateomnet.NetworkConfig{ InteriorNetNS: s.interiorNetNS, DumpNetInfo: true, - EgressRedirectPort: s.egressRedirectPort(req.GetEgressGatewayAddress() != ""), + EgressRedirectPort: s.egressRedirectPort(req.GetEgressGateway() != nil), }); err != nil { return nil, fmt.Errorf("while setting up actor network: %w", err) } + rcmd := &runsc{ + path: req.GetRunscPath(), + actorUID: req.GetActorUid(), + } + var containersToDelete []string defer func() { if retErr != nil { + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) + defer cancel() + if err := s.deactivateActorNetworking(cleanupCtx); err != nil { + slog.WarnContext(cleanupCtx, "Failed to deactivate actor networking after Restore failure", slog.Any("err", err)) + } + deleteContainers(cleanupCtx, rcmd, containersToDelete, "Restore") // Same overlay detach as the Run-failure path above. if err := imagecache.UnmountAllUnder(ateompath.OCIBundleDir(req.GetActorUid())); err != nil { slog.WarnContext(ctx, "Failed to unmount bundle rootfs overlays after Restore failure", "actorUID", req.GetActorUid(), "err", err) } - if err := ateomnet.CleanupActorNetwork(ctx, s.interiorNetNS); err != nil { - slog.WarnContext(ctx, "Failed to clean up actor network after Restore failure", slog.Any("err", err)) + if err := ateomnet.CleanupActorNetwork(cleanupCtx, s.interiorNetNS); err != nil { + slog.WarnContext(cleanupCtx, "Failed to clean up actor network after Restore failure", slog.Any("err", err)) } } }() - rcmd := &runsc{ - path: req.GetRunscPath(), - actorUID: req.GetActorUid(), - } - checkpointDir := ateompath.RestoreStateDir(req.GetActorUid()) // Compose the pause rootfs before create (see RunWorkload). runsc restore @@ -533,6 +564,7 @@ func (s *AteomService) RestoreWorkload(ctx context.Context, req *ateompb.Restore switch req.GetScope() { case ateompb.SnapshotScope_SNAPSHOT_SCOPE_DATA: // Create and restore pause container + containersToDelete = append(containersToDelete, "pause") if err := rcmd.cmdCreate(ctx, os.Stdout, "pause", []string{"--fs-restore-image-path", checkpointDir}); err != nil { return nil, fmt.Errorf("while creating pause container: %w", err) } @@ -541,6 +573,7 @@ func (s *AteomService) RestoreWorkload(ctx context.Context, req *ateompb.Restore } case ateompb.SnapshotScope_SNAPSHOT_SCOPE_FULL: // Create and restore pause container + containersToDelete = append(containersToDelete, "pause") if err := rcmd.cmdCreate(ctx, os.Stdout, "pause", nil); err != nil { return nil, fmt.Errorf("while creating pause container: %w", err) } @@ -564,6 +597,7 @@ func (s *AteomService) RestoreWorkload(ctx context.Context, req *ateompb.Restore } switch req.GetScope() { case ateompb.SnapshotScope_SNAPSHOT_SCOPE_DATA: + containersToDelete = append(containersToDelete, ac.GetName()) if err := rcmd.cmdCreate(ctx, pw, ac.GetName(), nil); err != nil { return nil, fmt.Errorf("while creating %q application container: %w", ac.GetName(), err) } @@ -571,6 +605,7 @@ func (s *AteomService) RestoreWorkload(ctx context.Context, req *ateompb.Restore return nil, fmt.Errorf("while starting %q application container: %w", ac.GetName(), err) } case ateompb.SnapshotScope_SNAPSHOT_SCOPE_FULL: + containersToDelete = append(containersToDelete, ac.GetName()) if err := rcmd.cmdCreate(ctx, pw, ac.GetName(), nil); err != nil { return nil, fmt.Errorf("while creating %q application container: %w", ac.GetName(), err) } @@ -586,7 +621,7 @@ func (s *AteomService) RestoreWorkload(ctx context.Context, req *ateompb.Restore if err := readyz.WaitAll(ctx, req.GetSpec().GetContainers(), ateomnet.ActorVethIP); err != nil { return nil, fmt.Errorf("while waiting for container readyz: %w", err) } - if err := s.activateActorNetworking(req.GetAtespace(), req.GetActorName(), req.GetActorVersion(), req.GetEgressGatewayAddress()); err != nil { + if err := s.activateActorNetworking(req.GetAtespace(), req.GetActorName(), egress); err != nil { return nil, err } @@ -595,49 +630,77 @@ func (s *AteomService) RestoreWorkload(ctx context.Context, req *ateompb.Restore return &ateompb.RestoreWorkloadResponse{}, nil } -func (s *AteomService) activateActorNetworking(atespace, actorName string, actorVersion int64, egressGatewayAddress string) error { - var egressClient atunnel.EgressDialer - if s.atunnelEgress != nil && egressGatewayAddress != "" { - serverName, _, err := net.SplitHostPort(egressGatewayAddress) - if err != nil { - return fmt.Errorf("invalid egress gateway address %q: %w", egressGatewayAddress, err) - } - egressClient, err = atunnel.NewClient(atunnel.ClientConfig{ - GatewayAddress: egressGatewayAddress, - ServerName: serverName, - CredentialBundlePath: s.atunnelCredentialBundle, - TrustBundlePath: s.atunnelEgressTrustBundle, - }) - if err != nil { - return fmt.Errorf("while configuring actor egress client: %w", err) - } +type actorEgress struct { + // client presents the actor certificate to the remote egress gateway. + client *atunnel.Client + // certificateSource owns the actor key and renews its certificate via atelet. + certificateSource *atunnel.BrokerCertificateSource + expiresAt time.Time +} + +func (s *AteomService) prepareActorEgress(ctx context.Context, gateway *ateompb.EgressGateway) (*actorEgress, error) { + if gateway == nil { + return nil, nil } - if s.atunnel != nil { - if err := s.atunnel.Activate(atespace, actorName); err != nil { - return fmt.Errorf("while activating actor ingress: %w", err) - } + if gateway.GetAddress() == "" { + return nil, fmt.Errorf("egress gateway address is required") } - if egressClient != nil { - if err := s.atunnelEgress.Activate(egressClient, atespace, actorName, actorVersion, ""); err != nil { - if s.atunnel != nil { - _ = s.atunnel.Deactivate(context.Background()) - } - return fmt.Errorf("while activating actor egress: %w", err) - } + serverName, _, err := net.SplitHostPort(gateway.GetAddress()) + if err != nil { + return nil, fmt.Errorf("invalid egress gateway address %q: %w", gateway.GetAddress(), err) + } + certificateSource, err := atunnel.NewBrokerCertificateSource(atunnel.BrokerConfig{ + SocketPath: ateompath.CredentialBrokerSocket, + CredentialBundlePath: s.workerCredentialBundlePath, + TrustBundlePath: s.podIdentityTrustBundlePath, + }) + if err != nil { + return nil, fmt.Errorf("while configuring actor certificate broker: %w", err) + } + // Mint before starting the workload so configured tunneled egress fails + // closed. The source retains the private key for mTLS and renewal. + expiresAt, err := certificateSource.Mint(ctx) + if err != nil { + return nil, fmt.Errorf("while obtaining actor certificate: %w", err) + } + gatewayClient, err := atunnel.NewClient(atunnel.ClientConfig{ + GatewayAddress: gateway.GetAddress(), + ServerName: serverName, + GetClientCertificate: certificateSource.GetClientCertificate, + TrustBundlePath: s.egressGatewayTrustBundlePath, + }) + if err != nil { + return nil, fmt.Errorf("while configuring actor egress client: %w", err) + } + return &actorEgress{client: gatewayClient, certificateSource: certificateSource, expiresAt: expiresAt}, nil +} + +func (s *AteomService) activateActorNetworking(atespace, actorName string, egress *actorEgress) error { + if err := s.atunnelIngress.Activate(atespace, actorName); err != nil { + return fmt.Errorf("while activating actor ingress: %w", err) + } + if egress == nil { + return nil + } + if err := s.atunnelEgress.Activate(egress.client, egress.certificateSource, egress.expiresAt); err != nil { + return fmt.Errorf("while activating actor egress: %w", err) } return nil } +func deleteContainers(ctx context.Context, rcmd *runsc, containers []string, operation string) { + for _, container := range slices.Backward(containers) { + if err := rcmd.cmdDelete(ctx, container); err != nil { + slog.WarnContext(ctx, "Failed to delete runsc container after failure", + "operation", operation, "container", container, "err", err) + } + } +} + func (s *AteomService) deactivateActorNetworking(ctx context.Context) error { // Stop admitting traffic and drain active streams before the Actor network // is torn down. Attempt both directions even if one fails to deactivate. - var err error - if s.atunnel != nil { - err = errors.Join(err, s.atunnel.Deactivate(ctx)) - } - if s.atunnelEgress != nil { - err = errors.Join(err, s.atunnelEgress.Deactivate(ctx)) - } + err := errors.Join(s.atunnelIngress.Deactivate(ctx), s.atunnelEgress.Deactivate(ctx)) if err != nil { return fmt.Errorf("while deactivating actor networking: %w", err) } @@ -651,7 +714,7 @@ func (s *AteomService) egressRedirectPort(redirectEgress bool) uint16 { if !redirectEgress { return 0 } - return s.atunnelEgressPort + return s.egressProxyPort } // setupCgroupDelegation prepares the worker pod's cgroup so runsc can create a diff --git a/cmd/ateom-microvm/main.go b/cmd/ateom-microvm/main.go index 246726997..1005bc354 100644 --- a/cmd/ateom-microvm/main.go +++ b/cmd/ateom-microvm/main.go @@ -33,6 +33,7 @@ import ( "os" "strings" "sync" + "time" "cloud.google.com/go/compute/metadata" "github.com/agent-substrate/substrate/cmd/ateom-microvm/internal/reaper" @@ -61,11 +62,11 @@ var ( logLevelFlag = flag.String("log-level", "info", "Minimum log level: debug, info, warn, or error.") atunnelListenAddress = flag.String("atunnel-listen-address", "0.0.0.0:443", "Address for actor ingress HTTPS") - atunnelCredentialBundle = flag.String("atunnel-credential-bundle", "/run/podidentity.podcert.ate.dev/credential-bundle.pem", "PEM credential bundle for actor ingress HTTPS") - atunnelTrustBundle = flag.String("atunnel-trust-bundle", "/run/podidentity.podcert.ate.dev/trust-bundle.pem", "PEM trust bundle for actor ingress clients") + workerCredentialBundle = flag.String("atunnel-credential-bundle", "/run/podidentity.podcert.ate.dev/credential-bundle.pem", "Worker Pod credential bundle used by atunnel for inbound serving and outbound mTLS") + podIdentityTrustBundle = flag.String("atunnel-trust-bundle", "/run/podidentity.podcert.ate.dev/trust-bundle.pem", "Pod identity trust bundle used for router clients and the node-local atelet") atunnelClientIdentity = flag.String("atunnel-client-identity", "spiffe://cluster.local/ns/ate-system/sa/atenet-router", "SPIFFE identity allowed to call actor ingress HTTPS") atunnelEgressListenAddress = flag.String("atunnel-egress-listen-address", "0.0.0.0:15001", "Address for transparently intercepted actor egress TCP") - atunnelEgressTrustBundle = flag.String("atunnel-egress-trust-bundle", "/run/servicedns.podcert.ate.dev/trust-bundle.pem", "PEM trust bundle for the egress gateway") + egressGatewayTrustBundle = flag.String("atunnel-egress-trust-bundle", "/run/servicedns.podcert.ate.dev/trust-bundle.pem", "Service DNS trust bundle for the remote egress gateway") ) const ( @@ -168,9 +169,9 @@ func do(ctx context.Context) error { if err != nil { return fmt.Errorf("while parsing atunnel upstream: %w", err) } - atunnelServer, err := atunnel.NewServer(atunnel.Config{ - CredentialBundlePath: *atunnelCredentialBundle, - TrustBundlePath: *atunnelTrustBundle, + atunnelIngress, err := atunnel.NewServer(atunnel.Config{ + CredentialBundlePath: *workerCredentialBundle, + TrustBundlePath: *podIdentityTrustBundle, AllowedClientID: *atunnelClientIdentity, Upstream: upstream, }) @@ -182,7 +183,7 @@ func do(ctx context.Context) error { return fmt.Errorf("while opening atunnel listener: %w", err) } go func() { - if err := atunnelServer.Serve(ctx, atunnelListener); err != nil { + if err := atunnelIngress.Serve(ctx, atunnelListener); err != nil { serverboot.Fatal(ctx, "Failed to serve actor ingress", err) } }() @@ -200,7 +201,7 @@ func do(ctx context.Context) error { _ = egressListener.Close() return fmt.Errorf("atunnel egress listener has invalid address %q", egressListener.Addr()) } - atunnelEgressPort := uint16(egressTCPAddr.Port) + egressProxyPort := uint16(egressTCPAddr.Port) go func() { if err := atunnelEgress.Serve(ctx, egressListener); err != nil { serverboot.Fatal(ctx, "Failed to serve actor egress", err) @@ -212,7 +213,7 @@ func do(ctx context.Context) error { grpc.StatsHandler(otelgrpc.NewServerHandler()), grpc.UnaryInterceptor(ateinterceptors.InternalServerUnaryInterceptor), ) - ateompb.RegisterAteomServer(svr, NewService(*podUID, *chBinary, *kataConfig, *kataDebug, interiorNetNS, actorLogger, atunnelServer, atunnelEgress, atunnelEgressPort, *atunnelCredentialBundle, *atunnelEgressTrustBundle)) + ateompb.RegisterAteomServer(svr, NewService(*podUID, *chBinary, *kataConfig, *kataDebug, interiorNetNS, actorLogger, atunnelIngress, atunnelEgress, egressProxyPort, *workerCredentialBundle, *podIdentityTrustBundle, *egressGatewayTrustBundle)) reflection.Register(svr) slog.InfoContext(ctx, "ateom-microvm serving", slog.String("socket", sockPath)) @@ -270,14 +271,20 @@ type AteomService struct { // actorLogger forwards the actor container's stdout/stderr to the worker pod's // stdout as ate.dev/*-labeled JSON and emits actor lifecycle events (parity // with ateom-gvisor). - actorLogger *actorlog.ActorLogger - atunnel *atunnel.Server - atunnelEgress *atunnel.Egress - // atunnelEgressPort is zero when tunneled egress is disabled. Otherwise, - // actor TCP connections are transparently redirected to this local port. - atunnelEgressPort uint16 - atunnelCredentialBundle string - atunnelEgressTrustBundle string + actorLogger *actorlog.ActorLogger + atunnelIngress *atunnel.Server + atunnelEgress *atunnel.Egress + + // egressProxyPort is the local atunnel listener used as the target of the + // actor network's transparent TCP redirect. + egressProxyPort uint16 + // workerCredentialBundlePath contains the worker Pod certificate and key. + // Atunnel presents it to both the egress gateway and the atelet broker. + workerCredentialBundlePath string + // podIdentityTrustBundlePath verifies the node-local atelet's Pod identity. + podIdentityTrustBundlePath string + // egressGatewayTrustBundlePath verifies the remote gateway's serving cert. + egressGatewayTrustBundlePath string // running maps actor UID -> the live micro-VM, kept so CheckpointWorkload can // pause+snapshot+teardown the same sandbox (and RestoreWorkload can track the @@ -288,52 +295,78 @@ type AteomService struct { var _ ateompb.AteomServer = (*AteomService)(nil) // NewService creates a new AteomService. -func NewService(podUID, chBinary, kataConfig string, kataDebug bool, interiorNetNS netns.NsHandle, actorLogger *actorlog.ActorLogger, atunnelServer *atunnel.Server, atunnelEgress *atunnel.Egress, atunnelEgressPort uint16, credentialBundle, egressTrustBundle string) *AteomService { +func NewService(podUID, chBinary, kataConfig string, kataDebug bool, interiorNetNS netns.NsHandle, actorLogger *actorlog.ActorLogger, atunnelIngress *atunnel.Server, atunnelEgress *atunnel.Egress, egressProxyPort uint16, workerCredentialBundlePath, podIdentityTrustBundlePath, egressGatewayTrustBundlePath string) *AteomService { return &AteomService{ - podUID: podUID, - chBinary: chBinary, - kataConfig: kataConfig, - kataDebug: kataDebug, - interiorNetNS: interiorNetNS, - actorLogger: actorLogger, - atunnel: atunnelServer, - atunnelEgress: atunnelEgress, - atunnelEgressPort: atunnelEgressPort, - atunnelCredentialBundle: credentialBundle, - atunnelEgressTrustBundle: egressTrustBundle, - running: map[string]*runningActor{}, + podUID: podUID, + chBinary: chBinary, + kataConfig: kataConfig, + kataDebug: kataDebug, + interiorNetNS: interiorNetNS, + actorLogger: actorLogger, + atunnelIngress: atunnelIngress, + atunnelEgress: atunnelEgress, + egressProxyPort: egressProxyPort, + workerCredentialBundlePath: workerCredentialBundlePath, + podIdentityTrustBundlePath: podIdentityTrustBundlePath, + egressGatewayTrustBundlePath: egressGatewayTrustBundlePath, + running: map[string]*runningActor{}, } } -func (s *AteomService) activateActorNetworking(atespace, actorName string, actorVersion int64, egressGatewayAddress string) error { - var egressClient atunnel.EgressDialer - if s.atunnelEgress != nil && egressGatewayAddress != "" { - serverName, _, err := net.SplitHostPort(egressGatewayAddress) - if err != nil { - return fmt.Errorf("invalid egress gateway address %q: %w", egressGatewayAddress, err) - } - egressClient, err = atunnel.NewClient(atunnel.ClientConfig{ - GatewayAddress: egressGatewayAddress, - ServerName: serverName, - CredentialBundlePath: s.atunnelCredentialBundle, - TrustBundlePath: s.atunnelEgressTrustBundle, - }) - if err != nil { - return fmt.Errorf("while configuring actor egress client: %w", err) - } +type actorEgress struct { + // client presents the actor certificate to the remote egress gateway. + client *atunnel.Client + // certificateSource owns the actor key and renews its certificate via atelet. + certificateSource *atunnel.BrokerCertificateSource + expiresAt time.Time +} + +func (s *AteomService) prepareActorEgress(ctx context.Context, gateway *ateompb.EgressGateway) (*actorEgress, error) { + if gateway == nil { + return nil, nil } - if s.atunnel != nil { - if err := s.atunnel.Activate(atespace, actorName); err != nil { - return fmt.Errorf("while activating actor ingress: %w", err) - } + if gateway.GetAddress() == "" { + return nil, fmt.Errorf("egress gateway address is required") } - if egressClient != nil { - if err := s.atunnelEgress.Activate(egressClient, atespace, actorName, actorVersion, ""); err != nil { - if s.atunnel != nil { - _ = s.atunnel.Deactivate(context.Background()) - } - return fmt.Errorf("while activating actor egress: %w", err) - } + serverName, _, err := net.SplitHostPort(gateway.GetAddress()) + if err != nil { + return nil, fmt.Errorf("invalid egress gateway address %q: %w", gateway.GetAddress(), err) + } + certificateSource, err := atunnel.NewBrokerCertificateSource(atunnel.BrokerConfig{ + SocketPath: ateompath.CredentialBrokerSocket, + CredentialBundlePath: s.workerCredentialBundlePath, + TrustBundlePath: s.podIdentityTrustBundlePath, + }) + if err != nil { + return nil, fmt.Errorf("while configuring actor certificate broker: %w", err) + } + // Mint before starting the workload so configured tunneled egress fails + // closed. The source retains the private key for mTLS and renewal. + expiresAt, err := certificateSource.Mint(ctx) + if err != nil { + return nil, fmt.Errorf("while obtaining actor certificate: %w", err) + } + gatewayClient, err := atunnel.NewClient(atunnel.ClientConfig{ + GatewayAddress: gateway.GetAddress(), + ServerName: serverName, + GetClientCertificate: certificateSource.GetClientCertificate, + TrustBundlePath: s.egressGatewayTrustBundlePath, + }) + if err != nil { + return nil, fmt.Errorf("while configuring actor egress client: %w", err) + } + return &actorEgress{client: gatewayClient, certificateSource: certificateSource, expiresAt: expiresAt}, nil +} + +func (s *AteomService) activateActorNetworking(atespace, actorName string, egress *actorEgress) error { + if err := s.atunnelIngress.Activate(atespace, actorName); err != nil { + return fmt.Errorf("while activating actor ingress: %w", err) + } + if egress == nil { + return nil + } + if err := s.atunnelEgress.Activate(egress.client, egress.certificateSource, egress.expiresAt); err != nil { + return fmt.Errorf("while activating actor egress: %w", err) } return nil } @@ -341,13 +374,7 @@ func (s *AteomService) activateActorNetworking(atespace, actorName string, actor func (s *AteomService) deactivateActorNetworking(ctx context.Context) error { // Stop admitting traffic and drain active streams before the Actor network // is torn down. Attempt both directions even if one fails to deactivate. - var err error - if s.atunnel != nil { - err = errors.Join(err, s.atunnel.Deactivate(ctx)) - } - if s.atunnelEgress != nil { - err = errors.Join(err, s.atunnelEgress.Deactivate(ctx)) - } + err := errors.Join(s.atunnelIngress.Deactivate(ctx), s.atunnelEgress.Deactivate(ctx)) if err != nil { return fmt.Errorf("while deactivating actor networking: %w", err) } @@ -361,5 +388,5 @@ func (s *AteomService) egressRedirectPort(redirectEgress bool) uint16 { if !redirectEgress { return 0 } - return s.atunnelEgressPort + return s.egressProxyPort } diff --git a/cmd/ateom-microvm/restore.go b/cmd/ateom-microvm/restore.go index a0c76affc..583311680 100644 --- a/cmd/ateom-microvm/restore.go +++ b/cmd/ateom-microvm/restore.go @@ -70,8 +70,7 @@ func (s *AteomService) RestoreWorkload(ctx context.Context, req *ateompb.Restore containers: req.GetSpec().GetContainers(), assetPaths: req.GetRuntimeAssetPaths(), - actorVersion: req.GetActorVersion(), - egressGatewayAddress: req.GetEgressGatewayAddress(), + egressGateway: req.GetEgressGateway(), } restoreDir := ateompath.RestoreStateDir(p.actorUID) durableDir := ateompath.DurableDirVolumeMountsDir(p.actorUID) @@ -133,6 +132,10 @@ func (s *AteomService) restoreFullScope(ctx context.Context, p actorBootParams, templateNS, templateName := p.templateNS, p.templateName rr := s.resolveRuntime(p.assetPaths) + egress, err := s.prepareActorEgress(ctx, p.egressGateway) + if err != nil { + return err + } kata.CleanupSandboxState(ctx, actorUID) // Repoint the snapshot's vsock socket to this actor's VMDir (the disk + kernel @@ -203,14 +206,19 @@ func (s *AteomService) restoreFullScope(ctx context.Context, p actorBootParams, InteriorNetNS: s.interiorNetNS, HostVethHWAddr: hostVethHWAddr, SweepInteriorLinks: true, - EgressRedirectPort: s.egressRedirectPort(p.egressGatewayAddress != ""), + EgressRedirectPort: s.egressRedirectPort(p.egressGateway != nil), }); err != nil { return fmt.Errorf("while setting up actor network: %w", err) } defer func() { if retErr != nil { - if cleanupErr := ateomnet.CleanupActorNetwork(ctx, s.interiorNetNS); cleanupErr != nil { - slog.WarnContext(ctx, "Failed to clean up actor network after Restore failure", slog.Any("err", cleanupErr)) + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) + defer cancel() + if cleanupErr := s.deactivateActorNetworking(cleanupCtx); cleanupErr != nil { + slog.WarnContext(cleanupCtx, "Failed to deactivate actor networking after Restore failure", slog.Any("err", cleanupErr)) + } + if cleanupErr := ateomnet.CleanupActorNetwork(cleanupCtx, s.interiorNetNS); cleanupErr != nil { + slog.WarnContext(cleanupCtx, "Failed to clean up actor network after Restore failure", slog.Any("err", cleanupErr)) } // Detach any bundle rootfs overlays mounted by buildActorContainers // before the failure, mirroring teardownActor's cleanup. @@ -298,7 +306,7 @@ func (s *AteomService) restoreFullScope(ctx context.Context, p actorBootParams, } } - if err := s.activateActorNetworking(p.actorRef.Atespace, p.actorRef.Name, p.actorVersion, p.egressGatewayAddress); err != nil { + if err := s.activateActorNetworking(p.actorRef.Atespace, p.actorRef.Name, egress); err != nil { return err } s.running[actorUID] = ra diff --git a/cmd/ateom-microvm/run.go b/cmd/ateom-microvm/run.go index ae691a2ca..7fdea7f2b 100644 --- a/cmd/ateom-microvm/run.go +++ b/cmd/ateom-microvm/run.go @@ -212,8 +212,7 @@ func (s *AteomService) RunWorkload(ctx context.Context, req *ateompb.RunWorkload containers: req.GetSpec().GetContainers(), assetPaths: req.GetRuntimeAssetPaths(), - actorVersion: req.GetActorVersion(), - egressGatewayAddress: req.GetEgressGatewayAddress(), + egressGateway: req.GetEgressGateway(), } s.actorLogger.EmitLifecycleLog("Actor starting", p.actorRef, p.actorUID, p.templateNS, p.templateName) @@ -235,12 +234,8 @@ type actorBootParams struct { templateName string containers []*ateompb.Container assetPaths map[string]string - // actorVersion is the Actor resource version ate-api observed when it - // assigned this worker; atunnel asserts it to the egress gateway. - actorVersion int64 - // egressGatewayAddress is empty unless an egress gateway is configured, in - // which case actor TCP egress is redirected to atunnel's local listener. - egressGatewayAddress string + // egressGateway is nil unless actor TCP should be redirected through atunnel. + egressGateway *ateompb.EgressGateway } // coldBootAttempts is how many times a cold boot is tried when the micro-VM @@ -298,6 +293,10 @@ func (s *AteomService) coldBootActor(ctx context.Context, p actorBootParams) (re return fmt.Errorf("ateom-microvm requires %q and %q asset paths", assetKernel, assetImage) } rr := s.resolveRuntime(paths) + egress, err := s.prepareActorEgress(ctx, p.egressGateway) + if err != nil { + return err + } // Networking (host side): per-activation veth into the interior netns. The // tap + TC mirror is built below (after the VM exists) so its FDs are fresh. @@ -305,14 +304,19 @@ func (s *AteomService) coldBootActor(ctx context.Context, p actorBootParams) (re InteriorNetNS: s.interiorNetNS, HostVethHWAddr: hostVethHWAddr, SweepInteriorLinks: true, - EgressRedirectPort: s.egressRedirectPort(p.egressGatewayAddress != ""), + EgressRedirectPort: s.egressRedirectPort(p.egressGateway != nil), }); err != nil { return fmt.Errorf("while setting up actor network: %w", err) } defer func() { if retErr != nil { - if cleanupErr := ateomnet.CleanupActorNetwork(ctx, s.interiorNetNS); cleanupErr != nil { - slog.WarnContext(ctx, "Failed to clean up actor network after Run failure", slog.Any("err", cleanupErr)) + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) + defer cancel() + if cleanupErr := s.deactivateActorNetworking(cleanupCtx); cleanupErr != nil { + slog.WarnContext(cleanupCtx, "Failed to deactivate actor networking after Run failure", slog.Any("err", cleanupErr)) + } + if cleanupErr := ateomnet.CleanupActorNetwork(cleanupCtx, s.interiorNetNS); cleanupErr != nil { + slog.WarnContext(cleanupCtx, "Failed to clean up actor network after Run failure", slog.Any("err", cleanupErr)) } // Detach any bundle rootfs overlays mounted by buildActorContainers // before the failure, mirroring teardownActor's cleanup. @@ -459,7 +463,7 @@ func (s *AteomService) coldBootActor(ctx context.Context, p actorBootParams) (re } ra := &runningActor{chCmd: chCmd, vfsdCmd: vfsdCmd, durableVfsdCmd: durableVfsdCmd, apiSocket: apiSocket, baseID: actorUID, logAgent: ac} - if err := s.activateActorNetworking(p.actorRef.Atespace, p.actorRef.Name, p.actorVersion, p.egressGatewayAddress); err != nil { + if err := s.activateActorNetworking(p.actorRef.Atespace, p.actorRef.Name, egress); err != nil { return err } s.running[actorUID] = ra diff --git a/internal/ateompath/ateompath.go b/internal/ateompath/ateompath.go index a6f9deb8f..5f9f5523d 100644 --- a/internal/ateompath/ateompath.go +++ b/internal/ateompath/ateompath.go @@ -33,7 +33,8 @@ var ( // internal/imagecache). It lives under BasePath so the cached layer // directories are visible at the same path in atelet (which writes them) // and in every ateom pod (which mounts them as overlay lowerdirs). - ImageCacheDir = filepath.Join(BasePath, "image-cache") + ImageCacheDir = filepath.Join(BasePath, "image-cache") + CredentialBrokerSocket = filepath.Join(BasePath, "credential-broker.sock") ) func RunSCBinaryPath(sha256 string) string { diff --git a/internal/atunnel/client.go b/internal/atunnel/client.go index 1a7817a5e..666431bbf 100644 --- a/internal/atunnel/client.go +++ b/internal/atunnel/client.go @@ -27,20 +27,6 @@ import ( "os" "strconv" "strings" - - "github.com/agent-substrate/substrate/internal/resources" -) - -const ( - // ActorAtespaceHeader identifies the atespace whose actor opened an egress - // tunnel. The egress gateway must authenticate this metadata before using it - // for policy decisions. - ActorAtespaceHeader = "X-Ate-Atespace" - // ActorNameHeader identifies the actor that opened an egress tunnel. - ActorNameHeader = "X-Ate-Actor-Name" - // ActorVersionHeader is the Actor resource version observed when the worker - // was assigned. Gateways use it as a lower bound on cached Actor metadata. - ActorVersionHeader = "X-Ate-Actor-Version" ) // TODO(liorlieberman): support/use CONNECT on Ingress as well. @@ -48,7 +34,7 @@ const ( type ClientConfig struct { GatewayAddress string ServerName string - CredentialBundlePath string + GetClientCertificate func(*tls.CertificateRequestInfo) (*tls.Certificate, error) TrustBundlePath string } @@ -69,15 +55,6 @@ func WithDialer(dial DialFunc) ClientOption { } } -// EgressMetadata is attached to an egress CONNECT request. BearerToken is -// optional until actor JWT issuance is wired into ateom. -type EgressMetadata struct { - Atespace string - ActorName string - ActorVersion int64 - BearerToken string -} - // Client opens actor egress streams through an mTLS-authenticated gateway. type Client struct { gatewayAddress string @@ -85,8 +62,8 @@ type Client struct { dialContext DialFunc } -// Client implements EgressDialer. -var _ EgressDialer = (*Client)(nil) +// Client implements egressDialer. +var _ egressDialer = (*Client)(nil) // NewClient creates an egress CONNECT client and validates its TLS material. func NewClient(cfg ClientConfig, opts ...ClientOption) (*Client, error) { @@ -96,15 +73,12 @@ func NewClient(cfg ClientConfig, opts ...ClientOption) (*Client, error) { if cfg.ServerName == "" { return nil, fmt.Errorf("atunnel: egress gateway server name is required") } - if cfg.CredentialBundlePath == "" { - return nil, fmt.Errorf("atunnel: credential bundle path is required") + if cfg.GetClientCertificate == nil { + return nil, fmt.Errorf("atunnel: client certificate source is required") } if cfg.TrustBundlePath == "" { return nil, fmt.Errorf("atunnel: trust bundle path is required") } - if _, err := loadCredentialBundle(cfg.CredentialBundlePath); err != nil { - return nil, err - } trustPEM, err := os.ReadFile(cfg.TrustBundlePath) if err != nil { return nil, fmt.Errorf("atunnel: reading trust bundle: %w", err) @@ -114,17 +88,14 @@ func NewClient(cfg ClientConfig, opts ...ClientOption) (*Client, error) { return nil, fmt.Errorf("atunnel: trust bundle %q contains no certificates", cfg.TrustBundlePath) } - credentialBundlePath := cfg.CredentialBundlePath client := &Client{ gatewayAddress: cfg.GatewayAddress, dialContext: (&net.Dialer{}).DialContext, tlsConfig: &tls.Config{ - MinVersion: tls.VersionTLS12, - RootCAs: rootCAs, - ServerName: cfg.ServerName, - GetClientCertificate: func(*tls.CertificateRequestInfo) (*tls.Certificate, error) { - return loadCredentialBundle(credentialBundlePath) - }, + MinVersion: tls.VersionTLS12, + RootCAs: rootCAs, + ServerName: cfg.ServerName, + GetClientCertificate: cfg.GetClientCertificate, }, } for _, opt := range opts { @@ -135,17 +106,10 @@ func NewClient(cfg ClientConfig, opts ...ClientOption) (*Client, error) { // DialContext opens a CONNECT tunnel to destination. destination becomes the // request authority, so it must include an explicit port. -func (c *Client) DialContext(ctx context.Context, destination string, metadata EgressMetadata) (net.Conn, error) { +func (c *Client) DialContext(ctx context.Context, destination string) (net.Conn, error) { if err := validateDestination(destination); err != nil { return nil, err } - if !resources.IsValidResourceName(metadata.Atespace) || !resources.IsValidResourceName(metadata.ActorName) { - return nil, fmt.Errorf("atunnel: invalid actor identity %q/%q", metadata.Atespace, metadata.ActorName) - } - if metadata.ActorVersion < 1 { - return nil, fmt.Errorf("atunnel: actor version must be positive") - } - rawConn, err := c.dialContext(ctx, "tcp", c.gatewayAddress) if err != nil { return nil, fmt.Errorf("atunnel: connecting to egress gateway: %w", err) @@ -160,14 +124,6 @@ func (c *Client) DialContext(ctx context.Context, destination string, metadata E Method: http.MethodConnect, URL: &url.URL{Host: destination}, Host: destination, - Header: http.Header{ - ActorAtespaceHeader: []string{metadata.Atespace}, - ActorNameHeader: []string{metadata.ActorName}, - ActorVersionHeader: []string{strconv.FormatInt(metadata.ActorVersion, 10)}, - }, - } - if metadata.BearerToken != "" { - req.Header.Set("Authorization", "Bearer "+metadata.BearerToken) } if err := req.Write(tlsConn); err != nil { _ = tlsConn.Close() diff --git a/internal/atunnel/client_test.go b/internal/atunnel/client_test.go index 9d74884d4..4f9dc78e4 100644 --- a/internal/atunnel/client_test.go +++ b/internal/atunnel/client_test.go @@ -55,12 +55,7 @@ func TestClientDialContext(t *testing.T) { }) client := newTestClient(t, ca, WithDialer(dialFixedAddress(gatewayAddress))) - conn, err := client.DialContext(context.Background(), "192.0.2.10:443", EgressMetadata{ - Atespace: "team-a", - ActorName: "actor-1", - ActorVersion: 7, - BearerToken: "actor-token", - }) + conn, err := client.DialContext(context.Background(), "192.0.2.10:443") if err != nil { t.Fatal(err) } @@ -73,17 +68,13 @@ func TestClientDialContext(t *testing.T) { if gotRequest.Host != "192.0.2.10:443" { t.Errorf("authority = %q, want 192.0.2.10:443", gotRequest.Host) } - if got := gotRequest.Header.Get(ActorAtespaceHeader); got != "team-a" { - t.Errorf("%s = %q, want team-a", ActorAtespaceHeader, got) - } - if got := gotRequest.Header.Get(ActorNameHeader); got != "actor-1" { - t.Errorf("%s = %q, want actor-1", ActorNameHeader, got) - } - if got := gotRequest.Header.Get(ActorVersionHeader); got != "7" { - t.Errorf("%s = %q, want 7", ActorVersionHeader, got) + for name := range gotRequest.Header { + if strings.HasPrefix(strings.ToLower(name), "x-ate-") { + t.Errorf("legacy identity header %q was sent", name) + } } - if got := gotRequest.Header.Get("Authorization"); got != "Bearer actor-token" { - t.Errorf("Authorization = %q, want Bearer actor-token", got) + if got := gotRequest.Header.Get("Authorization"); got != "" { + t.Errorf("Authorization = %q, want empty", got) } buffered := make([]byte, len("hello")) @@ -106,11 +97,7 @@ func TestClientDialContextRejected(t *testing.T) { }) client := newTestClient(t, ca, WithDialer(dialFixedAddress(gatewayAddress))) - _, err := client.DialContext(context.Background(), "192.0.2.10:443", EgressMetadata{ - Atespace: "team-a", - ActorName: "actor-1", - ActorVersion: 7, - }) + _, err := client.DialContext(context.Background(), "192.0.2.10:443") if err == nil || !strings.Contains(err.Error(), "denied by policy") { t.Fatalf("DialContext error = %v, want policy rejection", err) } @@ -122,37 +109,19 @@ func TestClientDialContextValidatesInput(t *testing.T) { tests := []struct { name string destination string - metadata EgressMetadata }{ { name: "destination has no port", destination: "192.0.2.10", - metadata: EgressMetadata{Atespace: "team-a", ActorName: "actor-1", ActorVersion: 7}, }, { name: "destination is a hostname", destination: "example.com:443", - metadata: EgressMetadata{Atespace: "team-a", ActorName: "actor-1", ActorVersion: 7}, - }, - { - name: "invalid atespace", - destination: "192.0.2.10:443", - metadata: EgressMetadata{Atespace: "TEAM A", ActorName: "actor-1", ActorVersion: 7}, - }, - { - name: "invalid actor", - destination: "192.0.2.10:443", - metadata: EgressMetadata{Atespace: "team-a", ActorName: "actor/1", ActorVersion: 7}, - }, - { - name: "invalid actor version", - destination: "192.0.2.10:443", - metadata: EgressMetadata{Atespace: "team-a", ActorName: "actor-1"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if _, err := client.DialContext(context.Background(), tt.destination, tt.metadata); err == nil { + if _, err := client.DialContext(context.Background(), tt.destination); err == nil { t.Fatal("DialContext unexpectedly succeeded") } }) @@ -170,19 +139,18 @@ func dialFixedAddress(address string) DialFunc { func newTestClient(t *testing.T, ca *testCA, opts ...ClientOption) *Client { t.Helper() dir := t.TempDir() - bundlePath := filepath.Join(dir, "client.pem") trustPath := filepath.Join(dir, "trust.pem") - writeCredentialBundle(t, bundlePath, ca.issue(t, - "spiffe://cluster.local/ns/ate-demo/sa/ateom", + certificate := ca.issue(t, + "spiffe://substrate-actor.local/atespace/team/actor/actor", []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, - )) + ) if err := os.WriteFile(trustPath, ca.certPEM, 0o600); err != nil { t.Fatal(err) } client, err := NewClient(ClientConfig{ GatewayAddress: "127.0.0.1:1", ServerName: "egress.test", - CredentialBundlePath: bundlePath, + GetClientCertificate: func(*tls.CertificateRequestInfo) (*tls.Certificate, error) { return &certificate, nil }, TrustBundlePath: trustPath, }, opts...) if err != nil { diff --git a/internal/atunnel/credential.go b/internal/atunnel/credential.go new file mode 100644 index 000000000..1d85ad4bb --- /dev/null +++ b/internal/atunnel/credential.go @@ -0,0 +1,184 @@ +// 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 atunnel + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "fmt" + "net" + "net/url" + "os" + "path" + "slices" + "sync" + "time" + + "github.com/agent-substrate/substrate/internal/credbundle" + "github.com/agent-substrate/substrate/internal/proto/ateletpb" + "github.com/agent-substrate/substrate/internal/substratex509" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" +) + +// BrokerCertificateSource owns atunnel's actor private key and obtains the +// matching short-lived certificate from the node-local atelet. +type BrokerCertificateSource struct { + socketPath string + tlsConfig *tls.Config + privateKey *ecdsa.PrivateKey + + mu sync.RWMutex + certificate *tls.Certificate +} + +// BrokerConfig configures the node-local atelet credential broker client. +type BrokerConfig struct { + // SocketPath is the atelet-owned Unix socket shared with this worker. + SocketPath string + // CredentialBundlePath is the worker Pod certificate and private key used + // only to authenticate atunnel to atelet. + CredentialBundlePath string + // TrustBundlePath verifies atelet's Pod certificate. + TrustBundlePath string +} + +// NewBrokerCertificateSource creates one actor key for this activation. The key +// is reused across renewals and never leaves atunnel; only its CSR crosses the +// credential broker socket. +func NewBrokerCertificateSource(cfg BrokerConfig) (*BrokerCertificateSource, error) { + if cfg.SocketPath == "" || cfg.CredentialBundlePath == "" || cfg.TrustBundlePath == "" { + return nil, fmt.Errorf("atunnel: credential broker socket, credentials, and trust bundle are required") + } + localCert, err := credbundle.Parse(cfg.CredentialBundlePath) + if err != nil { + return nil, fmt.Errorf("atunnel: load worker identity: %w", err) + } + localIdentity, err := substratex509.PodIdentityFromCertificate(localCert.Leaf) + if err != nil || localIdentity == nil { + return nil, fmt.Errorf("atunnel: worker certificate has no valid Pod identity") + } + trustPEM, err := os.ReadFile(cfg.TrustBundlePath) + if err != nil { + return nil, fmt.Errorf("atunnel: read credential broker trust bundle: %w", err) + } + roots := x509.NewCertPool() + if !roots.AppendCertsFromPEM(trustPEM) { + return nil, fmt.Errorf("atunnel: credential broker trust bundle contains no certificates") + } + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, fmt.Errorf("atunnel: generate actor private key: %w", err) + } + expectedURI := (&url.URL{Scheme: "spiffe", Host: "cluster.local", Path: path.Join("ns", "ate-system", "sa", "atelet")}).String() + tlsConfig := &tls.Config{ + MinVersion: tls.VersionTLS13, + InsecureSkipVerify: true, // Verification below supports SPIFFE Pod certificates without a DNS name. + GetClientCertificate: credbundle.ClientLoader(cfg.CredentialBundlePath), + VerifyConnection: func(state tls.ConnectionState) error { + // Verify both the normal server-auth chain and the identities that DNS + // verification cannot express: atelet's SPIFFE ID and exact node + // incarnation. This is why InsecureSkipVerify is set above. + if len(state.PeerCertificates) == 0 { + return fmt.Errorf("credential broker certificate is required") + } + intermediates := x509.NewCertPool() + for _, cert := range state.PeerCertificates[1:] { + intermediates.AddCert(cert) + } + if _, err := state.PeerCertificates[0].Verify(x509.VerifyOptions{Roots: roots, Intermediates: intermediates, KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}}); err != nil { + return fmt.Errorf("verify credential broker certificate: %w", err) + } + leaf := state.PeerCertificates[0] + if len(leaf.URIs) != 1 || leaf.URIs[0].String() != expectedURI { + return fmt.Errorf("credential broker is not atelet") + } + identity, err := substratex509.PodIdentityFromCertificate(leaf) + if err != nil || identity == nil || identity.NodeName != localIdentity.NodeName || identity.NodeUID != localIdentity.NodeUID { + return fmt.Errorf("credential broker is not on worker node %q (%s)", localIdentity.NodeName, localIdentity.NodeUID) + } + return nil + }, + } + + return &BrokerCertificateSource{socketPath: cfg.SocketPath, tlsConfig: tlsConfig, privateKey: privateKey}, nil +} + +// Mint requests and installs a fresh certificate for the source's existing +// actor key. It returns the new expiry for renewal scheduling. +func (s *BrokerCertificateSource) Mint(ctx context.Context) (time.Time, error) { + csr, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{}, s.privateKey) + if err != nil { + return time.Time{}, fmt.Errorf("atunnel: create actor CSR: %w", err) + } + // A fresh connection picks up rotated worker credentials and forces atelet's + // current certificate and node identity to be verified for every mint. + conn, err := grpc.NewClient("passthrough:///credential-broker", + grpc.WithTransportCredentials(credentials.NewTLS(s.tlsConfig)), + grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, "unix", s.socketPath) + }), + ) + if err != nil { + return time.Time{}, err + } + defer conn.Close() + resp, err := ateletpb.NewCredentialBrokerClient(conn).MintActorCertificate(ctx, &ateletpb.MintActorCertificateRequest{CertificateSigningRequest: csr}) + if err != nil { + return time.Time{}, fmt.Errorf("atunnel: mint actor certificate: %w", err) + } + chain := resp.GetActorCertificates() + if len(chain) == 0 { + return time.Time{}, fmt.Errorf("atunnel: credential broker returned no actor certificate") + } + leaf, err := x509.ParseCertificate(chain[0]) + if err != nil { + return time.Time{}, fmt.Errorf("atunnel: parse actor certificate: %w", err) + } + if !s.privateKey.PublicKey.Equal(leaf.PublicKey) { + return time.Time{}, fmt.Errorf("atunnel: actor certificate does not match private key") + } + now := time.Now() + if now.Before(leaf.NotBefore) || !leaf.NotAfter.After(now) { + return time.Time{}, fmt.Errorf("atunnel: credential broker returned an invalid actor certificate lifetime") + } + if !slices.Contains(leaf.ExtKeyUsage, x509.ExtKeyUsageClientAuth) { + return time.Time{}, fmt.Errorf("atunnel: actor certificate cannot authenticate a TLS client") + } + identity, err := substratex509.ActorIdentityFromCertificate(leaf) + if err != nil || identity == nil { + return time.Time{}, fmt.Errorf("atunnel: actor certificate has no valid actor identity") + } + cert := &tls.Certificate{Certificate: chain, PrivateKey: s.privateKey, Leaf: leaf} + s.mu.Lock() + s.certificate = cert + s.mu.Unlock() + return leaf.NotAfter, nil +} + +// GetClientCertificate supplies the current actor certificate to the egress +// gateway TLS handshake and refuses to use it after expiry. +func (s *BrokerCertificateSource) GetClientCertificate(*tls.CertificateRequestInfo) (*tls.Certificate, error) { + s.mu.RLock() + defer s.mu.RUnlock() + if s.certificate == nil || !s.certificate.Leaf.NotAfter.After(time.Now()) { + return nil, fmt.Errorf("atunnel: no valid actor certificate") + } + return s.certificate, nil +} diff --git a/internal/atunnel/credential_test.go b/internal/atunnel/credential_test.go new file mode 100644 index 000000000..1cb327ed3 --- /dev/null +++ b/internal/atunnel/credential_test.go @@ -0,0 +1,208 @@ +// 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 atunnel + +import ( + "context" + "crypto/ecdsa" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "math/big" + "net" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/agent-substrate/substrate/internal/proto/ateletpb" + "github.com/agent-substrate/substrate/internal/substratex509" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/status" +) + +func TestBrokerCertificateSourceMintsAndReusesKey(t *testing.T) { + source, broker := newTestBrokerCertificateSource(t, testAteletIdentity("node-a"), time.Hour) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + for range 2 { + if _, err := source.Mint(ctx); err != nil { + t.Fatal(err) + } + } + first, second := <-broker.publicKeys, <-broker.publicKeys + if string(first) != string(second) { + t.Fatal("renewal replaced the actor private key") + } + cert, err := source.GetClientCertificate(nil) + if err != nil { + t.Fatal(err) + } + identity, err := substratex509.ActorIdentityFromCertificate(cert.Leaf) + if err != nil { + t.Fatal(err) + } + if identity == nil || identity.ActorUid != "actor-uid" { + t.Fatalf("actor identity = %+v", identity) + } +} + +func TestBrokerCertificateSourceRejectsAteletOnDifferentNode(t *testing.T) { + source, _ := newTestBrokerCertificateSource(t, testAteletIdentity("node-b"), time.Hour) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if _, err := source.Mint(ctx); err == nil || !strings.Contains(err.Error(), "not on worker node") { + t.Fatalf("Mint() error = %v, want node identity rejection", err) + } +} + +func TestBrokerCertificateSourceRejectsExpiredCertificate(t *testing.T) { + source, _ := newTestBrokerCertificateSource(t, testAteletIdentity("node-a"), -time.Minute) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if _, err := source.Mint(ctx); err == nil || !strings.Contains(err.Error(), "invalid actor certificate lifetime") { + t.Fatalf("Mint() error = %v, want expired certificate rejection", err) + } +} + +type credentialBrokerStub struct { + ateletpb.UnimplementedCredentialBrokerServer + ca *testCA + lifetime time.Duration + publicKeys chan []byte +} + +func (s *credentialBrokerStub) MintActorCertificate(_ context.Context, req *ateletpb.MintActorCertificateRequest) (*ateletpb.MintActorCertificateResponse, error) { + csr, err := x509.ParseCertificateRequest(req.GetCertificateSigningRequest()) + if err != nil || csr.CheckSignature() != nil { + return nil, status.Error(codes.InvalidArgument, "invalid CSR") + } + now := time.Now() + template := &x509.Certificate{ + SerialNumber: big.NewInt(now.UnixNano()), + Subject: pkix.Name{CommonName: "actor"}, + NotBefore: now.Add(-time.Minute), + NotAfter: now.Add(s.lifetime), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + } + if err := substratex509.AddActorIdentityToCertificate(&substratex509.ActorIdentity{Atespace: "team", ActorName: "actor", ActorUid: "actor-uid"}, template); err != nil { + return nil, status.Error(codes.Internal, err.Error()) + } + der, err := x509.CreateCertificate(rand.Reader, template, s.ca.cert, csr.PublicKey, s.ca.key) + if err != nil { + return nil, status.Error(codes.Internal, err.Error()) + } + s.publicKeys <- csr.RawSubjectPublicKeyInfo + return &ateletpb.MintActorCertificateResponse{ActorCertificates: [][]byte{der}}, nil +} + +func newTestBrokerCertificateSource(t *testing.T, ateletIdentity *substratex509.PodIdentity, lifetime time.Duration) (*BrokerCertificateSource, *credentialBrokerStub) { + t.Helper() + ca := newTestCA(t) + workerCert := issueTestPodCertificate(t, ca, &substratex509.PodIdentity{ + Namespace: "ate-demo", + ServiceAccountName: "ateom", + ServiceAccountUID: "ateom-sa-uid", + PodName: "worker", + PodUID: "worker-uid", + NodeName: "node-a", + NodeUID: "node-uid", + }, "spiffe://cluster.local/ns/ate-demo/sa/ateom", []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}) + ateletCert := issueTestPodCertificate(t, ca, ateletIdentity, + "spiffe://cluster.local/ns/ate-system/sa/atelet", []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}) + + dir := t.TempDir() + credentialPath := filepath.Join(dir, "worker.pem") + trustPath := filepath.Join(dir, "trust.pem") + writeCredentialBundle(t, credentialPath, workerCert) + if err := os.WriteFile(trustPath, ca.certPEM, 0o600); err != nil { + t.Fatal(err) + } + + clientCAs := x509.NewCertPool() + clientCAs.AppendCertsFromPEM(ca.certPEM) + socketPath := filepath.Join(dir, "credential-broker.sock") + listener, err := net.Listen("unix", socketPath) + if err != nil { + t.Fatal(err) + } + server := grpc.NewServer(grpc.Creds(credentials.NewTLS(&tls.Config{ + MinVersion: tls.VersionTLS13, + Certificates: []tls.Certificate{ateletCert}, + ClientAuth: tls.RequireAndVerifyClientCert, + ClientCAs: clientCAs, + }))) + broker := &credentialBrokerStub{ca: ca, lifetime: lifetime, publicKeys: make(chan []byte, 2)} + ateletpb.RegisterCredentialBrokerServer(server, broker) + go func() { _ = server.Serve(listener) }() + t.Cleanup(func() { + server.Stop() + _ = listener.Close() + }) + + source, err := NewBrokerCertificateSource(BrokerConfig{ + SocketPath: socketPath, + CredentialBundlePath: credentialPath, + TrustBundlePath: trustPath, + }) + if err != nil { + t.Fatal(err) + } + return source, broker +} + +func testAteletIdentity(nodeName string) *substratex509.PodIdentity { + return &substratex509.PodIdentity{ + Namespace: "ate-system", + ServiceAccountName: "atelet", + ServiceAccountUID: "atelet-sa-uid", + PodName: "atelet", + PodUID: "atelet-uid", + NodeName: nodeName, + NodeUID: "node-uid", + } +} + +func issueTestPodCertificate(t *testing.T, ca *testCA, identity *substratex509.PodIdentity, spiffeID string, usages []x509.ExtKeyUsage) tls.Certificate { + t.Helper() + cert := ca.issue(t, spiffeID, usages) + template, err := x509.ParseCertificate(cert.Certificate[0]) + if err != nil { + t.Fatal(err) + } + if err := substratex509.AddPodIdentityToCertificate(identity, template); err != nil { + t.Fatal(err) + } + key, ok := cert.PrivateKey.(*ecdsa.PrivateKey) + if !ok { + t.Fatalf("private key has type %T", cert.PrivateKey) + } + der, err := x509.CreateCertificate(rand.Reader, template, ca.cert, &key.PublicKey, ca.key) + if err != nil { + t.Fatal(err) + } + cert.Certificate[0] = der + cert.Leaf, err = x509.ParseCertificate(der) + if err != nil { + t.Fatal(err) + } + return cert +} diff --git a/internal/atunnel/egress.go b/internal/atunnel/egress.go index 5b1d63b08..76ae00993 100644 --- a/internal/atunnel/egress.go +++ b/internal/atunnel/egress.go @@ -22,13 +22,16 @@ import ( "log/slog" "net" "sync" - - "github.com/agent-substrate/substrate/internal/resources" + "time" ) -// EgressDialer opens an authenticated tunnel to an original destination. -type EgressDialer interface { - DialContext(context.Context, string, EgressMetadata) (net.Conn, error) +// egressDialer opens an authenticated tunnel to an original destination. +type egressDialer interface { + DialContext(context.Context, string) (net.Conn, error) +} + +type actorCertificateSource interface { + Mint(context.Context) (time.Time, error) } // OriginalDestination returns the address that a transparently intercepted @@ -46,11 +49,15 @@ type Egress struct { } type egressActivation struct { - metadata EgressMetadata - dialer EgressDialer - ctx context.Context - cancel context.CancelFunc - wg sync.WaitGroup + dialer egressDialer + certificateSource actorCertificateSource + expiresAt time.Time + + // ctx scopes certificate renewal and every tunnel opened by this activation. wg + // lets Deactivate wait until both renewal and tunnel forwarding have exited. + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup } // NewEgress creates an activation-aware egress proxy. @@ -88,38 +95,91 @@ func (e *Egress) Serve(ctx context.Context, listener net.Listener) error { } } -// Activate allows egress for one actor. There can be only one active actor per -// worker. bearerToken may be empty until actor JWT issuance is available. -func (e *Egress) Activate(dialer EgressDialer, atespace, actorName string, actorVersion int64, bearerToken string) error { +// Activate allows egress with a previously obtained actor certificate and +// renews it until deactivation. +func (e *Egress) Activate(dialer egressDialer, certificateSource actorCertificateSource, expiresAt time.Time) error { if dialer == nil { return fmt.Errorf("atunnel: egress dialer is required") } - if !resources.IsValidResourceName(atespace) || !resources.IsValidResourceName(actorName) { - return fmt.Errorf("atunnel: invalid actor identity %q/%q", atespace, actorName) + if certificateSource == nil { + return fmt.Errorf("atunnel: actor certificate source is required") } - if actorVersion < 1 { - return fmt.Errorf("atunnel: actor version must be positive") + if !expiresAt.After(time.Now()) { + return fmt.Errorf("atunnel: valid actor certificate is required") } e.mu.Lock() defer e.mu.Unlock() if e.active != nil { - return fmt.Errorf("atunnel: actor %s/%s already has active egress", e.active.metadata.Atespace, e.active.metadata.ActorName) - } - ctx, cancel := context.WithCancel(context.Background()) - e.active = &egressActivation{ - metadata: EgressMetadata{ - Atespace: atespace, - ActorName: actorName, - ActorVersion: actorVersion, - BearerToken: bearerToken, - }, - dialer: dialer, - ctx: ctx, - cancel: cancel, + return fmt.Errorf("atunnel: actor already has active egress") } + activationCtx, cancel := context.WithCancel(context.Background()) + active := &egressActivation{ + dialer: dialer, + certificateSource: certificateSource, + expiresAt: expiresAt, + ctx: activationCtx, + cancel: cancel, + } + e.active = active + active.wg.Add(1) + go e.renew(active, expiresAt) return nil } +func (e *Egress) renew(active *egressActivation, expiresAt time.Time) { + defer active.wg.Done() + // Schedule from the credential's remaining lifetime: renew at 90%, then + // retry failures only while the currently installed certificate is valid. + delay := renewAfter(expiresAt) + for waitForRenewal(active.ctx, delay) { + nextExpiry, err := active.certificateSource.Mint(active.ctx) + if err != nil { + delay = retryAfter(expiresAt) + continue + } + if !nextExpiry.After(time.Now()) { + delay = retryAfter(expiresAt) + continue + } + e.mu.Lock() + // Check cancellation under the same lock as Deactivate. Whichever wins + // the lock last either installs a live expiry or leaves the activation empty; + // renewal can never restore a credential after deactivation cleared it. + if active.ctx.Err() != nil { + e.mu.Unlock() + return + } + active.expiresAt = nextExpiry + e.mu.Unlock() + expiresAt = nextExpiry + delay = renewAfter(expiresAt) + } +} + +func renewAfter(expiresAt time.Time) time.Duration { + remaining := time.Until(expiresAt) + return remaining - remaining/10 +} + +func retryAfter(expiresAt time.Time) time.Duration { + remaining := time.Until(expiresAt) + return min(30*time.Second, max(time.Second, remaining/10), remaining) +} + +func waitForRenewal(ctx context.Context, delay time.Duration) bool { + if delay <= 0 { + return false + } + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return false + case <-timer.C: + return true + } +} + // Deactivate rejects new egress, closes active streams, and waits for their // forwarding goroutines to exit. func (e *Egress) Deactivate(ctx context.Context) error { @@ -127,6 +187,7 @@ func (e *Egress) Deactivate(ctx context.Context) error { active := e.active e.active = nil if active != nil { + active.expiresAt = time.Time{} active.cancel() } e.mu.Unlock() @@ -155,6 +216,13 @@ func (e *Egress) handle(downstream net.Conn) { _ = downstream.Close() return } + if time.Now().Compare(active.expiresAt) >= 0 { + // Expiry blocks only new tunnels. Connections admitted with a valid + // certificate have completed mTLS and are allowed to drain normally. + e.mu.Unlock() + _ = downstream.Close() + return + } active.wg.Add(1) e.mu.Unlock() @@ -167,7 +235,7 @@ func (e *Egress) handle(downstream net.Conn) { slog.WarnContext(active.ctx, "atunnel failed to resolve original egress destination", slog.Any("err", err)) return } - upstream, err := active.dialer.DialContext(active.ctx, destination, active.metadata) + upstream, err := active.dialer.DialContext(active.ctx, destination) if err != nil { slog.WarnContext(active.ctx, "atunnel failed to open egress tunnel", slog.String("destination", destination), slog.Any("err", err)) return diff --git a/internal/atunnel/egress_test.go b/internal/atunnel/egress_test.go index 17828fbd5..7c492f473 100644 --- a/internal/atunnel/egress_test.go +++ b/internal/atunnel/egress_test.go @@ -16,30 +16,214 @@ package atunnel import ( "context" + "crypto/tls" + "errors" "io" "net" + "net/http" + "strings" + "sync/atomic" "testing" "time" ) -func TestEgressForwardsActiveActor(t *testing.T) { - dialed := make(chan egressDial, 1) - upstreamProxy, upstreamGateway := net.Pipe() - t.Cleanup(func() { - _ = upstreamProxy.Close() - _ = upstreamGateway.Close() +func TestEgressActivationFailsClosed(t *testing.T) { + egress, err := NewEgress(func(net.Conn) (string, error) { return "", nil }) + if err != nil { + t.Fatal(err) + } + dialer := egressDialerFunc(func(context.Context, string) (net.Conn, error) { + t.Fatal("dialed after failed activation") + return nil, nil }) - dialer := egressDialerFunc(func(_ context.Context, destination string, metadata EgressMetadata) (net.Conn, error) { - dialed <- egressDial{destination: destination, metadata: metadata} + if err := egress.Activate(dialer, fakeActorCertificateSource{err: errors.New("renewal failed")}, time.Time{}); err == nil { + t.Fatal("Activate() succeeded") + } + actor, proxy := net.Pipe() + defer actor.Close() + if err := actor.SetReadDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatal(err) + } + egress.handle(proxy) + if _, err := actor.Read(make([]byte, 1)); err == nil { + t.Fatal("failed activation admitted egress") + } +} + +func TestEgressExpiryRejectsNewButPreservesEstablished(t *testing.T) { + upstreamProxy, upstreamGateway := net.Pipe() + defer upstreamGateway.Close() + var dials atomic.Int32 + var mints atomic.Int32 + dialer := egressDialerFunc(func(context.Context, string) (net.Conn, error) { + dials.Add(1) return upstreamProxy, nil }) + egress, err := NewEgress(func(net.Conn) (string, error) { return "192.0.2.10:443", nil }) + if err != nil { + t.Fatal(err) + } + if err := egress.Activate(dialer, fakeActorCertificateSource{err: errors.New("renewal failed"), calls: &mints}, time.Now().Add(50*time.Millisecond)); err != nil { + t.Fatal(err) + } + actor, proxy := net.Pipe() + defer actor.Close() + egress.handle(proxy) + deadline := time.Now().Add(time.Second) + for dials.Load() == 0 && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + if dials.Load() != 1 { + t.Fatalf("dials = %d, want 1", dials.Load()) + } + time.Sleep(100 * time.Millisecond) + go func() { _, _ = actor.Write([]byte("still-open")) }() + buf := make([]byte, len("still-open")) + if _, err := io.ReadFull(upstreamGateway, buf); err != nil { + t.Fatalf("established tunnel closed after certificate expiry: %v", err) + } + + newActor, newProxy := net.Pipe() + defer newActor.Close() + if err := newActor.SetReadDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatal(err) + } + egress.handle(newProxy) + if _, err := newActor.Read(make([]byte, 1)); err == nil { + t.Fatal("new tunnel admitted after certificate expiry") + } + if dials.Load() != 1 { + t.Fatalf("dials = %d after expiry, want 1", dials.Load()) + } + if got := mints.Load(); got > 3 { + t.Fatalf("mint attempts = %d, retry loop spun near expiry", got) + } + _ = egress.Deactivate(context.Background()) +} + +func TestEgressRenewsBeforeExpiry(t *testing.T) { + var mints atomic.Int32 + renewed := make(chan struct{}, 1) + renewedExpiry := time.Now().Add(time.Hour) + source := fakeActorCertificateSource{ + expiresAt: renewedExpiry, + calls: &mints, + called: renewed, + } + upstream, gateway := net.Pipe() + defer gateway.Close() + dialer := egressDialerFunc(func(context.Context, string) (net.Conn, error) { + return upstream, nil + }) + egress, err := NewEgress(func(net.Conn) (string, error) { return "192.0.2.10:443", nil }) + if err != nil { + t.Fatal(err) + } + if err := egress.Activate(dialer, source, time.Now().Add(80*time.Millisecond)); err != nil { + t.Fatal(err) + } + select { + case <-renewed: + case <-time.After(time.Second): + t.Fatal("certificate was not renewed") + } + deadline := time.Now().Add(time.Second) + for { + egress.mu.Lock() + expiresAt := egress.active.expiresAt + egress.mu.Unlock() + if expiresAt.Equal(renewedExpiry) { + break + } + if time.Now().After(deadline) { + t.Fatal("renewed certificate expiry was not installed") + } + time.Sleep(time.Millisecond) + } + actor, proxy := net.Pipe() + defer actor.Close() + egress.handle(proxy) + _ = egress.Deactivate(context.Background()) +} + +func TestEgressDeactivationDropsConcurrentRenewal(t *testing.T) { + started := make(chan struct{}, 1) + release := make(chan struct{}) + egress, err := NewEgress(func(net.Conn) (string, error) { return "", nil }) + if err != nil { + t.Fatal(err) + } + dialer := egressDialerFunc(func(context.Context, string) (net.Conn, error) { return nil, nil }) + if err := egress.Activate(dialer, fakeActorCertificateSource{ + expiresAt: time.Now().Add(time.Hour), + called: started, + release: release, + }, time.Now().Add(50*time.Millisecond)); err != nil { + t.Fatal(err) + } + egress.mu.Lock() + active := egress.active + egress.mu.Unlock() + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("certificate renewal did not start") + } + done := make(chan error, 1) + go func() { done <- egress.Deactivate(context.Background()) }() + <-active.ctx.Done() + close(release) + if err := <-done; err != nil { + t.Fatal(err) + } + if !active.expiresAt.IsZero() { + t.Fatalf("deactivated certificate expiry = %v, want zero", active.expiresAt) + } +} + +func TestEgressEndToEnd(t *testing.T) { + ca := newTestCA(t) + requests := make(chan *http.Request, 1) + gatewayDone := make(chan struct{}) + gatewayAddress := serveTestConnectGateway(t, ca, func(conn net.Conn, req *http.Request) { + defer close(gatewayDone) + requests <- req + + tlsConn, ok := conn.(*tls.Conn) + if !ok { + t.Errorf("gateway connection has type %T, want *tls.Conn", conn) + } else { + peer := tlsConn.ConnectionState().PeerCertificates[0] + if len(peer.URIs) != 1 || peer.URIs[0].String() != "spiffe://substrate-actor.local/atespace/team/actor/actor" { + t.Errorf("client identity = %v, want actor SPIFFE ID", peer.URIs) + } + } + + if _, err := io.WriteString(conn, "HTTP/1.1 200 Connection Established\r\n\r\n"); err != nil { + t.Errorf("writing CONNECT response: %v", err) + return + } + payload := make([]byte, len("from actor")) + if _, err := io.ReadFull(conn, payload); err != nil { + t.Errorf("reading actor payload: %v", err) + return + } + if string(payload) != "from actor" { + t.Errorf("gateway payload = %q, want %q", payload, "from actor") + } + if _, err := io.WriteString(conn, "from gateway"); err != nil { + t.Errorf("writing gateway payload: %v", err) + } + }) + client := newTestClient(t, ca, WithDialer(dialFixedAddress(gatewayAddress))) + egress, err := NewEgress(func(net.Conn) (string, error) { return "192.0.2.10:443", nil }) if err != nil { t.Fatal(err) } - if err := egress.Activate(dialer, "team-a", "actor-1", 7, "actor-token"); err != nil { + if err := egress.Activate(client, fakeActorCertificateSource{expiresAt: time.Now().Add(time.Hour)}, time.Now().Add(time.Hour)); err != nil { t.Fatal(err) } @@ -50,33 +234,33 @@ func TestEgressForwardsActiveActor(t *testing.T) { }) egress.handle(downstreamProxy) - gotDial := <-dialed - if gotDial.destination != "192.0.2.10:443" { - t.Errorf("destination = %q, want 192.0.2.10:443", gotDial.destination) + req := <-requests + if req.Method != http.MethodConnect || req.Host != "192.0.2.10:443" { + t.Errorf("request = %s %s, want CONNECT 192.0.2.10:443", req.Method, req.Host) + } + if got := req.Header.Get("Authorization"); got != "" { + t.Errorf("Authorization = %q, want empty", got) } - if gotDial.metadata != (EgressMetadata{Atespace: "team-a", ActorName: "actor-1", ActorVersion: 7, BearerToken: "actor-token"}) { - t.Errorf("metadata = %+v", gotDial.metadata) + for name := range req.Header { + if strings.HasPrefix(strings.ToLower(name), "x-ate-") { + t.Errorf("legacy identity header %q was sent", name) + } } - actorPayload := []byte("from actor") - go func() { _, _ = downstreamActor.Write(actorPayload) }() - gotAtGateway := make([]byte, len(actorPayload)) - if _, err := io.ReadFull(upstreamGateway, gotAtGateway); err != nil { + if err := downstreamActor.SetDeadline(time.Now().Add(5 * time.Second)); err != nil { t.Fatal(err) } - if string(gotAtGateway) != string(actorPayload) { - t.Errorf("gateway payload = %q, want %q", gotAtGateway, actorPayload) + if _, err := io.WriteString(downstreamActor, "from actor"); err != nil { + t.Fatal(err) } - - gatewayPayload := []byte("from gateway") - go func() { _, _ = upstreamGateway.Write(gatewayPayload) }() - gotAtActor := make([]byte, len(gatewayPayload)) + gotAtActor := make([]byte, len("from gateway")) if _, err := io.ReadFull(downstreamActor, gotAtActor); err != nil { t.Fatal(err) } - if string(gotAtActor) != string(gatewayPayload) { - t.Errorf("actor payload = %q, want %q", gotAtActor, gatewayPayload) + if string(gotAtActor) != "from gateway" { + t.Errorf("actor payload = %q, want %q", gotAtActor, "from gateway") } + <-gatewayDone if err := egress.Deactivate(context.Background()); err != nil { t.Fatal(err) @@ -102,13 +286,32 @@ func TestEgressRejectsInactiveConnection(t *testing.T) { } } -type egressDial struct { - destination string - metadata EgressMetadata +type egressDialerFunc func(context.Context, string) (net.Conn, error) + +func (f egressDialerFunc) DialContext(ctx context.Context, destination string) (net.Conn, error) { + return f(ctx, destination) } -type egressDialerFunc func(context.Context, string, EgressMetadata) (net.Conn, error) +type fakeActorCertificateSource struct { + expiresAt time.Time + err error + calls *atomic.Int32 + called chan<- struct{} + release <-chan struct{} +} -func (f egressDialerFunc) DialContext(ctx context.Context, destination string, metadata EgressMetadata) (net.Conn, error) { - return f(ctx, destination, metadata) +func (s fakeActorCertificateSource) Mint(context.Context) (time.Time, error) { + if s.calls != nil { + s.calls.Add(1) + } + if s.called != nil { + select { + case s.called <- struct{}{}: + default: + } + } + if s.release != nil { + <-s.release + } + return s.expiresAt, s.err } diff --git a/internal/atunnel/server.go b/internal/atunnel/ingress.go similarity index 100% rename from internal/atunnel/server.go rename to internal/atunnel/ingress.go diff --git a/internal/atunnel/server_test.go b/internal/atunnel/ingress_test.go similarity index 100% rename from internal/atunnel/server_test.go rename to internal/atunnel/ingress_test.go diff --git a/internal/proto/ateletpb/atelet.pb.go b/internal/proto/ateletpb/atelet.pb.go index 021fc8dad..2fc73a059 100644 --- a/internal/proto/ateletpb/atelet.pb.go +++ b/internal/proto/ateletpb/atelet.pb.go @@ -200,6 +200,97 @@ func (SnapshotScope) EnumDescriptor() ([]byte, []int) { return file_atelet_proto_rawDescGZIP(), []int{2} } +type MintActorCertificateRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // DER-encoded PKCS #10 certificate signing request. Atunnel retains the + // corresponding private key. + CertificateSigningRequest []byte `protobuf:"bytes,1,opt,name=certificate_signing_request,json=certificateSigningRequest,proto3" json:"certificate_signing_request,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MintActorCertificateRequest) Reset() { + *x = MintActorCertificateRequest{} + mi := &file_atelet_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MintActorCertificateRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MintActorCertificateRequest) ProtoMessage() {} + +func (x *MintActorCertificateRequest) ProtoReflect() protoreflect.Message { + mi := &file_atelet_proto_msgTypes[0] + 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 MintActorCertificateRequest.ProtoReflect.Descriptor instead. +func (*MintActorCertificateRequest) Descriptor() ([]byte, []int) { + return file_atelet_proto_rawDescGZIP(), []int{0} +} + +func (x *MintActorCertificateRequest) GetCertificateSigningRequest() []byte { + if x != nil { + return x.CertificateSigningRequest + } + return nil +} + +type MintActorCertificateResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // DER-encoded leaf followed by any intermediate certificates. + ActorCertificates [][]byte `protobuf:"bytes,1,rep,name=actor_certificates,json=actorCertificates,proto3" json:"actor_certificates,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MintActorCertificateResponse) Reset() { + *x = MintActorCertificateResponse{} + mi := &file_atelet_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MintActorCertificateResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MintActorCertificateResponse) ProtoMessage() {} + +func (x *MintActorCertificateResponse) ProtoReflect() protoreflect.Message { + mi := &file_atelet_proto_msgTypes[1] + 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 MintActorCertificateResponse.ProtoReflect.Descriptor instead. +func (*MintActorCertificateResponse) Descriptor() ([]byte, []int) { + return file_atelet_proto_rawDescGZIP(), []int{1} +} + +func (x *MintActorCertificateResponse) GetActorCertificates() [][]byte { + if x != nil { + return x.ActorCertificates + } + return nil +} + type RunRequest struct { state protoimpl.MessageState `protogen:"open.v1"` TargetAteomUid string `protobuf:"bytes,1,opt,name=target_ateom_uid,json=targetAteomUid,proto3" json:"target_ateom_uid,omitempty"` @@ -213,13 +304,15 @@ type RunRequest struct { // fetches the relevant assets and records them with the actor's on-node state // so a later Checkpoint can pin the same version into the snapshot manifest. SandboxAssets *SandboxAssets `protobuf:"bytes,8,opt,name=sandbox_assets,json=sandboxAssets,proto3" json:"sandbox_assets,omitempty"` + // When absent, actor traffic uses direct egress instead of atunnel. + EgressGateway *EgressGateway `protobuf:"bytes,9,opt,name=egress_gateway,json=egressGateway,proto3" json:"egress_gateway,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *RunRequest) Reset() { *x = RunRequest{} - mi := &file_atelet_proto_msgTypes[0] + mi := &file_atelet_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -231,7 +324,7 @@ func (x *RunRequest) String() string { func (*RunRequest) ProtoMessage() {} func (x *RunRequest) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[0] + mi := &file_atelet_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -244,7 +337,7 @@ func (x *RunRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RunRequest.ProtoReflect.Descriptor instead. func (*RunRequest) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{0} + return file_atelet_proto_rawDescGZIP(), []int{2} } func (x *RunRequest) GetTargetAteomUid() string { @@ -303,6 +396,59 @@ func (x *RunRequest) GetSandboxAssets() *SandboxAssets { return nil } +func (x *RunRequest) GetEgressGateway() *EgressGateway { + if x != nil { + return x.EgressGateway + } + return nil +} + +// EgressGateway configures tunneled egress for one actor activation. +type EgressGateway struct { + state protoimpl.MessageState `protogen:"open.v1"` + // address is the remote gateway's host:port. + Address string `protobuf:"bytes,1,opt,name=address,proto3" json:"address,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EgressGateway) Reset() { + *x = EgressGateway{} + mi := &file_atelet_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EgressGateway) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EgressGateway) ProtoMessage() {} + +func (x *EgressGateway) ProtoReflect() protoreflect.Message { + mi := &file_atelet_proto_msgTypes[3] + 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 EgressGateway.ProtoReflect.Descriptor instead. +func (*EgressGateway) Descriptor() ([]byte, []int) { + return file_atelet_proto_rawDescGZIP(), []int{3} +} + +func (x *EgressGateway) GetAddress() string { + if x != nil { + return x.Address + } + return "" +} + // AssetFile is one content-addressed file atelet fetches for a sandbox runtime // (e.g. the gVisor runsc binary). type AssetFile struct { @@ -317,7 +463,7 @@ type AssetFile struct { func (x *AssetFile) Reset() { *x = AssetFile{} - mi := &file_atelet_proto_msgTypes[1] + mi := &file_atelet_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -329,7 +475,7 @@ func (x *AssetFile) String() string { func (*AssetFile) ProtoMessage() {} func (x *AssetFile) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[1] + mi := &file_atelet_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -342,7 +488,7 @@ func (x *AssetFile) ProtoReflect() protoreflect.Message { // Deprecated: Use AssetFile.ProtoReflect.Descriptor instead. func (*AssetFile) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{1} + return file_atelet_proto_rawDescGZIP(), []int{4} } func (x *AssetFile) GetUrl() string { @@ -370,7 +516,7 @@ type ArchAssets struct { func (x *ArchAssets) Reset() { *x = ArchAssets{} - mi := &file_atelet_proto_msgTypes[2] + mi := &file_atelet_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -382,7 +528,7 @@ func (x *ArchAssets) String() string { func (*ArchAssets) ProtoMessage() {} func (x *ArchAssets) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[2] + mi := &file_atelet_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -395,7 +541,7 @@ func (x *ArchAssets) ProtoReflect() protoreflect.Message { // Deprecated: Use ArchAssets.ProtoReflect.Descriptor instead. func (*ArchAssets) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{2} + return file_atelet_proto_rawDescGZIP(), []int{5} } func (x *ArchAssets) GetFiles() map[string]*AssetFile { @@ -419,7 +565,7 @@ type SandboxAssets struct { func (x *SandboxAssets) Reset() { *x = SandboxAssets{} - mi := &file_atelet_proto_msgTypes[3] + mi := &file_atelet_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -431,7 +577,7 @@ func (x *SandboxAssets) String() string { func (*SandboxAssets) ProtoMessage() {} func (x *SandboxAssets) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[3] + mi := &file_atelet_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -444,7 +590,7 @@ func (x *SandboxAssets) ProtoReflect() protoreflect.Message { // Deprecated: Use SandboxAssets.ProtoReflect.Descriptor instead. func (*SandboxAssets) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{3} + return file_atelet_proto_rawDescGZIP(), []int{6} } func (x *SandboxAssets) GetSandboxClass() string { @@ -473,7 +619,7 @@ type WorkloadSpec struct { func (x *WorkloadSpec) Reset() { *x = WorkloadSpec{} - mi := &file_atelet_proto_msgTypes[4] + mi := &file_atelet_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -485,7 +631,7 @@ func (x *WorkloadSpec) String() string { func (*WorkloadSpec) ProtoMessage() {} func (x *WorkloadSpec) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[4] + mi := &file_atelet_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -498,7 +644,7 @@ func (x *WorkloadSpec) ProtoReflect() protoreflect.Message { // Deprecated: Use WorkloadSpec.ProtoReflect.Descriptor instead. func (*WorkloadSpec) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{4} + return file_atelet_proto_rawDescGZIP(), []int{7} } func (x *WorkloadSpec) GetContainers() []*Container { @@ -530,7 +676,7 @@ type DurableDirVolume struct { func (x *DurableDirVolume) Reset() { *x = DurableDirVolume{} - mi := &file_atelet_proto_msgTypes[5] + mi := &file_atelet_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -542,7 +688,7 @@ func (x *DurableDirVolume) String() string { func (*DurableDirVolume) ProtoMessage() {} func (x *DurableDirVolume) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[5] + mi := &file_atelet_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -555,7 +701,7 @@ func (x *DurableDirVolume) ProtoReflect() protoreflect.Message { // Deprecated: Use DurableDirVolume.ProtoReflect.Descriptor instead. func (*DurableDirVolume) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{5} + return file_atelet_proto_rawDescGZIP(), []int{8} } type ExternalVolumeSource struct { @@ -568,7 +714,7 @@ type ExternalVolumeSource struct { func (x *ExternalVolumeSource) Reset() { *x = ExternalVolumeSource{} - mi := &file_atelet_proto_msgTypes[6] + mi := &file_atelet_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -580,7 +726,7 @@ func (x *ExternalVolumeSource) String() string { func (*ExternalVolumeSource) ProtoMessage() {} func (x *ExternalVolumeSource) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[6] + mi := &file_atelet_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -593,7 +739,7 @@ func (x *ExternalVolumeSource) ProtoReflect() protoreflect.Message { // Deprecated: Use ExternalVolumeSource.ProtoReflect.Descriptor instead. func (*ExternalVolumeSource) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{6} + return file_atelet_proto_rawDescGZIP(), []int{9} } func (x *ExternalVolumeSource) GetStorageVolumeId() string { @@ -625,7 +771,7 @@ type Volume struct { func (x *Volume) Reset() { *x = Volume{} - mi := &file_atelet_proto_msgTypes[7] + mi := &file_atelet_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -637,7 +783,7 @@ func (x *Volume) String() string { func (*Volume) ProtoMessage() {} func (x *Volume) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[7] + mi := &file_atelet_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -650,7 +796,7 @@ func (x *Volume) ProtoReflect() protoreflect.Message { // Deprecated: Use Volume.ProtoReflect.Descriptor instead. func (*Volume) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{7} + return file_atelet_proto_rawDescGZIP(), []int{10} } func (x *Volume) GetName() string { @@ -718,7 +864,7 @@ type VolumeMount struct { func (x *VolumeMount) Reset() { *x = VolumeMount{} - mi := &file_atelet_proto_msgTypes[8] + mi := &file_atelet_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -730,7 +876,7 @@ func (x *VolumeMount) String() string { func (*VolumeMount) ProtoMessage() {} func (x *VolumeMount) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[8] + mi := &file_atelet_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -743,7 +889,7 @@ func (x *VolumeMount) ProtoReflect() protoreflect.Message { // Deprecated: Use VolumeMount.ProtoReflect.Descriptor instead. func (*VolumeMount) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{8} + return file_atelet_proto_rawDescGZIP(), []int{11} } func (x *VolumeMount) GetName() string { @@ -775,7 +921,7 @@ type Container struct { func (x *Container) Reset() { *x = Container{} - mi := &file_atelet_proto_msgTypes[9] + mi := &file_atelet_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -787,7 +933,7 @@ func (x *Container) String() string { func (*Container) ProtoMessage() {} func (x *Container) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[9] + mi := &file_atelet_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -800,7 +946,7 @@ func (x *Container) ProtoReflect() protoreflect.Message { // Deprecated: Use Container.ProtoReflect.Descriptor instead. func (*Container) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{9} + return file_atelet_proto_rawDescGZIP(), []int{12} } func (x *Container) GetName() string { @@ -862,7 +1008,7 @@ type EnvEntry struct { func (x *EnvEntry) Reset() { *x = EnvEntry{} - mi := &file_atelet_proto_msgTypes[10] + mi := &file_atelet_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -874,7 +1020,7 @@ func (x *EnvEntry) String() string { func (*EnvEntry) ProtoMessage() {} func (x *EnvEntry) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[10] + mi := &file_atelet_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -887,7 +1033,7 @@ func (x *EnvEntry) ProtoReflect() protoreflect.Message { // Deprecated: Use EnvEntry.ProtoReflect.Descriptor instead. func (*EnvEntry) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{10} + return file_atelet_proto_rawDescGZIP(), []int{13} } func (x *EnvEntry) GetName() string { @@ -915,7 +1061,7 @@ type Readyz struct { func (x *Readyz) Reset() { *x = Readyz{} - mi := &file_atelet_proto_msgTypes[11] + mi := &file_atelet_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -927,7 +1073,7 @@ func (x *Readyz) String() string { func (*Readyz) ProtoMessage() {} func (x *Readyz) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[11] + mi := &file_atelet_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -940,7 +1086,7 @@ func (x *Readyz) ProtoReflect() protoreflect.Message { // Deprecated: Use Readyz.ProtoReflect.Descriptor instead. func (*Readyz) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{11} + return file_atelet_proto_rawDescGZIP(), []int{14} } func (x *Readyz) GetHttpGet() *HTTPGetAction { @@ -963,7 +1109,7 @@ type HTTPGetAction struct { func (x *HTTPGetAction) Reset() { *x = HTTPGetAction{} - mi := &file_atelet_proto_msgTypes[12] + mi := &file_atelet_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -975,7 +1121,7 @@ func (x *HTTPGetAction) String() string { func (*HTTPGetAction) ProtoMessage() {} func (x *HTTPGetAction) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[12] + mi := &file_atelet_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -988,7 +1134,7 @@ func (x *HTTPGetAction) ProtoReflect() protoreflect.Message { // Deprecated: Use HTTPGetAction.ProtoReflect.Descriptor instead. func (*HTTPGetAction) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{12} + return file_atelet_proto_rawDescGZIP(), []int{15} } func (x *HTTPGetAction) GetPath() string { @@ -1013,7 +1159,7 @@ type RunResponse struct { func (x *RunResponse) Reset() { *x = RunResponse{} - mi := &file_atelet_proto_msgTypes[13] + mi := &file_atelet_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1025,7 +1171,7 @@ func (x *RunResponse) String() string { func (*RunResponse) ProtoMessage() {} func (x *RunResponse) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[13] + mi := &file_atelet_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1038,7 +1184,7 @@ func (x *RunResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RunResponse.ProtoReflect.Descriptor instead. func (*RunResponse) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{13} + return file_atelet_proto_rawDescGZIP(), []int{16} } type LocalCheckpointConfiguration struct { @@ -1052,7 +1198,7 @@ type LocalCheckpointConfiguration struct { func (x *LocalCheckpointConfiguration) Reset() { *x = LocalCheckpointConfiguration{} - mi := &file_atelet_proto_msgTypes[14] + mi := &file_atelet_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1064,7 +1210,7 @@ func (x *LocalCheckpointConfiguration) String() string { func (*LocalCheckpointConfiguration) ProtoMessage() {} func (x *LocalCheckpointConfiguration) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[14] + mi := &file_atelet_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1077,7 +1223,7 @@ func (x *LocalCheckpointConfiguration) ProtoReflect() protoreflect.Message { // Deprecated: Use LocalCheckpointConfiguration.ProtoReflect.Descriptor instead. func (*LocalCheckpointConfiguration) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{14} + return file_atelet_proto_rawDescGZIP(), []int{17} } func (x *LocalCheckpointConfiguration) GetSnapshotPrefix() string { @@ -1106,7 +1252,7 @@ type ExternalCheckpointConfiguration struct { func (x *ExternalCheckpointConfiguration) Reset() { *x = ExternalCheckpointConfiguration{} - mi := &file_atelet_proto_msgTypes[15] + mi := &file_atelet_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1118,7 +1264,7 @@ func (x *ExternalCheckpointConfiguration) String() string { func (*ExternalCheckpointConfiguration) ProtoMessage() {} func (x *ExternalCheckpointConfiguration) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[15] + mi := &file_atelet_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1131,7 +1277,7 @@ func (x *ExternalCheckpointConfiguration) ProtoReflect() protoreflect.Message { // Deprecated: Use ExternalCheckpointConfiguration.ProtoReflect.Descriptor instead. func (*ExternalCheckpointConfiguration) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{15} + return file_atelet_proto_rawDescGZIP(), []int{18} } func (x *ExternalCheckpointConfiguration) GetSnapshotUriPrefix() string { @@ -1169,7 +1315,7 @@ type CheckpointRequest struct { func (x *CheckpointRequest) Reset() { *x = CheckpointRequest{} - mi := &file_atelet_proto_msgTypes[16] + mi := &file_atelet_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1181,7 +1327,7 @@ func (x *CheckpointRequest) String() string { func (*CheckpointRequest) ProtoMessage() {} func (x *CheckpointRequest) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[16] + mi := &file_atelet_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1194,7 +1340,7 @@ func (x *CheckpointRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CheckpointRequest.ProtoReflect.Descriptor instead. func (*CheckpointRequest) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{16} + return file_atelet_proto_rawDescGZIP(), []int{19} } func (x *CheckpointRequest) GetTargetAteomUid() string { @@ -1309,7 +1455,7 @@ type CheckpointResponse struct { func (x *CheckpointResponse) Reset() { *x = CheckpointResponse{} - mi := &file_atelet_proto_msgTypes[17] + mi := &file_atelet_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1321,7 +1467,7 @@ func (x *CheckpointResponse) String() string { func (*CheckpointResponse) ProtoMessage() {} func (x *CheckpointResponse) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[17] + mi := &file_atelet_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1334,7 +1480,7 @@ func (x *CheckpointResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CheckpointResponse.ProtoReflect.Descriptor instead. func (*CheckpointResponse) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{17} + return file_atelet_proto_rawDescGZIP(), []int{20} } type RestoreRequest struct { @@ -1366,13 +1512,15 @@ type RestoreRequest struct { // of the `config` oneof: the actor's snapshot may be local (a pause // checkpoint) while the golden snapshot is always external. GoldenSnapshotUriPrefix string `protobuf:"bytes,12,opt,name=golden_snapshot_uri_prefix,json=goldenSnapshotUriPrefix,proto3" json:"golden_snapshot_uri_prefix,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // When absent, actor traffic uses direct egress instead of atunnel. + EgressGateway *EgressGateway `protobuf:"bytes,13,opt,name=egress_gateway,json=egressGateway,proto3" json:"egress_gateway,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RestoreRequest) Reset() { *x = RestoreRequest{} - mi := &file_atelet_proto_msgTypes[18] + mi := &file_atelet_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1384,7 +1532,7 @@ func (x *RestoreRequest) String() string { func (*RestoreRequest) ProtoMessage() {} func (x *RestoreRequest) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[18] + mi := &file_atelet_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1397,7 +1545,7 @@ func (x *RestoreRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RestoreRequest.ProtoReflect.Descriptor instead. func (*RestoreRequest) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{18} + return file_atelet_proto_rawDescGZIP(), []int{21} } func (x *RestoreRequest) GetTargetAteomUid() string { @@ -1495,6 +1643,13 @@ func (x *RestoreRequest) GetGoldenSnapshotUriPrefix() string { return "" } +func (x *RestoreRequest) GetEgressGateway() *EgressGateway { + if x != nil { + return x.EgressGateway + } + return nil +} + type isRestoreRequest_Config interface { isRestoreRequest_Config() } @@ -1519,7 +1674,7 @@ type RestoreResponse struct { func (x *RestoreResponse) Reset() { *x = RestoreResponse{} - mi := &file_atelet_proto_msgTypes[19] + mi := &file_atelet_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1531,7 +1686,7 @@ func (x *RestoreResponse) String() string { func (*RestoreResponse) ProtoMessage() {} func (x *RestoreResponse) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[19] + mi := &file_atelet_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1544,14 +1699,18 @@ func (x *RestoreResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RestoreResponse.ProtoReflect.Descriptor instead. func (*RestoreResponse) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{19} + return file_atelet_proto_rawDescGZIP(), []int{22} } var File_atelet_proto protoreflect.FileDescriptor const file_atelet_proto_rawDesc = "" + "\n" + - "\fatelet.proto\x12\x06atelet\"\xe0\x02\n" + + "\fatelet.proto\x12\x06atelet\"]\n" + + "\x1bMintActorCertificateRequest\x12>\n" + + "\x1bcertificate_signing_request\x18\x01 \x01(\fR\x19certificateSigningRequest\"M\n" + + "\x1cMintActorCertificateResponse\x12-\n" + + "\x12actor_certificates\x18\x01 \x03(\fR\x11actorCertificates\"\x9e\x03\n" + "\n" + "RunRequest\x12(\n" + "\x10target_ateom_uid\x18\x01 \x01(\tR\x0etargetAteomUid\x12\x1a\n" + @@ -1562,7 +1721,10 @@ const file_atelet_proto_rawDesc = "" + "\x18actor_template_namespace\x18\x05 \x01(\tR\x16actorTemplateNamespace\x12.\n" + "\x13actor_template_name\x18\x06 \x01(\tR\x11actorTemplateName\x12(\n" + "\x04spec\x18\a \x01(\v2\x14.atelet.WorkloadSpecR\x04spec\x12<\n" + - "\x0esandbox_assets\x18\b \x01(\v2\x15.atelet.SandboxAssetsR\rsandboxAssets\"5\n" + + "\x0esandbox_assets\x18\b \x01(\v2\x15.atelet.SandboxAssetsR\rsandboxAssets\x12<\n" + + "\x0eegress_gateway\x18\t \x01(\v2\x15.atelet.EgressGatewayR\regressGateway\")\n" + + "\rEgressGateway\x12\x18\n" + + "\aaddress\x18\x01 \x01(\tR\aaddress\"5\n" + "\tAssetFile\x12\x10\n" + "\x03url\x18\x01 \x01(\tR\x03url\x12\x16\n" + "\x06sha256\x18\x02 \x01(\tR\x06sha256\"\x8e\x01\n" + @@ -1638,7 +1800,7 @@ const file_atelet_proto_rawDesc = "" + " \x01(\v2'.atelet.ExternalCheckpointConfigurationH\x00R\x0eexternalConfig\x12+\n" + "\x05scope\x18\v \x01(\x0e2\x15.atelet.SnapshotScopeR\x05scopeB\b\n" + "\x06config\"\x14\n" + - "\x12CheckpointResponse\"\xe5\x04\n" + + "\x12CheckpointResponse\"\xa3\x05\n" + "\x0eRestoreRequest\x12(\n" + "\x10target_ateom_uid\x18\x01 \x01(\tR\x0etargetAteomUid\x12\x1a\n" + "\batespace\x18\x02 \x01(\tR\batespace\x12\x1d\n" + @@ -1653,7 +1815,8 @@ const file_atelet_proto_rawDesc = "" + "\x0fexternal_config\x18\n" + " \x01(\v2'.atelet.ExternalCheckpointConfigurationH\x00R\x0eexternalConfig\x12+\n" + "\x05scope\x18\v \x01(\x0e2\x15.atelet.SnapshotScopeR\x05scope\x12;\n" + - "\x1agolden_snapshot_uri_prefix\x18\f \x01(\tR\x17goldenSnapshotUriPrefixB\b\n" + + "\x1agolden_snapshot_uri_prefix\x18\f \x01(\tR\x17goldenSnapshotUriPrefix\x12<\n" + + "\x0eegress_gateway\x18\r \x01(\v2\x15.atelet.EgressGatewayR\regressGatewayB\b\n" + "\x06config\"\x11\n" + "\x0fRestoreResponse*`\n" + "\n" + @@ -1669,7 +1832,9 @@ const file_atelet_proto_rawDesc = "" + "\x1aSNAPSHOT_SCOPE_UNSPECIFIED\x10\x00\x12\x17\n" + "\x13SNAPSHOT_SCOPE_FULL\x10\x01\x12\x17\n" + "\x13SNAPSHOT_SCOPE_DATA\x10\x02\x12!\n" + - "\x1dSNAPSHOT_SCOPE_DATA_ON_GOLDEN\x10\x032\xc4\x01\n" + + "\x1dSNAPSHOT_SCOPE_DATA_ON_GOLDEN\x10\x032w\n" + + "\x10CredentialBroker\x12c\n" + + "\x14MintActorCertificate\x12#.atelet.MintActorCertificateRequest\x1a$.atelet.MintActorCertificateResponse\"\x002\xc4\x01\n" + "\vAteomHerder\x120\n" + "\x03Run\x12\x12.atelet.RunRequest\x1a\x13.atelet.RunResponse\"\x00\x12E\n" + "\n" + @@ -1689,71 +1854,78 @@ func file_atelet_proto_rawDescGZIP() []byte { } var file_atelet_proto_enumTypes = make([]protoimpl.EnumInfo, 3) -var file_atelet_proto_msgTypes = make([]protoimpl.MessageInfo, 22) +var file_atelet_proto_msgTypes = make([]protoimpl.MessageInfo, 25) var file_atelet_proto_goTypes = []any{ (VolumeType)(0), // 0: atelet.VolumeType (CheckpointType)(0), // 1: atelet.CheckpointType (SnapshotScope)(0), // 2: atelet.SnapshotScope - (*RunRequest)(nil), // 3: atelet.RunRequest - (*AssetFile)(nil), // 4: atelet.AssetFile - (*ArchAssets)(nil), // 5: atelet.ArchAssets - (*SandboxAssets)(nil), // 6: atelet.SandboxAssets - (*WorkloadSpec)(nil), // 7: atelet.WorkloadSpec - (*DurableDirVolume)(nil), // 8: atelet.DurableDirVolume - (*ExternalVolumeSource)(nil), // 9: atelet.ExternalVolumeSource - (*Volume)(nil), // 10: atelet.Volume - (*VolumeMount)(nil), // 11: atelet.VolumeMount - (*Container)(nil), // 12: atelet.Container - (*EnvEntry)(nil), // 13: atelet.EnvEntry - (*Readyz)(nil), // 14: atelet.Readyz - (*HTTPGetAction)(nil), // 15: atelet.HTTPGetAction - (*RunResponse)(nil), // 16: atelet.RunResponse - (*LocalCheckpointConfiguration)(nil), // 17: atelet.LocalCheckpointConfiguration - (*ExternalCheckpointConfiguration)(nil), // 18: atelet.ExternalCheckpointConfiguration - (*CheckpointRequest)(nil), // 19: atelet.CheckpointRequest - (*CheckpointResponse)(nil), // 20: atelet.CheckpointResponse - (*RestoreRequest)(nil), // 21: atelet.RestoreRequest - (*RestoreResponse)(nil), // 22: atelet.RestoreResponse - nil, // 23: atelet.ArchAssets.FilesEntry - nil, // 24: atelet.SandboxAssets.AssetsEntry + (*MintActorCertificateRequest)(nil), // 3: atelet.MintActorCertificateRequest + (*MintActorCertificateResponse)(nil), // 4: atelet.MintActorCertificateResponse + (*RunRequest)(nil), // 5: atelet.RunRequest + (*EgressGateway)(nil), // 6: atelet.EgressGateway + (*AssetFile)(nil), // 7: atelet.AssetFile + (*ArchAssets)(nil), // 8: atelet.ArchAssets + (*SandboxAssets)(nil), // 9: atelet.SandboxAssets + (*WorkloadSpec)(nil), // 10: atelet.WorkloadSpec + (*DurableDirVolume)(nil), // 11: atelet.DurableDirVolume + (*ExternalVolumeSource)(nil), // 12: atelet.ExternalVolumeSource + (*Volume)(nil), // 13: atelet.Volume + (*VolumeMount)(nil), // 14: atelet.VolumeMount + (*Container)(nil), // 15: atelet.Container + (*EnvEntry)(nil), // 16: atelet.EnvEntry + (*Readyz)(nil), // 17: atelet.Readyz + (*HTTPGetAction)(nil), // 18: atelet.HTTPGetAction + (*RunResponse)(nil), // 19: atelet.RunResponse + (*LocalCheckpointConfiguration)(nil), // 20: atelet.LocalCheckpointConfiguration + (*ExternalCheckpointConfiguration)(nil), // 21: atelet.ExternalCheckpointConfiguration + (*CheckpointRequest)(nil), // 22: atelet.CheckpointRequest + (*CheckpointResponse)(nil), // 23: atelet.CheckpointResponse + (*RestoreRequest)(nil), // 24: atelet.RestoreRequest + (*RestoreResponse)(nil), // 25: atelet.RestoreResponse + nil, // 26: atelet.ArchAssets.FilesEntry + nil, // 27: atelet.SandboxAssets.AssetsEntry } var file_atelet_proto_depIdxs = []int32{ - 7, // 0: atelet.RunRequest.spec:type_name -> atelet.WorkloadSpec - 6, // 1: atelet.RunRequest.sandbox_assets:type_name -> atelet.SandboxAssets - 23, // 2: atelet.ArchAssets.files:type_name -> atelet.ArchAssets.FilesEntry - 24, // 3: atelet.SandboxAssets.assets:type_name -> atelet.SandboxAssets.AssetsEntry - 12, // 4: atelet.WorkloadSpec.containers:type_name -> atelet.Container - 10, // 5: atelet.WorkloadSpec.volumes:type_name -> atelet.Volume - 0, // 6: atelet.Volume.type:type_name -> atelet.VolumeType - 8, // 7: atelet.Volume.durable_dir:type_name -> atelet.DurableDirVolume - 9, // 8: atelet.Volume.external:type_name -> atelet.ExternalVolumeSource - 13, // 9: atelet.Container.env:type_name -> atelet.EnvEntry - 14, // 10: atelet.Container.readyz:type_name -> atelet.Readyz - 11, // 11: atelet.Container.volume_mounts:type_name -> atelet.VolumeMount - 15, // 12: atelet.Readyz.http_get:type_name -> atelet.HTTPGetAction - 7, // 13: atelet.CheckpointRequest.spec:type_name -> atelet.WorkloadSpec - 1, // 14: atelet.CheckpointRequest.type:type_name -> atelet.CheckpointType - 17, // 15: atelet.CheckpointRequest.local_config:type_name -> atelet.LocalCheckpointConfiguration - 18, // 16: atelet.CheckpointRequest.external_config:type_name -> atelet.ExternalCheckpointConfiguration - 2, // 17: atelet.CheckpointRequest.scope:type_name -> atelet.SnapshotScope - 7, // 18: atelet.RestoreRequest.spec:type_name -> atelet.WorkloadSpec - 1, // 19: atelet.RestoreRequest.type:type_name -> atelet.CheckpointType - 17, // 20: atelet.RestoreRequest.local_config:type_name -> atelet.LocalCheckpointConfiguration - 18, // 21: atelet.RestoreRequest.external_config:type_name -> atelet.ExternalCheckpointConfiguration - 2, // 22: atelet.RestoreRequest.scope:type_name -> atelet.SnapshotScope - 4, // 23: atelet.ArchAssets.FilesEntry.value:type_name -> atelet.AssetFile - 5, // 24: atelet.SandboxAssets.AssetsEntry.value:type_name -> atelet.ArchAssets - 3, // 25: atelet.AteomHerder.Run:input_type -> atelet.RunRequest - 19, // 26: atelet.AteomHerder.Checkpoint:input_type -> atelet.CheckpointRequest - 21, // 27: atelet.AteomHerder.Restore:input_type -> atelet.RestoreRequest - 16, // 28: atelet.AteomHerder.Run:output_type -> atelet.RunResponse - 20, // 29: atelet.AteomHerder.Checkpoint:output_type -> atelet.CheckpointResponse - 22, // 30: atelet.AteomHerder.Restore:output_type -> atelet.RestoreResponse - 28, // [28:31] is the sub-list for method output_type - 25, // [25:28] is the sub-list for method input_type - 25, // [25:25] is the sub-list for extension type_name - 25, // [25:25] is the sub-list for extension extendee - 0, // [0:25] is the sub-list for field type_name + 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 + 26, // 3: atelet.ArchAssets.files:type_name -> atelet.ArchAssets.FilesEntry + 27, // 4: atelet.SandboxAssets.assets:type_name -> atelet.SandboxAssets.AssetsEntry + 15, // 5: atelet.WorkloadSpec.containers:type_name -> atelet.Container + 13, // 6: atelet.WorkloadSpec.volumes:type_name -> atelet.Volume + 0, // 7: atelet.Volume.type:type_name -> atelet.VolumeType + 11, // 8: atelet.Volume.durable_dir:type_name -> atelet.DurableDirVolume + 12, // 9: atelet.Volume.external:type_name -> atelet.ExternalVolumeSource + 16, // 10: atelet.Container.env:type_name -> atelet.EnvEntry + 17, // 11: atelet.Container.readyz:type_name -> atelet.Readyz + 14, // 12: atelet.Container.volume_mounts:type_name -> atelet.VolumeMount + 18, // 13: atelet.Readyz.http_get:type_name -> atelet.HTTPGetAction + 10, // 14: atelet.CheckpointRequest.spec:type_name -> atelet.WorkloadSpec + 1, // 15: atelet.CheckpointRequest.type:type_name -> atelet.CheckpointType + 20, // 16: atelet.CheckpointRequest.local_config:type_name -> atelet.LocalCheckpointConfiguration + 21, // 17: atelet.CheckpointRequest.external_config:type_name -> atelet.ExternalCheckpointConfiguration + 2, // 18: atelet.CheckpointRequest.scope:type_name -> atelet.SnapshotScope + 10, // 19: atelet.RestoreRequest.spec:type_name -> atelet.WorkloadSpec + 1, // 20: atelet.RestoreRequest.type:type_name -> atelet.CheckpointType + 20, // 21: atelet.RestoreRequest.local_config:type_name -> atelet.LocalCheckpointConfiguration + 21, // 22: atelet.RestoreRequest.external_config:type_name -> atelet.ExternalCheckpointConfiguration + 2, // 23: atelet.RestoreRequest.scope:type_name -> atelet.SnapshotScope + 6, // 24: atelet.RestoreRequest.egress_gateway:type_name -> atelet.EgressGateway + 7, // 25: atelet.ArchAssets.FilesEntry.value:type_name -> atelet.AssetFile + 8, // 26: atelet.SandboxAssets.AssetsEntry.value:type_name -> atelet.ArchAssets + 3, // 27: atelet.CredentialBroker.MintActorCertificate:input_type -> atelet.MintActorCertificateRequest + 5, // 28: atelet.AteomHerder.Run:input_type -> atelet.RunRequest + 22, // 29: atelet.AteomHerder.Checkpoint:input_type -> atelet.CheckpointRequest + 24, // 30: atelet.AteomHerder.Restore:input_type -> atelet.RestoreRequest + 4, // 31: atelet.CredentialBroker.MintActorCertificate:output_type -> atelet.MintActorCertificateResponse + 19, // 32: atelet.AteomHerder.Run:output_type -> atelet.RunResponse + 23, // 33: atelet.AteomHerder.Checkpoint:output_type -> atelet.CheckpointResponse + 25, // 34: atelet.AteomHerder.Restore:output_type -> atelet.RestoreResponse + 31, // [31:35] is the sub-list for method output_type + 27, // [27:31] is the sub-list for method input_type + 27, // [27:27] is the sub-list for extension type_name + 27, // [27:27] is the sub-list for extension extendee + 0, // [0:27] is the sub-list for field type_name } func init() { file_atelet_proto_init() } @@ -1761,15 +1933,15 @@ func file_atelet_proto_init() { if File_atelet_proto != nil { return } - file_atelet_proto_msgTypes[7].OneofWrappers = []any{ + file_atelet_proto_msgTypes[10].OneofWrappers = []any{ (*Volume_DurableDir)(nil), (*Volume_External)(nil), } - file_atelet_proto_msgTypes[16].OneofWrappers = []any{ + file_atelet_proto_msgTypes[19].OneofWrappers = []any{ (*CheckpointRequest_LocalConfig)(nil), (*CheckpointRequest_ExternalConfig)(nil), } - file_atelet_proto_msgTypes[18].OneofWrappers = []any{ + file_atelet_proto_msgTypes[21].OneofWrappers = []any{ (*RestoreRequest_LocalConfig)(nil), (*RestoreRequest_ExternalConfig)(nil), } @@ -1779,9 +1951,9 @@ 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: 22, + NumMessages: 25, NumExtensions: 0, - NumServices: 1, + NumServices: 2, }, GoTypes: file_atelet_proto_goTypes, DependencyIndexes: file_atelet_proto_depIdxs, diff --git a/internal/proto/ateletpb/atelet.proto b/internal/proto/ateletpb/atelet.proto index cee9e6a89..fa78e4a6b 100644 --- a/internal/proto/ateletpb/atelet.proto +++ b/internal/proto/ateletpb/atelet.proto @@ -18,6 +18,22 @@ package atelet; option go_package = "github.com/agent-substrate/substrate/internal/proto/ateletpb"; +// CredentialBroker gives an authenticated worker its current actor credential. +service CredentialBroker { + rpc MintActorCertificate(MintActorCertificateRequest) returns (MintActorCertificateResponse) {} +} + +message MintActorCertificateRequest { + // DER-encoded PKCS #10 certificate signing request. Atunnel retains the + // corresponding private key. + bytes certificate_signing_request = 1; +} + +message MintActorCertificateResponse { + // DER-encoded leaf followed by any intermediate certificates. + repeated bytes actor_certificates = 1; +} + service AteomHerder { // Run tells atelet to create a new containerized workload from scratch on an // ateom. @@ -48,6 +64,15 @@ message RunRequest { // fetches the relevant assets and records them with the actor's on-node state // so a later Checkpoint can pin the same version into the snapshot manifest. SandboxAssets sandbox_assets = 8; + + // When absent, actor traffic uses direct egress instead of atunnel. + EgressGateway egress_gateway = 9; +} + +// EgressGateway configures tunneled egress for one actor activation. +message EgressGateway { + // address is the remote gateway's host:port. + string address = 1; } // AssetFile is one content-addressed file atelet fetches for a sandbox runtime @@ -254,6 +279,9 @@ message RestoreRequest { // of the `config` oneof: the actor's snapshot may be local (a pause // checkpoint) while the golden snapshot is always external. string golden_snapshot_uri_prefix = 12; + + // When absent, actor traffic uses direct egress instead of atunnel. + EgressGateway egress_gateway = 13; } message RestoreResponse { diff --git a/internal/proto/ateletpb/atelet_grpc.pb.go b/internal/proto/ateletpb/atelet_grpc.pb.go index 4f05a878d..e4e6d1923 100644 --- a/internal/proto/ateletpb/atelet_grpc.pb.go +++ b/internal/proto/ateletpb/atelet_grpc.pb.go @@ -32,6 +32,112 @@ import ( // Requires gRPC-Go v1.64.0 or later. const _ = grpc.SupportPackageIsVersion9 +const ( + CredentialBroker_MintActorCertificate_FullMethodName = "/atelet.CredentialBroker/MintActorCertificate" +) + +// CredentialBrokerClient is the client API for CredentialBroker service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// CredentialBroker gives an authenticated worker its current actor credential. +type CredentialBrokerClient interface { + MintActorCertificate(ctx context.Context, in *MintActorCertificateRequest, opts ...grpc.CallOption) (*MintActorCertificateResponse, error) +} + +type credentialBrokerClient struct { + cc grpc.ClientConnInterface +} + +func NewCredentialBrokerClient(cc grpc.ClientConnInterface) CredentialBrokerClient { + return &credentialBrokerClient{cc} +} + +func (c *credentialBrokerClient) MintActorCertificate(ctx context.Context, in *MintActorCertificateRequest, opts ...grpc.CallOption) (*MintActorCertificateResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(MintActorCertificateResponse) + err := c.cc.Invoke(ctx, CredentialBroker_MintActorCertificate_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// CredentialBrokerServer is the server API for CredentialBroker service. +// All implementations must embed UnimplementedCredentialBrokerServer +// for forward compatibility. +// +// CredentialBroker gives an authenticated worker its current actor credential. +type CredentialBrokerServer interface { + MintActorCertificate(context.Context, *MintActorCertificateRequest) (*MintActorCertificateResponse, error) + mustEmbedUnimplementedCredentialBrokerServer() +} + +// UnimplementedCredentialBrokerServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedCredentialBrokerServer struct{} + +func (UnimplementedCredentialBrokerServer) MintActorCertificate(context.Context, *MintActorCertificateRequest) (*MintActorCertificateResponse, error) { + return nil, status.Error(codes.Unimplemented, "method MintActorCertificate not implemented") +} +func (UnimplementedCredentialBrokerServer) mustEmbedUnimplementedCredentialBrokerServer() {} +func (UnimplementedCredentialBrokerServer) testEmbeddedByValue() {} + +// UnsafeCredentialBrokerServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to CredentialBrokerServer will +// result in compilation errors. +type UnsafeCredentialBrokerServer interface { + mustEmbedUnimplementedCredentialBrokerServer() +} + +func RegisterCredentialBrokerServer(s grpc.ServiceRegistrar, srv CredentialBrokerServer) { + // If the following call panics, it indicates UnimplementedCredentialBrokerServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&CredentialBroker_ServiceDesc, srv) +} + +func _CredentialBroker_MintActorCertificate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(MintActorCertificateRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(CredentialBrokerServer).MintActorCertificate(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: CredentialBroker_MintActorCertificate_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(CredentialBrokerServer).MintActorCertificate(ctx, req.(*MintActorCertificateRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// CredentialBroker_ServiceDesc is the grpc.ServiceDesc for CredentialBroker service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var CredentialBroker_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "atelet.CredentialBroker", + HandlerType: (*CredentialBrokerServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "MintActorCertificate", + Handler: _CredentialBroker_MintActorCertificate_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "atelet.proto", +} + const ( AteomHerder_Run_FullMethodName = "/atelet.AteomHerder/Run" AteomHerder_Checkpoint_FullMethodName = "/atelet.AteomHerder/Checkpoint" diff --git a/internal/proto/ateompb/ateom.pb.go b/internal/proto/ateompb/ateom.pb.go index f738c0578..9022c5643 100644 --- a/internal/proto/ateompb/ateom.pb.go +++ b/internal/proto/ateompb/ateom.pb.go @@ -99,26 +99,23 @@ func (SnapshotScope) EnumDescriptor() ([]byte, []int) { } type RunWorkloadRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Atespace string `protobuf:"bytes,1,opt,name=atespace,proto3" json:"atespace,omitempty"` - ActorName string `protobuf:"bytes,2,opt,name=actor_name,json=actorName,proto3" json:"actor_name,omitempty"` - ActorUid string `protobuf:"bytes,3,opt,name=actor_uid,json=actorUid,proto3" json:"actor_uid,omitempty"` - // Actor resource version observed by ate-api when assigning this worker. - ActorVersion int64 `protobuf:"varint,9,opt,name=actor_version,json=actorVersion,proto3" json:"actor_version,omitempty"` - ActorTemplateNamespace string `protobuf:"bytes,4,opt,name=actor_template_namespace,json=actorTemplateNamespace,proto3" json:"actor_template_namespace,omitempty"` - ActorTemplateName string `protobuf:"bytes,5,opt,name=actor_template_name,json=actorTemplateName,proto3" json:"actor_template_name,omitempty"` - RunscPath string `protobuf:"bytes,6,opt,name=runsc_path,json=runscPath,proto3" json:"runsc_path,omitempty"` - Spec *WorkloadSpec `protobuf:"bytes,7,opt,name=spec,proto3" json:"spec,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Atespace string `protobuf:"bytes,1,opt,name=atespace,proto3" json:"atespace,omitempty"` + ActorName string `protobuf:"bytes,2,opt,name=actor_name,json=actorName,proto3" json:"actor_name,omitempty"` + ActorUid string `protobuf:"bytes,3,opt,name=actor_uid,json=actorUid,proto3" json:"actor_uid,omitempty"` + ActorTemplateNamespace string `protobuf:"bytes,4,opt,name=actor_template_namespace,json=actorTemplateNamespace,proto3" json:"actor_template_namespace,omitempty"` + ActorTemplateName string `protobuf:"bytes,5,opt,name=actor_template_name,json=actorTemplateName,proto3" json:"actor_template_name,omitempty"` + RunscPath string `protobuf:"bytes,6,opt,name=runsc_path,json=runscPath,proto3" json:"runsc_path,omitempty"` + Spec *WorkloadSpec `protobuf:"bytes,7,opt,name=spec,proto3" json:"spec,omitempty"` // runtime_asset_paths maps a runtime asset name (e.g. "cloud-hypervisor", // "virtiofsd", "kata-kernel", "kata-image", "kata-config") // to the local on-disk path atelet fetched it to (content-addressed, like // runsc_path). Empty for the gVisor runtime, which uses runsc_path. RuntimeAssetPaths map[string]string `protobuf:"bytes,8,rep,name=runtime_asset_paths,json=runtimeAssetPaths,proto3" json:"runtime_asset_paths,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` - // Remote egress gateway selected for this activation. When absent, actor - // traffic uses direct egress instead of being redirected through atunnel. - EgressGatewayAddress *string `protobuf:"bytes,10,opt,name=egress_gateway_address,json=egressGatewayAddress,proto3,oneof" json:"egress_gateway_address,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // When absent, actor traffic uses direct egress instead of atunnel. + EgressGateway *EgressGateway `protobuf:"bytes,10,opt,name=egress_gateway,json=egressGateway,proto3" json:"egress_gateway,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RunWorkloadRequest) Reset() { @@ -172,13 +169,6 @@ func (x *RunWorkloadRequest) GetActorUid() string { return "" } -func (x *RunWorkloadRequest) GetActorVersion() int64 { - if x != nil { - return x.ActorVersion - } - return 0 -} - func (x *RunWorkloadRequest) GetActorTemplateNamespace() string { if x != nil { return x.ActorTemplateNamespace @@ -214,9 +204,55 @@ func (x *RunWorkloadRequest) GetRuntimeAssetPaths() map[string]string { return nil } -func (x *RunWorkloadRequest) GetEgressGatewayAddress() string { - if x != nil && x.EgressGatewayAddress != nil { - return *x.EgressGatewayAddress +func (x *RunWorkloadRequest) GetEgressGateway() *EgressGateway { + if x != nil { + return x.EgressGateway + } + return nil +} + +// EgressGateway configures tunneled egress for one actor activation. +type EgressGateway struct { + state protoimpl.MessageState `protogen:"open.v1"` + // address is the remote gateway's host:port. + Address string `protobuf:"bytes,1,opt,name=address,proto3" json:"address,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EgressGateway) Reset() { + *x = EgressGateway{} + mi := &file_ateom_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EgressGateway) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EgressGateway) ProtoMessage() {} + +func (x *EgressGateway) ProtoReflect() protoreflect.Message { + mi := &file_ateom_proto_msgTypes[1] + 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 EgressGateway.ProtoReflect.Descriptor instead. +func (*EgressGateway) Descriptor() ([]byte, []int) { + return file_ateom_proto_rawDescGZIP(), []int{1} +} + +func (x *EgressGateway) GetAddress() string { + if x != nil { + return x.Address } return "" } @@ -231,7 +267,7 @@ type WorkloadSpec struct { func (x *WorkloadSpec) Reset() { *x = WorkloadSpec{} - mi := &file_ateom_proto_msgTypes[1] + mi := &file_ateom_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -243,7 +279,7 @@ func (x *WorkloadSpec) String() string { func (*WorkloadSpec) ProtoMessage() {} func (x *WorkloadSpec) ProtoReflect() protoreflect.Message { - mi := &file_ateom_proto_msgTypes[1] + mi := &file_ateom_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -256,7 +292,7 @@ func (x *WorkloadSpec) ProtoReflect() protoreflect.Message { // Deprecated: Use WorkloadSpec.ProtoReflect.Descriptor instead. func (*WorkloadSpec) Descriptor() ([]byte, []int) { - return file_ateom_proto_rawDescGZIP(), []int{1} + return file_ateom_proto_rawDescGZIP(), []int{2} } func (x *WorkloadSpec) GetContainers() []*Container { @@ -279,7 +315,7 @@ type Container struct { func (x *Container) Reset() { *x = Container{} - mi := &file_ateom_proto_msgTypes[2] + mi := &file_ateom_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -291,7 +327,7 @@ func (x *Container) String() string { func (*Container) ProtoMessage() {} func (x *Container) ProtoReflect() protoreflect.Message { - mi := &file_ateom_proto_msgTypes[2] + mi := &file_ateom_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -304,7 +340,7 @@ func (x *Container) ProtoReflect() protoreflect.Message { // Deprecated: Use Container.ProtoReflect.Descriptor instead. func (*Container) Descriptor() ([]byte, []int) { - return file_ateom_proto_rawDescGZIP(), []int{2} + return file_ateom_proto_rawDescGZIP(), []int{3} } func (x *Container) GetName() string { @@ -342,7 +378,7 @@ type DurableDirVolumeMount struct { func (x *DurableDirVolumeMount) Reset() { *x = DurableDirVolumeMount{} - mi := &file_ateom_proto_msgTypes[3] + mi := &file_ateom_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -354,7 +390,7 @@ func (x *DurableDirVolumeMount) String() string { func (*DurableDirVolumeMount) ProtoMessage() {} func (x *DurableDirVolumeMount) ProtoReflect() protoreflect.Message { - mi := &file_ateom_proto_msgTypes[3] + mi := &file_ateom_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -367,7 +403,7 @@ func (x *DurableDirVolumeMount) ProtoReflect() protoreflect.Message { // Deprecated: Use DurableDirVolumeMount.ProtoReflect.Descriptor instead. func (*DurableDirVolumeMount) Descriptor() ([]byte, []int) { - return file_ateom_proto_rawDescGZIP(), []int{3} + return file_ateom_proto_rawDescGZIP(), []int{4} } func (x *DurableDirVolumeMount) GetVolumeName() string { @@ -395,7 +431,7 @@ type Readyz struct { func (x *Readyz) Reset() { *x = Readyz{} - mi := &file_ateom_proto_msgTypes[4] + mi := &file_ateom_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -407,7 +443,7 @@ func (x *Readyz) String() string { func (*Readyz) ProtoMessage() {} func (x *Readyz) ProtoReflect() protoreflect.Message { - mi := &file_ateom_proto_msgTypes[4] + mi := &file_ateom_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -420,7 +456,7 @@ func (x *Readyz) ProtoReflect() protoreflect.Message { // Deprecated: Use Readyz.ProtoReflect.Descriptor instead. func (*Readyz) Descriptor() ([]byte, []int) { - return file_ateom_proto_rawDescGZIP(), []int{4} + return file_ateom_proto_rawDescGZIP(), []int{5} } func (x *Readyz) GetHttpGet() *HTTPGetAction { @@ -443,7 +479,7 @@ type HTTPGetAction struct { func (x *HTTPGetAction) Reset() { *x = HTTPGetAction{} - mi := &file_ateom_proto_msgTypes[5] + mi := &file_ateom_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -455,7 +491,7 @@ func (x *HTTPGetAction) String() string { func (*HTTPGetAction) ProtoMessage() {} func (x *HTTPGetAction) ProtoReflect() protoreflect.Message { - mi := &file_ateom_proto_msgTypes[5] + mi := &file_ateom_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -468,7 +504,7 @@ func (x *HTTPGetAction) ProtoReflect() protoreflect.Message { // Deprecated: Use HTTPGetAction.ProtoReflect.Descriptor instead. func (*HTTPGetAction) Descriptor() ([]byte, []int) { - return file_ateom_proto_rawDescGZIP(), []int{5} + return file_ateom_proto_rawDescGZIP(), []int{6} } func (x *HTTPGetAction) GetPath() string { @@ -493,7 +529,7 @@ type RunWorkloadResponse struct { func (x *RunWorkloadResponse) Reset() { *x = RunWorkloadResponse{} - mi := &file_ateom_proto_msgTypes[6] + mi := &file_ateom_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -505,7 +541,7 @@ func (x *RunWorkloadResponse) String() string { func (*RunWorkloadResponse) ProtoMessage() {} func (x *RunWorkloadResponse) ProtoReflect() protoreflect.Message { - mi := &file_ateom_proto_msgTypes[6] + mi := &file_ateom_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -518,7 +554,7 @@ func (x *RunWorkloadResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RunWorkloadResponse.ProtoReflect.Descriptor instead. func (*RunWorkloadResponse) Descriptor() ([]byte, []int) { - return file_ateom_proto_rawDescGZIP(), []int{6} + return file_ateom_proto_rawDescGZIP(), []int{7} } type CheckpointWorkloadRequest struct { @@ -550,7 +586,7 @@ type CheckpointWorkloadRequest struct { func (x *CheckpointWorkloadRequest) Reset() { *x = CheckpointWorkloadRequest{} - mi := &file_ateom_proto_msgTypes[7] + mi := &file_ateom_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -562,7 +598,7 @@ func (x *CheckpointWorkloadRequest) String() string { func (*CheckpointWorkloadRequest) ProtoMessage() {} func (x *CheckpointWorkloadRequest) ProtoReflect() protoreflect.Message { - mi := &file_ateom_proto_msgTypes[7] + mi := &file_ateom_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -575,7 +611,7 @@ func (x *CheckpointWorkloadRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CheckpointWorkloadRequest.ProtoReflect.Descriptor instead. func (*CheckpointWorkloadRequest) Descriptor() ([]byte, []int) { - return file_ateom_proto_rawDescGZIP(), []int{7} + return file_ateom_proto_rawDescGZIP(), []int{8} } func (x *CheckpointWorkloadRequest) GetAtespace() string { @@ -660,7 +696,7 @@ type CheckpointWorkloadResponse struct { func (x *CheckpointWorkloadResponse) Reset() { *x = CheckpointWorkloadResponse{} - mi := &file_ateom_proto_msgTypes[8] + mi := &file_ateom_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -672,7 +708,7 @@ func (x *CheckpointWorkloadResponse) String() string { func (*CheckpointWorkloadResponse) ProtoMessage() {} func (x *CheckpointWorkloadResponse) ProtoReflect() protoreflect.Message { - mi := &file_ateom_proto_msgTypes[8] + mi := &file_ateom_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -685,7 +721,7 @@ func (x *CheckpointWorkloadResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CheckpointWorkloadResponse.ProtoReflect.Descriptor instead. func (*CheckpointWorkloadResponse) Descriptor() ([]byte, []int) { - return file_ateom_proto_rawDescGZIP(), []int{8} + return file_ateom_proto_rawDescGZIP(), []int{9} } func (x *CheckpointWorkloadResponse) GetSnapshotFiles() []string { @@ -696,16 +732,14 @@ func (x *CheckpointWorkloadResponse) GetSnapshotFiles() []string { } type RestoreWorkloadRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Atespace string `protobuf:"bytes,1,opt,name=atespace,proto3" json:"atespace,omitempty"` - ActorName string `protobuf:"bytes,2,opt,name=actor_name,json=actorName,proto3" json:"actor_name,omitempty"` - ActorUid string `protobuf:"bytes,3,opt,name=actor_uid,json=actorUid,proto3" json:"actor_uid,omitempty"` - // Actor resource version observed by ate-api when assigning this worker. - ActorVersion int64 `protobuf:"varint,11,opt,name=actor_version,json=actorVersion,proto3" json:"actor_version,omitempty"` - ActorTemplateNamespace string `protobuf:"bytes,4,opt,name=actor_template_namespace,json=actorTemplateNamespace,proto3" json:"actor_template_namespace,omitempty"` - ActorTemplateName string `protobuf:"bytes,5,opt,name=actor_template_name,json=actorTemplateName,proto3" json:"actor_template_name,omitempty"` - RunscPath string `protobuf:"bytes,6,opt,name=runsc_path,json=runscPath,proto3" json:"runsc_path,omitempty"` - Spec *WorkloadSpec `protobuf:"bytes,7,opt,name=spec,proto3" json:"spec,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Atespace string `protobuf:"bytes,1,opt,name=atespace,proto3" json:"atespace,omitempty"` + ActorName string `protobuf:"bytes,2,opt,name=actor_name,json=actorName,proto3" json:"actor_name,omitempty"` + ActorUid string `protobuf:"bytes,3,opt,name=actor_uid,json=actorUid,proto3" json:"actor_uid,omitempty"` + ActorTemplateNamespace string `protobuf:"bytes,4,opt,name=actor_template_namespace,json=actorTemplateNamespace,proto3" json:"actor_template_namespace,omitempty"` + ActorTemplateName string `protobuf:"bytes,5,opt,name=actor_template_name,json=actorTemplateName,proto3" json:"actor_template_name,omitempty"` + RunscPath string `protobuf:"bytes,6,opt,name=runsc_path,json=runscPath,proto3" json:"runsc_path,omitempty"` + Spec *WorkloadSpec `protobuf:"bytes,7,opt,name=spec,proto3" json:"spec,omitempty"` // The object storage URI prefix of the snapshot to restore. SnapshotUriPrefix string `protobuf:"bytes,8,opt,name=snapshot_uri_prefix,json=snapshotUriPrefix,proto3" json:"snapshot_uri_prefix,omitempty"` // runtime_asset_paths maps a runtime asset name to the local on-disk path @@ -713,9 +747,8 @@ type RestoreWorkloadRequest struct { RuntimeAssetPaths map[string]string `protobuf:"bytes,9,rep,name=runtime_asset_paths,json=runtimeAssetPaths,proto3" json:"runtime_asset_paths,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` // What content to restore from the snapshot. Scope SnapshotScope `protobuf:"varint,10,opt,name=scope,proto3,enum=ateom.SnapshotScope" json:"scope,omitempty"` - // Remote egress gateway selected for this activation. When absent, actor - // traffic uses direct egress instead of being redirected through atunnel. - EgressGatewayAddress *string `protobuf:"bytes,12,opt,name=egress_gateway_address,json=egressGatewayAddress,proto3,oneof" json:"egress_gateway_address,omitempty"` + // When absent, actor traffic uses direct egress instead of atunnel. + EgressGateway *EgressGateway `protobuf:"bytes,12,opt,name=egress_gateway,json=egressGateway,proto3" json:"egress_gateway,omitempty"` // The object storage URI prefix of the ActorTemplate's golden snapshot. // Set only when scope is SNAPSHOT_SCOPE_DATA_ON_GOLDEN. Mirrors the // snapshot_uri_prefix contract (field 8). @@ -726,7 +759,7 @@ type RestoreWorkloadRequest struct { func (x *RestoreWorkloadRequest) Reset() { *x = RestoreWorkloadRequest{} - mi := &file_ateom_proto_msgTypes[9] + mi := &file_ateom_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -738,7 +771,7 @@ func (x *RestoreWorkloadRequest) String() string { func (*RestoreWorkloadRequest) ProtoMessage() {} func (x *RestoreWorkloadRequest) ProtoReflect() protoreflect.Message { - mi := &file_ateom_proto_msgTypes[9] + mi := &file_ateom_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -751,7 +784,7 @@ func (x *RestoreWorkloadRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RestoreWorkloadRequest.ProtoReflect.Descriptor instead. func (*RestoreWorkloadRequest) Descriptor() ([]byte, []int) { - return file_ateom_proto_rawDescGZIP(), []int{9} + return file_ateom_proto_rawDescGZIP(), []int{10} } func (x *RestoreWorkloadRequest) GetAtespace() string { @@ -775,13 +808,6 @@ func (x *RestoreWorkloadRequest) GetActorUid() string { return "" } -func (x *RestoreWorkloadRequest) GetActorVersion() int64 { - if x != nil { - return x.ActorVersion - } - return 0 -} - func (x *RestoreWorkloadRequest) GetActorTemplateNamespace() string { if x != nil { return x.ActorTemplateNamespace @@ -831,11 +857,11 @@ func (x *RestoreWorkloadRequest) GetScope() SnapshotScope { return SnapshotScope_SNAPSHOT_SCOPE_UNSPECIFIED } -func (x *RestoreWorkloadRequest) GetEgressGatewayAddress() string { - if x != nil && x.EgressGatewayAddress != nil { - return *x.EgressGatewayAddress +func (x *RestoreWorkloadRequest) GetEgressGateway() *EgressGateway { + if x != nil { + return x.EgressGateway } - return "" + return nil } func (x *RestoreWorkloadRequest) GetGoldenSnapshotUriPrefix() string { @@ -853,7 +879,7 @@ type RestoreWorkloadResponse struct { func (x *RestoreWorkloadResponse) Reset() { *x = RestoreWorkloadResponse{} - mi := &file_ateom_proto_msgTypes[10] + mi := &file_ateom_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -865,7 +891,7 @@ func (x *RestoreWorkloadResponse) String() string { func (*RestoreWorkloadResponse) ProtoMessage() {} func (x *RestoreWorkloadResponse) ProtoReflect() protoreflect.Message { - mi := &file_ateom_proto_msgTypes[10] + mi := &file_ateom_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -878,32 +904,32 @@ func (x *RestoreWorkloadResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RestoreWorkloadResponse.ProtoReflect.Descriptor instead. func (*RestoreWorkloadResponse) Descriptor() ([]byte, []int) { - return file_ateom_proto_rawDescGZIP(), []int{10} + return file_ateom_proto_rawDescGZIP(), []int{11} } var File_ateom_proto protoreflect.FileDescriptor const file_ateom_proto_rawDesc = "" + "\n" + - "\vateom.proto\x12\x05ateom\"\xc1\x04\n" + + "\vateom.proto\x12\x05ateom\"\x83\x04\n" + "\x12RunWorkloadRequest\x12\x1a\n" + "\batespace\x18\x01 \x01(\tR\batespace\x12\x1d\n" + "\n" + "actor_name\x18\x02 \x01(\tR\tactorName\x12\x1b\n" + - "\tactor_uid\x18\x03 \x01(\tR\bactorUid\x12#\n" + - "\ractor_version\x18\t \x01(\x03R\factorVersion\x128\n" + + "\tactor_uid\x18\x03 \x01(\tR\bactorUid\x128\n" + "\x18actor_template_namespace\x18\x04 \x01(\tR\x16actorTemplateNamespace\x12.\n" + "\x13actor_template_name\x18\x05 \x01(\tR\x11actorTemplateName\x12\x1d\n" + "\n" + "runsc_path\x18\x06 \x01(\tR\trunscPath\x12'\n" + "\x04spec\x18\a \x01(\v2\x13.ateom.WorkloadSpecR\x04spec\x12`\n" + - "\x13runtime_asset_paths\x18\b \x03(\v20.ateom.RunWorkloadRequest.RuntimeAssetPathsEntryR\x11runtimeAssetPaths\x129\n" + - "\x16egress_gateway_address\x18\n" + - " \x01(\tH\x00R\x14egressGatewayAddress\x88\x01\x01\x1aD\n" + + "\x13runtime_asset_paths\x18\b \x03(\v20.ateom.RunWorkloadRequest.RuntimeAssetPathsEntryR\x11runtimeAssetPaths\x12;\n" + + "\x0eegress_gateway\x18\n" + + " \x01(\v2\x14.ateom.EgressGatewayR\regressGateway\x1aD\n" + "\x16RuntimeAssetPathsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\x19\n" + - "\x17_egress_gateway_address\"@\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\")\n" + + "\rEgressGateway\x12\x18\n" + + "\aaddress\x18\x01 \x01(\tR\aaddress\"@\n" + "\fWorkloadSpec\x120\n" + "\n" + "containers\x18\x01 \x03(\v2\x10.ateom.ContainerR\n" + @@ -941,13 +967,12 @@ const file_ateom_proto_rawDesc = "" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"C\n" + "\x1aCheckpointWorkloadResponse\x12%\n" + - "\x0esnapshot_files\x18\x01 \x03(\tR\rsnapshotFiles\"\xe2\x05\n" + + "\x0esnapshot_files\x18\x01 \x03(\tR\rsnapshotFiles\"\xa4\x05\n" + "\x16RestoreWorkloadRequest\x12\x1a\n" + "\batespace\x18\x01 \x01(\tR\batespace\x12\x1d\n" + "\n" + "actor_name\x18\x02 \x01(\tR\tactorName\x12\x1b\n" + - "\tactor_uid\x18\x03 \x01(\tR\bactorUid\x12#\n" + - "\ractor_version\x18\v \x01(\x03R\factorVersion\x128\n" + + "\tactor_uid\x18\x03 \x01(\tR\bactorUid\x128\n" + "\x18actor_template_namespace\x18\x04 \x01(\tR\x16actorTemplateNamespace\x12.\n" + "\x13actor_template_name\x18\x05 \x01(\tR\x11actorTemplateName\x12\x1d\n" + "\n" + @@ -956,13 +981,12 @@ const file_ateom_proto_rawDesc = "" + "\x13snapshot_uri_prefix\x18\b \x01(\tR\x11snapshotUriPrefix\x12d\n" + "\x13runtime_asset_paths\x18\t \x03(\v24.ateom.RestoreWorkloadRequest.RuntimeAssetPathsEntryR\x11runtimeAssetPaths\x12*\n" + "\x05scope\x18\n" + - " \x01(\x0e2\x14.ateom.SnapshotScopeR\x05scope\x129\n" + - "\x16egress_gateway_address\x18\f \x01(\tH\x00R\x14egressGatewayAddress\x88\x01\x01\x12;\n" + + " \x01(\x0e2\x14.ateom.SnapshotScopeR\x05scope\x12;\n" + + "\x0eegress_gateway\x18\f \x01(\v2\x14.ateom.EgressGatewayR\regressGateway\x12;\n" + "\x1agolden_snapshot_uri_prefix\x18\r \x01(\tR\x17goldenSnapshotUriPrefix\x1aD\n" + "\x16RuntimeAssetPathsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\x19\n" + - "\x17_egress_gateway_address\"\x19\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x19\n" + "\x17RestoreWorkloadResponse*\x84\x01\n" + "\rSnapshotScope\x12\x1e\n" + "\x1aSNAPSHOT_SCOPE_UNSPECIFIED\x10\x00\x12\x17\n" + @@ -987,48 +1011,51 @@ func file_ateom_proto_rawDescGZIP() []byte { } var file_ateom_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_ateom_proto_msgTypes = make([]protoimpl.MessageInfo, 14) +var file_ateom_proto_msgTypes = make([]protoimpl.MessageInfo, 15) var file_ateom_proto_goTypes = []any{ (SnapshotScope)(0), // 0: ateom.SnapshotScope (*RunWorkloadRequest)(nil), // 1: ateom.RunWorkloadRequest - (*WorkloadSpec)(nil), // 2: ateom.WorkloadSpec - (*Container)(nil), // 3: ateom.Container - (*DurableDirVolumeMount)(nil), // 4: ateom.DurableDirVolumeMount - (*Readyz)(nil), // 5: ateom.Readyz - (*HTTPGetAction)(nil), // 6: ateom.HTTPGetAction - (*RunWorkloadResponse)(nil), // 7: ateom.RunWorkloadResponse - (*CheckpointWorkloadRequest)(nil), // 8: ateom.CheckpointWorkloadRequest - (*CheckpointWorkloadResponse)(nil), // 9: ateom.CheckpointWorkloadResponse - (*RestoreWorkloadRequest)(nil), // 10: ateom.RestoreWorkloadRequest - (*RestoreWorkloadResponse)(nil), // 11: ateom.RestoreWorkloadResponse - nil, // 12: ateom.RunWorkloadRequest.RuntimeAssetPathsEntry - nil, // 13: ateom.CheckpointWorkloadRequest.RuntimeAssetPathsEntry - nil, // 14: ateom.RestoreWorkloadRequest.RuntimeAssetPathsEntry + (*EgressGateway)(nil), // 2: ateom.EgressGateway + (*WorkloadSpec)(nil), // 3: ateom.WorkloadSpec + (*Container)(nil), // 4: ateom.Container + (*DurableDirVolumeMount)(nil), // 5: ateom.DurableDirVolumeMount + (*Readyz)(nil), // 6: ateom.Readyz + (*HTTPGetAction)(nil), // 7: ateom.HTTPGetAction + (*RunWorkloadResponse)(nil), // 8: ateom.RunWorkloadResponse + (*CheckpointWorkloadRequest)(nil), // 9: ateom.CheckpointWorkloadRequest + (*CheckpointWorkloadResponse)(nil), // 10: ateom.CheckpointWorkloadResponse + (*RestoreWorkloadRequest)(nil), // 11: ateom.RestoreWorkloadRequest + (*RestoreWorkloadResponse)(nil), // 12: ateom.RestoreWorkloadResponse + nil, // 13: ateom.RunWorkloadRequest.RuntimeAssetPathsEntry + nil, // 14: ateom.CheckpointWorkloadRequest.RuntimeAssetPathsEntry + nil, // 15: ateom.RestoreWorkloadRequest.RuntimeAssetPathsEntry } var file_ateom_proto_depIdxs = []int32{ - 2, // 0: ateom.RunWorkloadRequest.spec:type_name -> ateom.WorkloadSpec - 12, // 1: ateom.RunWorkloadRequest.runtime_asset_paths:type_name -> ateom.RunWorkloadRequest.RuntimeAssetPathsEntry - 3, // 2: ateom.WorkloadSpec.containers:type_name -> ateom.Container - 5, // 3: ateom.Container.readyz:type_name -> ateom.Readyz - 4, // 4: ateom.Container.durable_dir_volume_mounts:type_name -> ateom.DurableDirVolumeMount - 6, // 5: ateom.Readyz.http_get:type_name -> ateom.HTTPGetAction - 2, // 6: ateom.CheckpointWorkloadRequest.spec:type_name -> ateom.WorkloadSpec - 13, // 7: ateom.CheckpointWorkloadRequest.runtime_asset_paths:type_name -> ateom.CheckpointWorkloadRequest.RuntimeAssetPathsEntry - 0, // 8: ateom.CheckpointWorkloadRequest.scope:type_name -> ateom.SnapshotScope - 2, // 9: ateom.RestoreWorkloadRequest.spec:type_name -> ateom.WorkloadSpec - 14, // 10: ateom.RestoreWorkloadRequest.runtime_asset_paths:type_name -> ateom.RestoreWorkloadRequest.RuntimeAssetPathsEntry - 0, // 11: ateom.RestoreWorkloadRequest.scope:type_name -> ateom.SnapshotScope - 1, // 12: ateom.Ateom.RunWorkload:input_type -> ateom.RunWorkloadRequest - 8, // 13: ateom.Ateom.CheckpointWorkload:input_type -> ateom.CheckpointWorkloadRequest - 10, // 14: ateom.Ateom.RestoreWorkload:input_type -> ateom.RestoreWorkloadRequest - 7, // 15: ateom.Ateom.RunWorkload:output_type -> ateom.RunWorkloadResponse - 9, // 16: ateom.Ateom.CheckpointWorkload:output_type -> ateom.CheckpointWorkloadResponse - 11, // 17: ateom.Ateom.RestoreWorkload:output_type -> ateom.RestoreWorkloadResponse - 15, // [15:18] is the sub-list for method output_type - 12, // [12:15] is the sub-list for method input_type - 12, // [12:12] is the sub-list for extension type_name - 12, // [12:12] is the sub-list for extension extendee - 0, // [0:12] is the sub-list for field type_name + 3, // 0: ateom.RunWorkloadRequest.spec:type_name -> ateom.WorkloadSpec + 13, // 1: ateom.RunWorkloadRequest.runtime_asset_paths:type_name -> ateom.RunWorkloadRequest.RuntimeAssetPathsEntry + 2, // 2: ateom.RunWorkloadRequest.egress_gateway:type_name -> ateom.EgressGateway + 4, // 3: ateom.WorkloadSpec.containers:type_name -> ateom.Container + 6, // 4: ateom.Container.readyz:type_name -> ateom.Readyz + 5, // 5: ateom.Container.durable_dir_volume_mounts:type_name -> ateom.DurableDirVolumeMount + 7, // 6: ateom.Readyz.http_get:type_name -> ateom.HTTPGetAction + 3, // 7: ateom.CheckpointWorkloadRequest.spec:type_name -> ateom.WorkloadSpec + 14, // 8: ateom.CheckpointWorkloadRequest.runtime_asset_paths:type_name -> ateom.CheckpointWorkloadRequest.RuntimeAssetPathsEntry + 0, // 9: ateom.CheckpointWorkloadRequest.scope:type_name -> ateom.SnapshotScope + 3, // 10: ateom.RestoreWorkloadRequest.spec:type_name -> ateom.WorkloadSpec + 15, // 11: ateom.RestoreWorkloadRequest.runtime_asset_paths:type_name -> ateom.RestoreWorkloadRequest.RuntimeAssetPathsEntry + 0, // 12: ateom.RestoreWorkloadRequest.scope:type_name -> ateom.SnapshotScope + 2, // 13: ateom.RestoreWorkloadRequest.egress_gateway:type_name -> ateom.EgressGateway + 1, // 14: ateom.Ateom.RunWorkload:input_type -> ateom.RunWorkloadRequest + 9, // 15: ateom.Ateom.CheckpointWorkload:input_type -> ateom.CheckpointWorkloadRequest + 11, // 16: ateom.Ateom.RestoreWorkload:input_type -> ateom.RestoreWorkloadRequest + 8, // 17: ateom.Ateom.RunWorkload:output_type -> ateom.RunWorkloadResponse + 10, // 18: ateom.Ateom.CheckpointWorkload:output_type -> ateom.CheckpointWorkloadResponse + 12, // 19: ateom.Ateom.RestoreWorkload:output_type -> ateom.RestoreWorkloadResponse + 17, // [17:20] is the sub-list for method output_type + 14, // [14:17] is the sub-list for method input_type + 14, // [14:14] is the sub-list for extension type_name + 14, // [14:14] is the sub-list for extension extendee + 0, // [0:14] is the sub-list for field type_name } func init() { file_ateom_proto_init() } @@ -1036,15 +1063,13 @@ func file_ateom_proto_init() { if File_ateom_proto != nil { return } - file_ateom_proto_msgTypes[0].OneofWrappers = []any{} - file_ateom_proto_msgTypes[9].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_ateom_proto_rawDesc), len(file_ateom_proto_rawDesc)), NumEnums: 1, - NumMessages: 14, + NumMessages: 15, NumExtensions: 0, NumServices: 1, }, diff --git a/internal/proto/ateompb/ateom.proto b/internal/proto/ateompb/ateom.proto index 9a2176e38..ed31ee971 100644 --- a/internal/proto/ateompb/ateom.proto +++ b/internal/proto/ateompb/ateom.proto @@ -51,9 +51,6 @@ message RunWorkloadRequest { string atespace = 1; string actor_name = 2; string actor_uid = 3; - // Actor resource version observed by ate-api when assigning this worker. - int64 actor_version = 9; - string actor_template_namespace = 4; string actor_template_name = 5; @@ -67,9 +64,14 @@ message RunWorkloadRequest { // runsc_path). Empty for the gVisor runtime, which uses runsc_path. map runtime_asset_paths = 8; - // Remote egress gateway selected for this activation. When absent, actor - // traffic uses direct egress instead of being redirected through atunnel. - optional string egress_gateway_address = 10; + // When absent, actor traffic uses direct egress instead of atunnel. + EgressGateway egress_gateway = 10; +} + +// EgressGateway configures tunneled egress for one actor activation. +message EgressGateway { + // address is the remote gateway's host:port. + string address = 1; } // WorkloadSpec parallels Pod, but with far fewer configurable fields. @@ -176,9 +178,6 @@ message RestoreWorkloadRequest { string atespace = 1; string actor_name = 2; string actor_uid = 3; - // Actor resource version observed by ate-api when assigning this worker. - int64 actor_version = 11; - string actor_template_namespace = 4; string actor_template_name = 5; @@ -196,9 +195,8 @@ message RestoreWorkloadRequest { // What content to restore from the snapshot. SnapshotScope scope = 10; - // Remote egress gateway selected for this activation. When absent, actor - // traffic uses direct egress instead of being redirected through atunnel. - optional string egress_gateway_address = 12; + // When absent, actor traffic uses direct egress instead of atunnel. + EgressGateway egress_gateway = 12; // The object storage URI prefix of the ActorTemplate's golden snapshot. // Set only when scope is SNAPSHOT_SCOPE_DATA_ON_GOLDEN. Mirrors the diff --git a/manifests/ate-install/atelet.yaml b/manifests/ate-install/atelet.yaml index b8e057a86..4fab73edc 100644 --- a/manifests/ate-install/atelet.yaml +++ b/manifests/ate-install/atelet.yaml @@ -71,6 +71,7 @@ spec: - --gcp-auth-for-image-pulls=true - --grpc-server-cred-bundle=/run/podidentity.podcert.ate.dev/credential-bundle.pem - --client-ca-certs=/run/podidentity.podcert.ate.dev/trust-bundle.pem + - --ateapi-ca-file=/run/servicedns.podcert.ate.dev/trust-bundle.pem # atelet does no mounts, netlink, device, or namespace operations (those # live in the ateom worker pod) — it only reads/writes the # /var/lib/ateom-gvisor hostPath as root, so it needs no Linux @@ -87,10 +88,6 @@ spec: drop: - ALL env: - - name: MY_NODE_NAME - valueFrom: - fieldRef: - fieldPath: spec.nodeName - name: POD_NAME valueFrom: fieldRef: @@ -122,6 +119,9 @@ spec: mountPath: /var/lib/ateom-gvisor - name: podidentity mountPath: /run/podidentity.podcert.ate.dev + - name: servicedns-ca + mountPath: /run/servicedns.podcert.ate.dev + readOnly: true volumes: - name: run-ateom hostPath: @@ -144,3 +144,12 @@ spec: matchLabels: podcert.ate.dev/canarying: live path: trust-bundle.pem + - name: servicedns-ca + projected: + sources: + - clusterTrustBundle: + signerName: servicedns.podcert.ate.dev/identity + labelSelector: + matchLabels: + podcert.ate.dev/canarying: live + path: trust-bundle.pem diff --git a/pkg/proto/ateapipb/ateapi.pb.go b/pkg/proto/ateapipb/ateapi.pb.go index 557dad7da..3e27ec223 100644 --- a/pkg/proto/ateapipb/ateapi.pb.go +++ b/pkg/proto/ateapipb/ateapi.pb.go @@ -2847,8 +2847,11 @@ type MintCertRequest struct { // The signer will ignore the contents of the CSR except to extract the // subject public key. CertificateSigningRequest []byte `protobuf:"bytes,4,opt,name=certificate_signing_request,json=certificateSigningRequest,proto3" json:"certificate_signing_request,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Exact worker Pod requesting the certificate. Ateapi verifies that this + // worker is still assigned to the actor before signing. + WorkerPodUid string `protobuf:"bytes,5,opt,name=worker_pod_uid,json=workerPodUid,proto3" json:"worker_pod_uid,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *MintCertRequest) Reset() { @@ -2909,6 +2912,13 @@ func (x *MintCertRequest) GetCertificateSigningRequest() []byte { return nil } +func (x *MintCertRequest) GetWorkerPodUid() string { + if x != nil { + return x.WorkerPodUid + } + return "" +} + type MintCertResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // Response contains a list of DER encoded certificates. The first entry is the @@ -3152,13 +3162,14 @@ const file_ateapi_proto_rawDesc = "" + "actor_name\x18\x03 \x01(\tR\tactorName\x12\x1b\n" + "\tactor_uid\x18\x04 \x01(\tR\bactorUid\".\n" + "\x0fMintJWTResponse\x12\x1b\n" + - "\tactor_jwt\x18\x01 \x01(\tR\bactorJwt\"\xa9\x01\n" + + "\tactor_jwt\x18\x01 \x01(\tR\bactorJwt\"\xcf\x01\n" + "\x0fMintCertRequest\x12\x1a\n" + "\batespace\x18\x01 \x01(\tR\batespace\x12\x1d\n" + "\n" + "actor_name\x18\x02 \x01(\tR\tactorName\x12\x1b\n" + "\tactor_uid\x18\x03 \x01(\tR\bactorUid\x12>\n" + - "\x1bcertificate_signing_request\x18\x04 \x01(\fR\x19certificateSigningRequest\"A\n" + + "\x1bcertificate_signing_request\x18\x04 \x01(\fR\x19certificateSigningRequest\x12$\n" + + "\x0eworker_pod_uid\x18\x05 \x01(\tR\fworkerPodUid\"A\n" + "\x10MintCertResponse\x12-\n" + "\x12actor_certificates\x18\x01 \x03(\fR\x11actorCertificates*\x80\x01\n" + "\x14SnapshotContentScope\x12&\n" + diff --git a/pkg/proto/ateapipb/ateapi.proto b/pkg/proto/ateapipb/ateapi.proto index 1324804d8..a58331bb2 100644 --- a/pkg/proto/ateapipb/ateapi.proto +++ b/pkg/proto/ateapipb/ateapi.proto @@ -473,20 +473,10 @@ message DebugClearResponse {} // ActorIdentity allows substrate workloads to exchange their // infrastructure-level credentials (k8s service account token, etc.) for a -// substrate actor-level credential. A given substrate actor might migrate +// substrate actor-level credential. A given substrate actor might migrate // between many different physical workers over the course of its lifecycle, // whereas the actor credential's identity will be stable for the life of the // actor. -// -// This service requires authentication. You can authenticate with a Kubernetes -// service account token in an `Authorization: Bearer` header, or you can -// authenticate with a Kubernetes service account certificate as an mTLS -// certificate. (Kubernetes service account certificates do not currently exist -// upstream, but we will provide a polyfill based on Pod Certificates). -// -// The broker will check that the service credentials you authenticated with -// belong to a Pod that is currently mapped to the requested actor in the -// actor database. service ActorIdentity { // Request an Actor Identity JWT. // @@ -549,6 +539,10 @@ message MintCertRequest { // The signer will ignore the contents of the CSR except to extract the // subject public key. bytes certificate_signing_request = 4; + + // Exact worker Pod requesting the certificate. Ateapi verifies that this + // worker is still assigned to the actor before signing. + string worker_pod_uid = 5; } message MintCertResponse { diff --git a/pkg/proto/ateapipb/ateapi_grpc.pb.go b/pkg/proto/ateapipb/ateapi_grpc.pb.go index 3db1d0bfa..80b49c1e0 100644 --- a/pkg/proto/ateapipb/ateapi_grpc.pb.go +++ b/pkg/proto/ateapipb/ateapi_grpc.pb.go @@ -945,20 +945,10 @@ const ( // // ActorIdentity allows substrate workloads to exchange their // infrastructure-level credentials (k8s service account token, etc.) for a -// substrate actor-level credential. A given substrate actor might migrate +// substrate actor-level credential. A given substrate actor might migrate // between many different physical workers over the course of its lifecycle, // whereas the actor credential's identity will be stable for the life of the // actor. -// -// This service requires authentication. You can authenticate with a Kubernetes -// service account token in an `Authorization: Bearer` header, or you can -// authenticate with a Kubernetes service account certificate as an mTLS -// certificate. (Kubernetes service account certificates do not currently exist -// upstream, but we will provide a polyfill based on Pod Certificates). -// -// The broker will check that the service credentials you authenticated with -// belong to a Pod that is currently mapped to the requested actor in the -// actor database. type ActorIdentityClient interface { // Request an Actor Identity JWT. // @@ -1014,20 +1004,10 @@ func (c *actorIdentityClient) MintCert(ctx context.Context, in *MintCertRequest, // // ActorIdentity allows substrate workloads to exchange their // infrastructure-level credentials (k8s service account token, etc.) for a -// substrate actor-level credential. A given substrate actor might migrate +// substrate actor-level credential. A given substrate actor might migrate // between many different physical workers over the course of its lifecycle, // whereas the actor credential's identity will be stable for the life of the // actor. -// -// This service requires authentication. You can authenticate with a Kubernetes -// service account token in an `Authorization: Bearer` header, or you can -// authenticate with a Kubernetes service account certificate as an mTLS -// certificate. (Kubernetes service account certificates do not currently exist -// upstream, but we will provide a polyfill based on Pod Certificates). -// -// The broker will check that the service credentials you authenticated with -// belong to a Pod that is currently mapped to the requested actor in the -// actor database. type ActorIdentityServer interface { // Request an Actor Identity JWT. //