feat: support egress probe task contracts
This commit is contained in:
parent
04c67d733c
commit
864320a2c8
@ -41,6 +41,29 @@ func TestRunnerExecutesBoundedTaskBatchAndReportsFacts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunnerOmitsEgressProbeURLFromGlobalObservation(t *testing.T) {
|
||||||
|
now := time.Date(2026, 8, 1, 13, 30, 0, 0, time.UTC)
|
||||||
|
task := checkerTask("task-egress", now)
|
||||||
|
task.Level = controlplanev1.CheckLevel_CHECK_LEVEL_EGRESS
|
||||||
|
task.TargetUrl = "https://egress.example/identity"
|
||||||
|
client := &clientStub{stream: &taskStreamStub{tasks: []*controlplanev1.CheckTask{task}}}
|
||||||
|
runner, err := NewRunner(client, &executorStub{}, Options{
|
||||||
|
CheckerID: "checker-a", InstanceID: "instance-a", MaxInFlight: 1,
|
||||||
|
SupportedLevels: []controlplanev1.CheckLevel{controlplanev1.CheckLevel_CHECK_LEVEL_EGRESS},
|
||||||
|
ReportBatchSize: 1, RetryDelay: time.Millisecond, Now: func() time.Time { return now },
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRunner(): %v", err)
|
||||||
|
}
|
||||||
|
if err := runner.RunOnce(context.Background()); err != nil {
|
||||||
|
t.Fatalf("RunOnce(): %v", err)
|
||||||
|
}
|
||||||
|
observation := client.batches[0].GetObservations()[0]
|
||||||
|
if observation.GetLevel() != controlplanev1.CheckLevel_CHECK_LEVEL_EGRESS || observation.GetTargetUrl() != "" || observation.GetRoutingName() != "" {
|
||||||
|
t.Fatalf("Observation = %+v, want global EGRESS fact without target profile", observation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func checkerTask(id string, now time.Time) *controlplanev1.CheckTask {
|
func checkerTask(id string, now time.Time) *controlplanev1.CheckTask {
|
||||||
return &controlplanev1.CheckTask{
|
return &controlplanev1.CheckTask{
|
||||||
TaskId: id, ProxyId: id + "-proxy", Protocol: controlplanev1.ProxyProtocol_PROXY_PROTOCOL_HTTP,
|
TaskId: id, ProxyId: id + "-proxy", Protocol: controlplanev1.ProxyProtocol_PROXY_PROTOCOL_HTTP,
|
||||||
|
|||||||
@ -136,6 +136,9 @@ func prepare(task *controlplanev1.CheckTask, now time.Time) (time.Time, string,
|
|||||||
}
|
}
|
||||||
target = task.GetTargetUrl()
|
target = task.GetTargetUrl()
|
||||||
case controlplanev1.CheckLevel_CHECK_LEVEL_EGRESS:
|
case controlplanev1.CheckLevel_CHECK_LEVEL_EGRESS:
|
||||||
|
if task.GetRoutingName() != "" || task.GetTargetUrl() == "" {
|
||||||
|
return time.Time{}, "", nil, errors.New("invalid egress task")
|
||||||
|
}
|
||||||
target = task.GetTargetUrl()
|
target = task.GetTargetUrl()
|
||||||
default:
|
default:
|
||||||
return time.Time{}, "", nil, errors.New("unsupported task level")
|
return time.Time{}, "", nil, errors.New("unsupported task level")
|
||||||
|
|||||||
@ -60,6 +60,20 @@ func TestExecutorTargetReportsHTTPFailureAsFact(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExecutorRejectsEgressTaskWithRoutingProfile(t *testing.T) {
|
||||||
|
proxy := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
|
||||||
|
response.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer proxy.Close()
|
||||||
|
task := validTask(t, proxy.URL, controlplanev1.CheckLevel_CHECK_LEVEL_EGRESS)
|
||||||
|
task.RoutingName = "route-a"
|
||||||
|
task.TargetUrl = "https://egress.example/identity"
|
||||||
|
result := NewExecutor().Execute(context.Background(), task)
|
||||||
|
if result.Success || result.FailureClass != FailureInvalidTask {
|
||||||
|
t.Fatalf("Execute(EGRESS with routing) = %+v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExecutorReportsUnsupportedProtocolAsFact(t *testing.T) {
|
func TestExecutorReportsUnsupportedProtocolAsFact(t *testing.T) {
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
result := NewExecutor().Execute(context.Background(), &controlplanev1.CheckTask{
|
result := NewExecutor().Execute(context.Background(), &controlplanev1.CheckTask{
|
||||||
|
|||||||
@ -239,13 +239,23 @@ func wireCheckTask(task LeasedTask, now time.Time) (*controlplanev1.CheckTask, e
|
|||||||
if !ok {
|
if !ok {
|
||||||
return nil, ErrInvalidLeasedTask
|
return nil, ErrInvalidLeasedTask
|
||||||
}
|
}
|
||||||
if task.Level == healthDomain.LevelTarget {
|
switch task.Level {
|
||||||
|
case healthDomain.LevelBasic:
|
||||||
|
if task.RoutingName != "" || task.TargetURL != "" {
|
||||||
|
return nil, ErrInvalidLeasedTask
|
||||||
|
}
|
||||||
|
case healthDomain.LevelEgress:
|
||||||
|
targetURL, err := healthDomain.NormalizeEgressTarget(task.TargetURL)
|
||||||
|
if task.RoutingName != "" || err != nil || targetURL != task.TargetURL {
|
||||||
|
return nil, ErrInvalidLeasedTask
|
||||||
|
}
|
||||||
|
case healthDomain.LevelTarget:
|
||||||
if _, err := healthDomain.NormalizeTargetProfile(healthDomain.TargetProfile{
|
if _, err := healthDomain.NormalizeTargetProfile(healthDomain.TargetProfile{
|
||||||
RoutingName: task.RoutingName, TargetURL: task.TargetURL,
|
RoutingName: task.RoutingName, TargetURL: task.TargetURL,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return nil, ErrInvalidLeasedTask
|
return nil, ErrInvalidLeasedTask
|
||||||
}
|
}
|
||||||
} else if task.RoutingName != "" || task.TargetURL != "" {
|
default:
|
||||||
return nil, ErrInvalidLeasedTask
|
return nil, ErrInvalidLeasedTask
|
||||||
}
|
}
|
||||||
return &controlplanev1.CheckTask{
|
return &controlplanev1.CheckTask{
|
||||||
|
|||||||
@ -173,6 +173,60 @@ func TestGRPCHandlerStreamsLeasedTasksAndFencesReportedFacts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGRPCHandlerStreamsEgressProbeURLAndAcceptsGlobalFact(t *testing.T) {
|
||||||
|
now := time.Date(2026, 8, 1, 12, 30, 0, 0, time.UTC)
|
||||||
|
broker, err := NewMemoryTaskBroker(MemoryTaskBrokerOptions{
|
||||||
|
LeaseTTL: time.Minute, Now: func() time.Time { return now },
|
||||||
|
Material: TaskMaterialResolverFunc(func(context.Context, Candidate) (TaskMaterial, error) {
|
||||||
|
return TaskMaterial{Protocol: proxyDomain.SchemeHTTP, Host: "proxy.example", Port: 8080}, nil
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewMemoryTaskBroker(): %v", err)
|
||||||
|
}
|
||||||
|
const egressURL = "https://egress.example/identity"
|
||||||
|
if offered, offerErr := broker.Offer(context.Background(), []PlannedTask{{
|
||||||
|
Candidate: Candidate{ProxyID: "proxy-a", State: proxyDomain.StateAvailable, Level: healthDomain.LevelEgress, TargetURL: egressURL, DueAt: now},
|
||||||
|
Deadline: now.Add(10 * time.Second), Attempts: 1,
|
||||||
|
}}); offerErr != nil || offered != 1 {
|
||||||
|
t.Fatalf("Offer() = (%d, %v)", offered, offerErr)
|
||||||
|
}
|
||||||
|
global := &recordingGlobalStore{}
|
||||||
|
reducer, err := NewReducer(global, &recordingTargetStore{}, func(context.Context, healthDomain.Observation) (int, error) { return 2, nil })
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewReducer(): %v", err)
|
||||||
|
}
|
||||||
|
handler, err := NewGRPCHandler(reducer, &recordingCheckerIdentity{}, GRPCHandlerOptions{
|
||||||
|
MaxObservationsPerBatch: 1, MaxTasksPerClaim: 1, TaskBroker: broker, Now: func() time.Time { return now },
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewGRPCHandler(): %v", err)
|
||||||
|
}
|
||||||
|
client, closeClient := newCheckerGRPCClient(t, handler)
|
||||||
|
defer closeClient()
|
||||||
|
|
||||||
|
stream, err := client.StreamCheckTasks(context.Background(), &controlplanev1.StreamCheckTasksRequest{
|
||||||
|
CheckerId: "checker-a", InstanceId: "instance-a", MaxInFlight: 1,
|
||||||
|
SupportedLevels: []controlplanev1.CheckLevel{controlplanev1.CheckLevel_CHECK_LEVEL_EGRESS},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StreamCheckTasks(): %v", err)
|
||||||
|
}
|
||||||
|
task, err := stream.Recv()
|
||||||
|
if err != nil || task.GetLevel() != controlplanev1.CheckLevel_CHECK_LEVEL_EGRESS || task.GetTargetUrl() != egressURL || task.GetRoutingName() != "" {
|
||||||
|
t.Fatalf("Recv() = (%+v, %v)", task, err)
|
||||||
|
}
|
||||||
|
observation := grpcHealthObservation(task.GetTaskId(), controlplanev1.CheckLevel_CHECK_LEVEL_EGRESS, now.Add(time.Second))
|
||||||
|
observation.LeaseToken = task.GetLeaseToken()
|
||||||
|
response, err := client.ReportObservations(context.Background(), &controlplanev1.ObservationBatch{
|
||||||
|
CheckerId: "checker-a", Observations: []*controlplanev1.HealthObservation{observation},
|
||||||
|
})
|
||||||
|
if err != nil || response.GetAccepted() != 1 || len(global.commands) != 1 ||
|
||||||
|
global.commands[0].Observation.TargetURL != "" || global.commands[0].Observation.RoutingName != "" {
|
||||||
|
t.Fatalf("ReportObservations() = (%+v, %v), commands=%+v", response, err, global.commands)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func newCheckerGRPCClient(t *testing.T, handler controlplanev1.CheckerControlPlaneServer) (controlplanev1.CheckerControlPlaneClient, func()) {
|
func newCheckerGRPCClient(t *testing.T, handler controlplanev1.CheckerControlPlaneServer) (controlplanev1.CheckerControlPlaneClient, func()) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
listener := bufconn.Listen(1 << 20)
|
listener := bufconn.Listen(1 << 20)
|
||||||
|
|||||||
@ -142,10 +142,15 @@ func (planner *Planner) validateCandidate(candidate Candidate) (Priority, bool,
|
|||||||
return 0, false, ErrInvalidScheduleRequest
|
return 0, false, ErrInvalidScheduleRequest
|
||||||
}
|
}
|
||||||
switch candidate.Level {
|
switch candidate.Level {
|
||||||
case healthDomain.LevelBasic, healthDomain.LevelEgress:
|
case healthDomain.LevelBasic:
|
||||||
if candidate.RoutingName != "" || candidate.TargetURL != "" {
|
if candidate.RoutingName != "" || candidate.TargetURL != "" {
|
||||||
return 0, false, ErrInvalidScheduleRequest
|
return 0, false, ErrInvalidScheduleRequest
|
||||||
}
|
}
|
||||||
|
case healthDomain.LevelEgress:
|
||||||
|
targetURL, err := healthDomain.NormalizeEgressTarget(candidate.TargetURL)
|
||||||
|
if candidate.RoutingName != "" || err != nil || targetURL != candidate.TargetURL {
|
||||||
|
return 0, false, ErrInvalidScheduleRequest
|
||||||
|
}
|
||||||
case healthDomain.LevelTarget:
|
case healthDomain.LevelTarget:
|
||||||
if _, err := healthDomain.NormalizeTargetProfile(healthDomain.TargetProfile{
|
if _, err := healthDomain.NormalizeTargetProfile(healthDomain.TargetProfile{
|
||||||
RoutingName: candidate.RoutingName, TargetURL: candidate.TargetURL,
|
RoutingName: candidate.RoutingName, TargetURL: candidate.TargetURL,
|
||||||
|
|||||||
@ -19,7 +19,7 @@ func TestPlannerPrioritizesAndBoundsOverdueCandidates(t *testing.T) {
|
|||||||
now := time.Date(2026, 7, 31, 17, 0, 0, 0, time.UTC)
|
now := time.Date(2026, 7, 31, 17, 0, 0, 0, time.UTC)
|
||||||
plans, err := planner.Plan(now, 1, 10, []Candidate{
|
plans, err := planner.Plan(now, 1, 10, []Candidate{
|
||||||
{ProxyID: "available", State: proxyDomain.StateAvailable, Level: healthDomain.LevelBasic, DueAt: now.Add(-time.Second)},
|
{ProxyID: "available", State: proxyDomain.StateAvailable, Level: healthDomain.LevelBasic, DueAt: now.Add(-time.Second)},
|
||||||
{ProxyID: "suspect", State: proxyDomain.StateSuspect, Level: healthDomain.LevelEgress, DueAt: now.Add(-2 * time.Second)},
|
{ProxyID: "suspect", State: proxyDomain.StateSuspect, Level: healthDomain.LevelEgress, TargetURL: "https://egress.example/identity", DueAt: now.Add(-2 * time.Second)},
|
||||||
{ProxyID: "fetched", State: proxyDomain.StateFetched, Level: healthDomain.LevelBasic, DueAt: now.Add(-3 * time.Second)},
|
{ProxyID: "fetched", State: proxyDomain.StateFetched, Level: healthDomain.LevelBasic, DueAt: now.Add(-3 * time.Second)},
|
||||||
{ProxyID: "future", State: proxyDomain.StateFetched, Level: healthDomain.LevelBasic, DueAt: now.Add(time.Second)},
|
{ProxyID: "future", State: proxyDomain.StateFetched, Level: healthDomain.LevelBasic, DueAt: now.Add(time.Second)},
|
||||||
})
|
})
|
||||||
|
|||||||
@ -352,10 +352,15 @@ func validatePlannedTask(task PlannedTask, now time.Time) error {
|
|||||||
return ErrInvalidLeasedTask
|
return ErrInvalidLeasedTask
|
||||||
}
|
}
|
||||||
switch task.Candidate.Level {
|
switch task.Candidate.Level {
|
||||||
case healthDomain.LevelBasic, healthDomain.LevelEgress:
|
case healthDomain.LevelBasic:
|
||||||
if task.Candidate.RoutingName != "" || task.Candidate.TargetURL != "" {
|
if task.Candidate.RoutingName != "" || task.Candidate.TargetURL != "" {
|
||||||
return ErrInvalidLeasedTask
|
return ErrInvalidLeasedTask
|
||||||
}
|
}
|
||||||
|
case healthDomain.LevelEgress:
|
||||||
|
targetURL, err := healthDomain.NormalizeEgressTarget(task.Candidate.TargetURL)
|
||||||
|
if task.Candidate.RoutingName != "" || err != nil || targetURL != task.Candidate.TargetURL {
|
||||||
|
return ErrInvalidLeasedTask
|
||||||
|
}
|
||||||
case healthDomain.LevelTarget:
|
case healthDomain.LevelTarget:
|
||||||
if _, err := healthDomain.NormalizeTargetProfile(healthDomain.TargetProfile{
|
if _, err := healthDomain.NormalizeTargetProfile(healthDomain.TargetProfile{
|
||||||
RoutingName: task.Candidate.RoutingName, TargetURL: task.Candidate.TargetURL,
|
RoutingName: task.Candidate.RoutingName, TargetURL: task.Candidate.TargetURL,
|
||||||
@ -403,8 +408,17 @@ func newLeaseToken() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func taskMatchesObservation(task PlannedTask, observation healthDomain.Observation) bool {
|
func taskMatchesObservation(task PlannedTask, observation healthDomain.Observation) bool {
|
||||||
return task.Candidate.ProxyID == observation.ProxyID && task.Candidate.Level == observation.Level &&
|
if task.Candidate.ProxyID != observation.ProxyID || task.Candidate.Level != observation.Level {
|
||||||
task.Candidate.RoutingName == observation.RoutingName && task.Candidate.TargetURL == observation.TargetURL
|
return false
|
||||||
|
}
|
||||||
|
switch task.Candidate.Level {
|
||||||
|
case healthDomain.LevelBasic, healthDomain.LevelEgress:
|
||||||
|
return observation.RoutingName == "" && observation.TargetURL == ""
|
||||||
|
case healthDomain.LevelTarget:
|
||||||
|
return task.Candidate.RoutingName == observation.RoutingName && task.Candidate.TargetURL == observation.TargetURL
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func validTaskIdentifier(value string) bool {
|
func validTaskIdentifier(value string) bool {
|
||||||
|
|||||||
@ -145,6 +145,22 @@ func NormalizeTargetProfile(value TargetProfile) (TargetProfile, error) {
|
|||||||
return value, nil
|
return value, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NormalizeEgressTarget canonicalizes the HTTP endpoint used to observe a
|
||||||
|
// proxy's public egress identity. The endpoint is task execution material,
|
||||||
|
// never part of the resulting global health fact.
|
||||||
|
func NormalizeEgressTarget(value string) (string, error) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return "", ErrInvalidObservation
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(value)
|
||||||
|
if err != nil || parsed.Scheme == "" || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" ||
|
||||||
|
(parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||||
|
return "", ErrInvalidObservation
|
||||||
|
}
|
||||||
|
return parsed.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (value TargetProfile) Key() string {
|
func (value TargetProfile) Key() string {
|
||||||
return value.RoutingName + "\x00" + value.TargetURL
|
return value.RoutingName + "\x00" + value.TargetURL
|
||||||
}
|
}
|
||||||
|
|||||||
@ -86,6 +86,16 @@ func TestNormalizeObservationRejectsCrossLevelFields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNormalizeEgressTargetAcceptsOnlyHTTPOrHTTPSURL(t *testing.T) {
|
||||||
|
target, err := NormalizeEgressTarget("https://egress.example/identity?format=json")
|
||||||
|
if err != nil || target != "https://egress.example/identity?format=json" {
|
||||||
|
t.Fatalf("NormalizeEgressTarget(valid) = (%q, %v)", target, err)
|
||||||
|
}
|
||||||
|
if _, err := NormalizeEgressTarget("ftp://egress.example/identity"); !errors.Is(err, ErrInvalidObservation) {
|
||||||
|
t.Fatalf("NormalizeEgressTarget(ftp) error = %v, want ErrInvalidObservation", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func globalObservation(taskID string, success bool, observedAt time.Time) Observation {
|
func globalObservation(taskID string, success bool, observedAt time.Time) Observation {
|
||||||
observation := Observation{TaskID: taskID, ProxyID: "proxy-a", Level: LevelBasic, Success: success, ObservedAt: observedAt}
|
observation := Observation{TaskID: taskID, ProxyID: "proxy-a", Level: LevelBasic, Success: success, ObservedAt: observedAt}
|
||||||
if !success {
|
if !success {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user