Skip to content

Readiness fallback uses the router Service as HTTP Host #387

Description

@gambtho

When the controller cannot reach a worker directly, it polls readiness through atenet-router. It sends ate-target-actor, but leaves the router Service as the HTTP Host. The router rejects that request, so WorkspaceReady remains false.

With default/task123 running, compare the responses:

kubectl -n ate-system port-forward svc/atenet-router 8001:80
curl -i -H 'ate-target-actor: default/task123' 'http://localhost:8001/readyz?check=workspace'
curl -i -H 'Host: task123.default.actors.resources.substrate.ate.dev' -H 'ate-target-actor: default/task123' 'http://localhost:8001/readyz?check=workspace'

The patch sets the actor DNS Host on the fallback, keeps the target header, and updates the networking examples. The regression test fails without the Host change and passes with it; make test passes.

Branch: https://github.com/gambtho/ax/tree/fix/readiness-actor-authority

diff --git a/docs/networking.md b/docs/networking.md
index 2e57e6e..368c55c 100644
--- a/docs/networking.md
+++ b/docs/networking.md
@@ -1,15 +1,16 @@
 # Networking
 
-Tasks do not get a Kubernetes Service or Ingress of their own. Every request to a task goes through Agent Substrate's **atenet router**, the `atenet-router` Service in the `ate-system` namespace. The router reads a single header, `ate-target-actor`, resolves the actor to the worker it is running on, resumes it first if it was suspended, and proxies the request there. `Host` and `:authority` are left alone for your application; the header alone selects the target.
+Tasks do not get a Kubernetes Service or Ingress of their own. Every request to a task goes through Agent Substrate's **atenet router**, the `atenet-router` Service in the `ate-system` namespace. The router requires the actor's stable DNS authority and accepts `ate-target-actor` as the explicit target identifier; it resumes the actor first if it was suspended, then proxies the request there.
 
-The header value is `<atespace>/<task>`. The controller always names a task's actor after the task, so `default/task123` reaches the task `task123` in the `default` atespace.
+The actor authority is `<task>.<atespace>.actors.resources.substrate.ate.dev`, and the target header is `<atespace>/<task>`. The controller always names a task's actor after the task, so both values below identify the task `task123` in the `default` atespace.
 
 ## From inside the cluster
 
-Use the Service DNS name and add the header. This is exactly how the controller polls a task's readiness.
+Use the Service DNS name and set the actor authority. This is exactly how the controller polls a task's readiness.
 
 ```bash
-curl -H "ate-target-actor: default/task123" \
+curl -H "Host: task123.default.actors.resources.substrate.ate.dev" \
+  -H "ate-target-actor: default/task123" \
   http://atenet-router.ate-system.svc.cluster.local/metadata/v1alpha1/ax/task
 ```
 
@@ -19,7 +20,8 @@ Port-forward the router, then talk to it the same way.
 
 ```bash
 kubectl -n ate-system port-forward svc/atenet-router 8001:80
-curl -H "ate-target-actor: default/task123" http://localhost:8001/readyz
+curl -H "Host: task123.default.actors.resources.substrate.ate.dev" \
+  -H "ate-target-actor: default/task123" http://localhost:8001/readyz
 ```
 
 ## gRPC request routing
diff --git a/internal/controller/reconciler.go b/internal/controller/reconciler.go
index 31c399b..1b16769 100644
--- a/internal/controller/reconciler.go
+++ b/internal/controller/reconciler.go
@@ -265,6 +265,7 @@ func (r *TaskReconciler) Reconcile(ctx context.Context, task *v1alpha1.Task, gat
 				rReq, _ := http.NewRequestWithContext(pollCtx, http.MethodGet, fmt.Sprintf("http://%s/readyz?check=workspace", routerAddr), nil)
 				if rReq != nil {
 					rReq.Header.Set("ate-target-actor", fmt.Sprintf("%s/%s", atespace, actorName))
+					rReq.Host = fmt.Sprintf("%s.%s.actors.resources.substrate.ate.dev", actorName, atespace)
 					if resp, err := r.httpClient.Do(rReq); err == nil {
 						_ = resp.Body.Close()
 						if resp.StatusCode == http.StatusOK {
diff --git a/internal/controller/reconciler_test.go b/internal/controller/reconciler_test.go
index e699ee0..5542d9d 100644
--- a/internal/controller/reconciler_test.go
+++ b/internal/controller/reconciler_test.go
@@ -18,6 +18,8 @@ import (
 	"context"
 	"net"
 	"net/http"
+	"net/http/httptest"
+	"strings"
 	"testing"
 	"time"
 
@@ -348,7 +350,39 @@ func TestTaskReconciler_WorkspaceReady(t *testing.T) {
 	assertCondition(t, reconciled, "WorkspaceReady", "False", "Initializing")
 	assertCondition(t, reconciled, "Ready", "False", "WorkspaceInitializing")
 
-	// Case 2: Worker readyz endpoint succeeds -> WorkspaceReady=True and Ready=True.
+	// Case 2: A worker address is not directly reachable, so readiness goes through
+	// atenet-router with the actor DNS authority and explicit target header.
+	router := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if got, want := r.Host, "ready-task.default.actors.resources.substrate.ate.dev"; got != want {
+			http.Error(w, "unexpected host "+got, http.StatusNotFound)
+			return
+		}
+		if got, want := r.Header.Get("ate-target-actor"), "default/ready-task"; got != want {
+			http.Error(w, "unexpected target "+got, http.StatusNotFound)
+			return
+		}
+		w.WriteHeader(http.StatusOK)
+	}))
+	defer router.Close()
+	t.Setenv("ATENET_ROUTER_ADDR", strings.TrimPrefix(router.URL, "http://"))
+	mockSrv.workerIP = "127.0.0.1:1"
+	reconciledViaRouter, err := reconciler.Reconcile(ctx, task, nil)
+	if err != nil {
+		t.Fatalf("Reconcile through atenet-router failed: %v", err)
+	}
+	assertCondition(t, reconciledViaRouter, "WorkspaceReady", "True", "SetupComplete")
+	assertCondition(t, reconciledViaRouter, "Ready", "True", "TaskRunning")
+
+	// Case 3: Worker readyz endpoint succeeds directly -> WorkspaceReady=True and Ready=True.
+	task = &v1alpha1.Task{
+		ApiVersion: v1alpha1.APIVersion,
+		Kind:       v1alpha1.KindTask,
+		Metadata: &v1alpha1.ObjectMeta{
+			Name:     "ready-task",
+			Atespace: "default",
+		},
+		Spec: &v1alpha1.TaskSpec{},
+	}
 	mockSrv.workerIP = httpLis.Addr().String()
 	reconciledReady, err := reconciler.Reconcile(ctx, task, nil)
 	if err != nil {
@@ -357,7 +391,7 @@ func TestTaskReconciler_WorkspaceReady(t *testing.T) {
 	assertCondition(t, reconciledReady, "WorkspaceReady", "True", "SetupComplete")
 	assertCondition(t, reconciledReady, "Ready", "True", "TaskRunning")
 
-	// Case 3: Suspending the task -> Ready=False (TaskSuspended), but the workspace was
+	// Case 4: Suspending the task -> Ready=False (TaskSuspended), but the workspace was
 	// already initialized so WorkspaceReady stays True.
 	task = reconciledReady
 	task.Spec.Suspend = true
@@ -371,7 +405,7 @@ func TestTaskReconciler_WorkspaceReady(t *testing.T) {
 	assertCondition(t, reconciledSuspended, "Ready", "False", "TaskSuspended")
 	assertCondition(t, reconciledSuspended, "WorkspaceReady", "True", "SetupComplete")
 
-	// Case 4: Resuming with the worker unreachable -> the reconciler trusts the recorded
+	// Case 5: Resuming with the worker unreachable -> the reconciler trusts the recorded
 	// WorkspaceReady instead of re-polling, so the task is Ready again immediately.
 	mockSrv.workerIP = "127.0.0.1:1"
 	task = reconciledSuspended

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions