Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# 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.

name: release

on:
workflow_dispatch:
inputs:
tag:
description: 'Image tag (e.g. v1.2.3-rc1). Leave blank to auto-generate from branch+SHA.'
required: false
create_release:
description: 'Create a GitHub release'
type: boolean
default: false

permissions:
contents: write
packages: write

jobs:
release:
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Validate and resolve tag
id: tag
run: |
TAG="${{ inputs.tag }}"
if [[ -z "${TAG}" ]]; then
BRANCH="${GITHUB_REF_NAME//\//-}"
SHA="$(git rev-parse --short HEAD)"
TAG="${BRANCH}-${SHA}"
fi
if [[ "${{ inputs.create_release }}" == "true" ]]; then
if [[ ! "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9._-]+)?$ ]]; then
echo "::error::Tag '${TAG}' must match vMAJOR.MINOR.PATCH[-prerelease] when creating a release (e.g. v1.2.3 or v1.2.3-rc1)"
exit 1
fi
fi
echo "value=${TAG}" >> "$GITHUB_OUTPUT"
if [[ "${{ inputs.create_release }}" == "true" ]]; then
echo "tags=${TAG},latest" >> "$GITHUB_OUTPUT"
else
echo "tags=${TAG}" >> "$GITHUB_OUTPUT"
fi

- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: 'go.mod'

- name: Install ko
uses: ko-build/setup-ko@v0.7

- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Set up QEMU (multi-arch)
uses: docker/setup-qemu-action@v3

- name: Build and push images
env:
# ghcr.io/<owner>/<repo> — resolves correctly in forks
KO_DOCKER_REPO: ghcr.io/${{ github.repository }}
run: |
./hack/run-tool.sh ko build \
--tags "${{ steps.tag.outputs.tags }}" \
--platform linux/amd64,linux/arm64 \
--bare \
./cmd/ateapi \
./cmd/atelet \
./cmd/podcertcontroller \
./cmd/atenet

- name: Create GitHub Release
if: inputs.create_release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.tag.outputs.value }}
generate_release_notes: true
128 changes: 115 additions & 13 deletions cmd/ateapi/internal/controlapi/functional_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/testing/protocmp"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
Expand Down Expand Up @@ -146,24 +147,30 @@ type FakeAteletServer struct {

Lock sync.Mutex

RunCalled bool
RunCalled bool
RunRequest *ateletpb.RunRequest

CheckpointCalled bool
CheckpointCalled bool
CheckpointRequest *ateletpb.CheckpointRequest

RestoreCalled bool
FailRestore error
RestoreDelay time.Duration
RestoreCalled bool
RestoreRequest *ateletpb.RestoreRequest
FailRestore error
RestoreDelay time.Duration
}

func (f *FakeAteletServer) Reset() {
f.Lock.Lock()
defer f.Lock.Unlock()

f.RunCalled = false
f.RunRequest = nil

f.CheckpointCalled = false
f.CheckpointRequest = nil

f.RestoreCalled = false
f.RestoreRequest = nil
f.FailRestore = nil
f.RestoreDelay = 0
}
Expand All @@ -173,6 +180,7 @@ func (f *FakeAteletServer) Run(ctx context.Context, req *ateletpb.RunRequest) (*
defer f.Lock.Unlock()

f.RunCalled = true
f.RunRequest = proto.Clone(req).(*ateletpb.RunRequest)

return &ateletpb.RunResponse{}, nil
}
Expand All @@ -182,6 +190,7 @@ func (f *FakeAteletServer) Checkpoint(ctx context.Context, req *ateletpb.Checkpo
defer f.Lock.Unlock()

f.CheckpointCalled = true
f.CheckpointRequest = proto.Clone(req).(*ateletpb.CheckpointRequest)

return &ateletpb.CheckpointResponse{}, nil
}
Expand All @@ -191,6 +200,7 @@ func (f *FakeAteletServer) Restore(ctx context.Context, req *ateletpb.RestoreReq
defer f.Lock.Unlock()

f.RestoreCalled = true
f.RestoreRequest = proto.Clone(req).(*ateletpb.RestoreRequest)
if f.RestoreDelay > 0 {
time.Sleep(f.RestoreDelay)
}
Expand All @@ -200,6 +210,16 @@ func (f *FakeAteletServer) Restore(ctx context.Context, req *ateletpb.RestoreReq
return &ateletpb.RestoreResponse{}, nil
}

func (f *FakeAteletServer) lastRestoreRequest() *ateletpb.RestoreRequest {
f.Lock.Lock()
defer f.Lock.Unlock()

if f.RestoreRequest == nil {
return nil
}
return proto.Clone(f.RestoreRequest).(*ateletpb.RestoreRequest)
}

type testContext struct {
mr *miniredis.Miniredis
service *Service
Expand Down Expand Up @@ -261,7 +281,7 @@ func setupTest(t *testing.T, ns string) *testContext {

// 4. Initialize Service
dialer := NewAteletDialer(workerInformer.GetIndexer(), ateletInformer.GetIndexer())
service := NewService(persistence, actorTemplateLister, dialer)
service := NewService(persistence, actorTemplateLister, dialer, k8sClient)

// 5. Start REAL gRPC Server for ATE API
grpcServer := grpc.NewServer(grpc.UnaryInterceptor(ateinterceptors.ServerUnaryInterceptor))
Expand Down Expand Up @@ -330,6 +350,17 @@ func namespaceForTest(baseName string) string {
}

func createTemplate(t *testing.T, tc *testContext, ns string) {
t.Helper()
createTemplateWithContainers(t, tc, ns, []atev1alpha1.Container{
{
Name: "main",
Image: "main@sha256:abc",
Command: []string{"/main"},
},
})
}

func createTemplateWithContainers(t *testing.T, tc *testContext, ns string, containers []atev1alpha1.Container) {
t.Helper()
actorTemplate := &atev1alpha1.ActorTemplate{
ObjectMeta: metav1.ObjectMeta{
Expand All @@ -344,13 +375,7 @@ func createTemplate(t *testing.T, tc *testContext, ns string) {
},
},
PauseImage: "pause@sha256:abc",
Containers: []atev1alpha1.Container{
{
Name: "main",
Image: "main@sha256:abc",
Command: []string{"/main"},
},
},
Containers: containers,
WorkerPoolRef: corev1.ObjectReference{
Namespace: ns,
Name: "pool1",
Expand Down Expand Up @@ -846,6 +871,83 @@ func TestResumeActor(t *testing.T) {
}
}

func TestResumeActorResolvesValueFromEnv(t *testing.T) {
ns := namespaceForTest("ns-resume-secret-env")
tc := setupTest(t, ns)
defer tc.cleanup()

_, err := tc.k8sClient.CoreV1().Secrets(ns).Create(context.Background(), &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "api-keys",
Namespace: ns,
},
Data: map[string][]byte{
"anthropic": []byte("sk-test"),
},
}, metav1.CreateOptions{})
if err != nil {
t.Fatalf("failed to create secret: %v", err)
}

createTemplateWithContainers(t, tc, ns, []atev1alpha1.Container{
{
Name: "main",
Image: "main@sha256:abc",
Command: []string{"/main"},
Env: []atev1alpha1.EnvVar{
{
Name: "LITERAL",
Value: "plain",
},
{
Name: "ANTHROPIC_API_KEY",
ValueFrom: &atev1alpha1.EnvVarSource{
SecretKeyRef: &atev1alpha1.SecretKeySelector{
Name: "api-keys",
Key: "anthropic",
},
},
},
},
},
})
createWorkerPod(t, tc, ns, "worker-1", "node1")

_, err = tc.client.CreateActor(context.Background(), &ateapipb.CreateActorRequest{
ActorTemplateNamespace: ns,
ActorTemplateName: "tmpl1",
ActorId: "id1",
})
if err != nil {
t.Fatalf("CreateActor failed: %v", err)
}
_, err = tc.client.ResumeActor(context.Background(), &ateapipb.ResumeActorRequest{
ActorId: "id1",
})
if err != nil {
t.Fatalf("ResumeActor failed: %v", err)
}

restoreReq := tc.fakeAtelet.lastRestoreRequest()
if restoreReq == nil {
t.Fatalf("expected Restore to be called")
}
if len(restoreReq.GetSpec().GetContainers()) != 1 {
t.Fatalf("expected one container in restore request, got %d", len(restoreReq.GetSpec().GetContainers()))
}
gotEnv := map[string]string{}
for _, env := range restoreReq.GetSpec().GetContainers()[0].GetEnv() {
gotEnv[env.GetName()] = env.GetValue()
}
wantEnv := map[string]string{
"LITERAL": "plain",
"ANTHROPIC_API_KEY": "sk-test",
}
if diff := cmp.Diff(wantEnv, gotEnv); diff != "" {
t.Errorf("resolved env mismatch (-want +got):\n%s", diff)
}
}

// TestResumeActor_NoWorkers tests that resuming an actor fails when no free workers are available.
// Workflow:
// 1. Creates a mock ActorTemplate.
Expand Down
6 changes: 3 additions & 3 deletions cmd/ateapi/internal/controlapi/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ package controlapi
import (
"github.com/agent-substrate/substrate/cmd/ateapi/internal/store"
listersv1alpha1 "github.com/agent-substrate/substrate/pkg/client/listers/api/v1alpha1"

"github.com/agent-substrate/substrate/pkg/proto/ateapipb"
"k8s.io/client-go/kubernetes"
)

// Service implements ateapipb.Control
Expand All @@ -33,12 +33,12 @@ type Service struct {
var _ ateapipb.ControlServer = (*Service)(nil)

// NewService creates a service.
func NewService(persistence store.Interface, actorTemplateLister listersv1alpha1.ActorTemplateLister, dialer *AteletDialer) *Service {
func NewService(persistence store.Interface, actorTemplateLister listersv1alpha1.ActorTemplateLister, dialer *AteletDialer, kubeClient kubernetes.Interface) *Service {
s := &Service{
persistence: persistence,
actorTemplateLister: actorTemplateLister,
dialer: dialer,
actorWorkflow: NewActorWorkflow(persistence, dialer, actorTemplateLister),
actorWorkflow: NewActorWorkflow(persistence, dialer, actorTemplateLister, kubeClient),
}

return s
Expand Down
Loading
Loading