Files
substrate/cmd/ateapi/internal/controlapi/workload_spec.go
T
Tim Hockin 68bb5efc8f Add more CRD validation (#205)
It's not all done but the fields we are playing with now are mostly
done.
2026-06-09 13:19:25 -04:00

198 lines
5.6 KiB
Go

// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package controlapi
import (
"context"
"fmt"
"sync"
"time"
"github.com/agent-substrate/substrate/internal/proto/ateletpb"
atev1alpha1 "github.com/agent-substrate/substrate/pkg/api/v1alpha1"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
)
const envSecretCacheTTL = 30 * time.Second
func workloadSpecFromActorTemplate(ctx context.Context, kubeClient kubernetes.Interface, secretCache *envSecretCache, actorTemplate *atev1alpha1.ActorTemplate) (*ateletpb.WorkloadSpec, error) {
workloadSpec := &ateletpb.WorkloadSpec{
PauseImage: actorTemplate.Spec.PauseImage,
}
resolver := envResolver{
kubeClient: kubeClient,
namespace: actorTemplate.Namespace,
cache: secretCache,
}
for _, ctr := range actorTemplate.Spec.Containers {
ateletCtr := &ateletpb.Container{
Name: ctr.Name,
Image: ctr.Image,
Command: ctr.Command,
}
for _, env := range ctr.Env {
ateletEnv, err := resolver.resolve(ctx, ctr.Name, env)
if err != nil {
return nil, err
}
if ateletEnv != nil {
ateletCtr.Env = append(ateletCtr.Env, ateletEnv)
}
}
workloadSpec.Containers = append(workloadSpec.Containers, ateletCtr)
}
return workloadSpec, nil
}
type envResolver struct {
kubeClient kubernetes.Interface
namespace string
cache *envSecretCache
}
func (r *envResolver) resolve(ctx context.Context, containerName string, env atev1alpha1.EnvVar) (*ateletpb.EnvEntry, error) {
envID := fmt.Sprintf("container %q env %q", containerName, env.Name)
switch {
case env.Value != nil:
return &ateletpb.EnvEntry{
Name: env.Name,
Value: *env.Value,
}, nil
case env.ValueFrom != nil:
value, include, err := r.resolveValueFrom(ctx, envID, env.ValueFrom)
if err != nil {
return nil, err
}
if !include {
return nil, nil
}
return &ateletpb.EnvEntry{
Name: env.Name,
Value: value,
}, nil
}
return nil, status.Errorf(codes.FailedPrecondition, "%s has unknown value source", envID)
}
func (r *envResolver) resolveValueFrom(ctx context.Context, envID string, valueFrom *atev1alpha1.EnvVarSource) (string, bool, error) {
if ref := valueFrom.SecretKeyRef; ref != nil {
return r.resolveSecretKeyRef(ctx, envID, ref)
}
return "", false, status.Errorf(codes.FailedPrecondition, "%s uses unsupported valueFrom source; only secretKeyRef is supported", envID)
}
func (r *envResolver) resolveSecretKeyRef(ctx context.Context, envID string, ref *atev1alpha1.SecretKeySelector) (string, bool, error) {
if r.kubeClient == nil {
return "", false, status.Errorf(codes.FailedPrecondition, "%s cannot resolve secretKeyRef because Kubernetes client is unavailable", envID)
}
secret, err := r.secret(ctx, ref.Name)
if err != nil {
if apierrors.IsNotFound(err) {
if isOptional(ref.Optional) {
return "", false, nil
}
return "", false, status.Errorf(codes.FailedPrecondition, "%s references missing secret %s/%s", envID, r.namespace, ref.Name)
}
return "", false, status.Errorf(codes.Internal, "while resolving %s secretKeyRef %s/%s: %v", envID, r.namespace, ref.Name, err)
}
value, ok := secret.Data[ref.Key]
if !ok {
if isOptional(ref.Optional) {
return "", false, nil
}
return "", false, status.Errorf(codes.FailedPrecondition, "%s references missing key %q in secret %s/%s", envID, ref.Key, r.namespace, ref.Name)
}
return string(value), true, nil
}
func (r *envResolver) secret(ctx context.Context, name string) (*corev1.Secret, error) {
if r.cache != nil {
return r.cache.get(ctx, r.kubeClient, r.namespace, name)
}
return r.kubeClient.CoreV1().Secrets(r.namespace).Get(ctx, name, metav1.GetOptions{})
}
type envSecretCache struct {
mu sync.RWMutex
ttl time.Duration
entries map[envSecretCacheKey]envSecretCacheEntry
}
type envSecretCacheKey struct {
namespace string
name string
}
type envSecretCacheEntry struct {
secret *corev1.Secret
expiresAt time.Time
}
func newEnvSecretCache(ttl time.Duration) *envSecretCache {
return &envSecretCache{
ttl: ttl,
entries: map[envSecretCacheKey]envSecretCacheEntry{},
}
}
func (c *envSecretCache) get(ctx context.Context, kubeClient kubernetes.Interface, namespace, name string) (*corev1.Secret, error) {
key := envSecretCacheKey{
namespace: namespace,
name: name,
}
now := time.Now()
c.mu.RLock()
entry, ok := c.entries[key]
if ok && now.Before(entry.expiresAt) {
secret := entry.secret.DeepCopy()
c.mu.RUnlock()
return secret, nil
}
c.mu.RUnlock()
// TODO: Make refresh smarter if this pattern sticks, for example by
// refreshing asynchronously or watching referenced Secrets.
secret, err := kubeClient.CoreV1().Secrets(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return nil, err
}
secret = secret.DeepCopy()
c.mu.Lock()
c.entries[key] = envSecretCacheEntry{
secret: secret,
expiresAt: time.Now().Add(c.ttl),
}
c.mu.Unlock()
return secret.DeepCopy(), nil
}
func isOptional(optional *bool) bool {
return optional != nil && *optional
}