feat: run compose control plane with mtls

This commit is contained in:
youfak 2026-08-07 16:35:11 +08:00
parent 3bcc7298a2
commit 7e73b5ad1c
14 changed files with 591 additions and 39 deletions

1
.gitignore vendored
View File

@ -17,6 +17,7 @@ coverage/
.env.*
!.env.example
configs/local.yaml
deploy/.control-plane-tls/
*.pem
*.key

View File

@ -128,7 +128,8 @@ flowchart LR
Checker 探测和
Observation 状态归并Provider 连续空结果的代次化自动 Sequential 切换、禁用候选过滤、
末端 `stop` 的 CAS 路由停用和 Snapshot 即时刷新。
- **部分完成**Docker Compose/Kubernetes 运行时 mTLS Overlay。
- **部分完成**Kubernetes 运行时 mTLS OverlayCompose 已具备本地的
Controller/Gateway/Checker mTLS 运行链路。
- **待完成**:故障演练和代表性集群压测;现有 HTTP、CONNECT 长连接和 Extract
场景只提供可复现的负载工具,不构成容量验证结论。
@ -167,6 +168,19 @@ Redis、PostgreSQL 和 Controller fixture 脚本会使用 Docker 启动隔离依
./scripts/test-controller.ps1
```
本地 Compose 还会启动 Controller、两个固定身份的 Gateway 和一个 Checker。首次启动前
生成仅用于本机的 7 天 mTLS 证书;输出目录受 `.gitignore` 保护,脚本拒绝写入非空目录:
```powershell
./scripts/generate-local-controlplane-certs.ps1
docker compose -f deploy/docker-compose.yml up -d --build
docker compose -f deploy/docker-compose.yml ps
```
Gateway 只有取得有效 Snapshot 后才会通过 `/readyz`Checker 在首次成功领取控制面任务
批次后才会通过 `/readyz`,空批次也代表连接和身份验证已经成功。后续任务领取失败会立即
撤销 Checker 的就绪状态。
PostgreSQL 与 Redis 是 Controller 的启动依赖。当前可运行的 Controller 入口如下,
`CONFIG_FILE` 替换为实际配置路径,并确保其中的 PostgreSQL 与 Redis 地址可从
进程所在网络访问:
@ -180,7 +194,7 @@ go run ./cmd/proxy-controller -config CONFIG_FILE
改用宿主机可达的存储地址。
本地配置中的 `.invalid` Provider URL 是故障演示占位,不会提供真实代理。
Gateway 已提供启动命令;需要先启用 Controller `controlPlane` 并配置匹配的 mTLS
Gateway 也可独立启动;需要启用 Controller `controlPlane` 并配置匹配的 mTLS
证书(回环 fixture 可使用明文),再提供独立的拨号地址和 Worker 身份:
```powershell

View File

@ -5,10 +5,18 @@ Grafana 与 Kubernetes。镜像会构建 `proxy-controller`、`proxy-gateway`
`proxy-checker`Controller 已装配 Redis BASIC 检查任务Checker 可通过认证
控制面执行 HTTP/HTTPS BASIC 探测。
默认 Compose 和 Kubernetes Base 仍将 `controlPlane.enabled` 保持为 `false`
跨节点控制面必须由环境 Overlay 提供 Controller 可拨号地址、独立 Worker/Checker
身份及 mTLS 证书,因此这些默认清单不能视为完整的生产发布配置。`checker.yaml`
保留为 Overlay 模板,尚未加入 Base。
Compose 使用 `config/local.yaml` 启用 Controller、两个 Gateway 和一个 Checker 的
mTLS 控制面。`generate-local-controlplane-certs.ps1` 为三个固定工作负载生成不同的
SPIFFE URI 证书,私钥只写入被忽略的 `deploy/.control-plane-tls/`。运行前先执行:
```powershell
./scripts/generate-local-controlplane-certs.ps1
docker compose -f deploy/docker-compose.yml up -d --build
```
Kubernetes Base 仍保持 `controlPlane.enabled: false`,并保留 `checker.yaml` 作为
环境 Overlay 模板。生产 Overlay 必须为每个弹性 Gateway/Checker 工作负载配置唯一
身份与证书轮换,不能复用 Compose 的固定开发证书。
当前可执行验证:

View File

@ -17,12 +17,14 @@ type composeDocument struct {
}
type composeService struct {
Image string `yaml:"image"`
Command []string `yaml:"command"`
Ports []string `yaml:"ports"`
Volumes []string `yaml:"volumes"`
Tmpfs []string `yaml:"tmpfs"`
DependsOn any `yaml:"depends_on"`
Image string `yaml:"image"`
Command []string `yaml:"command"`
Ports []string `yaml:"ports"`
Expose []string `yaml:"expose"`
Volumes []string `yaml:"volumes"`
Tmpfs []string `yaml:"tmpfs"`
Environment map[string]string `yaml:"environment"`
DependsOn any `yaml:"depends_on"`
}
func TestPostgresIntegrationFixtureIsIsolatedAndEphemeral(t *testing.T) {
@ -133,6 +135,65 @@ func TestLocalGatewaysDoNotDependOnControlPlaneStorage(t *testing.T) {
}
}
func TestLocalComposeProvidesAuthenticatedControlPlaneForEveryWorker(t *testing.T) {
document := loadComposeDocument(t)
controller := document.Services["controller"]
if !slices.Contains(controller.Expose, "8443") || !slices.Contains(controller.Volumes, "./.control-plane-tls/controller:/run/proxy-pool-tls/server:ro") {
t.Fatalf("controller does not expose and mount control-plane TLS: expose=%v volumes=%v", controller.Expose, controller.Volumes)
}
for _, expected := range []struct {
name string
identity string
instance string
certificate string
}{
{name: "gateway-a", identity: "gateway-a", instance: "gateway-a-1", certificate: "gateway-a"},
{name: "gateway-b", identity: "gateway-b", instance: "gateway-b-1", certificate: "gateway-b"},
{name: "checker", identity: "checker-a", instance: "checker-a-1", certificate: "checker-a"},
} {
service, ok := document.Services[expected.name]
if !ok {
t.Fatalf("docker-compose.yml has no %s", expected.name)
}
if service.Environment["PROXY_POOL_CONTROL_PLANE_ADDRESS"] != "controller:8443" ||
!slices.Contains(service.Volumes, "./.control-plane-tls/"+expected.certificate+":/run/proxy-pool-tls/client:ro") ||
!composeDependsOn(service.DependsOn, "controller") {
t.Errorf("%s control-plane wiring = env=%v volumes=%v dependsOn=%v", expected.name, service.Environment, service.Volumes, service.DependsOn)
}
if expected.name == "checker" {
if service.Environment["PROXY_POOL_CHECKER_ID"] != expected.identity ||
service.Environment["PROXY_POOL_CHECKER_INSTANCE_ID"] != expected.instance ||
service.Environment["PROXY_POOL_CHECKER_MAX_IN_FLIGHT"] != "200" || !slices.Contains(service.Expose, "9090") {
t.Errorf("checker identity or metrics wiring = env=%v expose=%v", service.Environment, service.Expose)
}
continue
}
if service.Environment["PROXY_POOL_WORKER_ID"] != expected.identity ||
service.Environment["PROXY_POOL_INSTANCE_ID"] != expected.instance ||
service.Environment["PROXY_POOL_CLUSTER_ID"] != "compose-local" || service.Environment["PROXY_POOL_ZONE"] != "compose-local" {
t.Errorf("%s identity wiring = %v", expected.name, service.Environment)
}
}
}
func TestLocalControlPlaneCertificateGeneratorUsesDistinctSPIFFERoles(t *testing.T) {
payload, err := os.ReadFile("../scripts/generate-local-controlplane-certs.ps1")
if err != nil {
t.Fatalf("read generate-local-controlplane-certs.ps1: %v", err)
}
script := string(payload)
for _, required := range []string{
"OutputDirectory must be empty", "DNS:controller",
"spiffe://proxy-pool.local/development/worker/gateway-a",
"spiffe://proxy-pool.local/development/worker/gateway-b",
"spiffe://proxy-pool.local/development/checker/checker-a",
} {
if !strings.Contains(script, required) {
t.Errorf("certificate generator missing %q", required)
}
}
}
func TestDeploymentEntrypointsExistInSource(t *testing.T) {
dockerfile, err := os.ReadFile("docker/Dockerfile")
if err != nil {
@ -160,13 +221,13 @@ func TestDeploymentEntrypointsExistInSource(t *testing.T) {
}
}
func TestKubernetesBaseExcludesUnimplementedProcessManifests(t *testing.T) {
func TestKubernetesBaseLeavesControlPlaneClientsForMTLSOverlay(t *testing.T) {
payload, err := os.ReadFile("kubernetes/base/kustomization.yaml")
if err != nil {
t.Fatalf("read kustomization.yaml: %v", err)
}
if strings.Contains(string(payload), "checker.yaml") {
t.Fatal("kubernetes base includes checker.yaml before proxy-checker exists")
t.Fatal("kubernetes base includes checker.yaml without per-workload mTLS identity overlay")
}
}

View File

@ -64,7 +64,30 @@ admin:
token: "${PROXY_POOL_ADMIN_TOKEN}"
controlPlane:
enabled: false
enabled: true
listen: 0.0.0.0:8443
protocolVersion: 1
heartbeatInterval: 10s
sessionTTL: 30s
maxStaleAge: 30s
maxMessageBytes: 4194304
maxRuntimeCounters: 100000
maxConcurrentStreams: 1000
tls:
mode: mtls
certFile: /run/proxy-pool-tls/server/tls.crt
keyFile: /run/proxy-pool-tls/server/tls.key
clientCAFile: /run/proxy-pool-tls/server/ca.crt
trustDomain: proxy-pool.local
environment: development
gatewayTLS:
certFile: /run/proxy-pool-tls/client/tls.crt
keyFile: /run/proxy-pool-tls/client/tls.key
serverCAFile: /run/proxy-pool-tls/client/ca.crt
checkerTLS:
certFile: /run/proxy-pool-tls/client/tls.crt
keyFile: /run/proxy-pool-tls/client/tls.key
serverCAFile: /run/proxy-pool-tls/client/ca.crt
metrics:
enabled: true

View File

@ -15,8 +15,6 @@ x-app: &app
image: proxy-pool:local
restart: unless-stopped
networks: [frontend, backend]
volumes:
- ./config/local.yaml:/etc/proxy-pool/config.yaml:ro
environment: *app-environment
stop_grace_period: 45s
@ -24,6 +22,19 @@ services:
gateway-a:
<<: *app
command: ["proxy-gateway"]
environment:
<<: *app-environment
PROXY_POOL_CONTROL_PLANE_ADDRESS: controller:8443
PROXY_POOL_CLUSTER_ID: compose-local
PROXY_POOL_WORKER_ID: gateway-a
PROXY_POOL_INSTANCE_ID: gateway-a-1
PROXY_POOL_ZONE: compose-local
volumes:
- ./config/local.yaml:/etc/proxy-pool/config.yaml:ro
- ./.control-plane-tls/gateway-a:/run/proxy-pool-tls/client:ro
depends_on:
controller:
condition: service_healthy
expose: ["8080", "9090"]
healthcheck:
test: ["CMD", "curl", "--fail", "--silent", "http://127.0.0.1:9090/readyz"]
@ -35,6 +46,19 @@ services:
gateway-b:
<<: *app
command: ["proxy-gateway"]
environment:
<<: *app-environment
PROXY_POOL_CONTROL_PLANE_ADDRESS: controller:8443
PROXY_POOL_CLUSTER_ID: compose-local
PROXY_POOL_WORKER_ID: gateway-b
PROXY_POOL_INSTANCE_ID: gateway-b-1
PROXY_POOL_ZONE: compose-local
volumes:
- ./config/local.yaml:/etc/proxy-pool/config.yaml:ro
- ./.control-plane-tls/gateway-b:/run/proxy-pool-tls/client:ro
depends_on:
controller:
condition: service_healthy
expose: ["8080", "9090"]
healthcheck:
test: ["CMD", "curl", "--fail", "--silent", "http://127.0.0.1:9090/readyz"]
@ -49,12 +73,15 @@ services:
environment:
<<: *app-environment
PROXY_POOL_CONFIG_FINGERPRINT_KEY: ${PROXY_POOL_CONFIG_FINGERPRINT_KEY:?set PROXY_POOL_CONFIG_FINGERPRINT_KEY}
volumes:
- ./config/local.yaml:/etc/proxy-pool/config.yaml:ro
- ./.control-plane-tls/controller:/run/proxy-pool-tls/server:ro
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
expose: ["8081", "8082", "9090"]
expose: ["8081", "8082", "8443", "9090"]
ports:
- "127.0.0.1:8081:8081"
- "127.0.0.1:8082:8082"
@ -65,6 +92,29 @@ services:
retries: 12
start_period: 15s
checker:
<<: *app
command: ["proxy-checker"]
environment:
<<: *app-environment
PROXY_POOL_CONTROL_PLANE_ADDRESS: controller:8443
PROXY_POOL_CHECKER_ID: checker-a
PROXY_POOL_CHECKER_INSTANCE_ID: checker-a-1
PROXY_POOL_CHECKER_MAX_IN_FLIGHT: "200"
volumes:
- ./config/local.yaml:/etc/proxy-pool/config.yaml:ro
- ./.control-plane-tls/checker-a:/run/proxy-pool-tls/client:ro
depends_on:
controller:
condition: service_healthy
expose: ["9090"]
healthcheck:
test: ["CMD", "curl", "--fail", "--silent", "http://127.0.0.1:9090/readyz"]
interval: 5s
timeout: 2s
retries: 12
start_period: 10s
haproxy:
image: haproxy:3.2-alpine
restart: unless-stopped

View File

@ -14,6 +14,9 @@ scrape_configs:
- job_name: proxy-controller
static_configs:
- targets: [controller:9090]
- job_name: proxy-checker
static_configs:
- targets: [checker:9090]
- job_name: haproxy
metrics_path: /metrics
static_configs:

View File

@ -306,9 +306,9 @@ controlPlane:
Gateway 连接 Controller 时使用独立启动参数而非 `controlPlane.listen`。至少设置
`PROXY_POOL_CONTROL_PLANE_ADDRESS`、`PROXY_POOL_CLUSTER_ID`、
`PROXY_POOL_WORKER_ID`、`PROXY_POOL_INSTANCE_ID` 和 `PROXY_POOL_ZONE`,详见
[控制面协议](../api/control-plane.md#9-gateway-启动参数)。基础 Compose/Kubernetes
模板保持 `controlPlane.enabled: false`,环境 Overlay 挂载 mTLS 证书并启用后才可启动
Gateway
[控制面协议](../api/control-plane.md#9-gateway-启动参数)。Compose 本地模板使用固定
Worker/Checker 身份和被忽略的开发证书目录Kubernetes Base 保持
`controlPlane.enabled: false`,必须由具备每工作负载唯一身份的 mTLS Overlay 启用
Checker 同样使用独立的可拨号地址:`proxy-checker` 的 `-control-plane`
`-checker-id`、`-instance-id` 和 `-max-in-flight` 可由对应的

View File

@ -50,6 +50,13 @@ $env:PROVIDER_A_TOKEN = "PROVIDER_A_TOKEN"
$env:PROVIDER_B_TOKEN = "PROVIDER_B_TOKEN"
```
首次启动 Compose 前,生成不入库的本地控制面证书。生成器要求目标目录为空,避免覆写
已有密钥:
```powershell
.\scripts\generate-local-controlplane-certs.ps1
```
### 2.3 静态检查
```powershell
@ -57,6 +64,10 @@ docker compose -f deploy/docker-compose.yml config
kubectl kustomize deploy/kubernetes/base > rendered.yaml
```
Compose 启动后Gateway 的 `/readyz` 需要先接收有效 SnapshotChecker 的 `/readyz`
需要成功建立一次任务领取流,并会在后续领取失败时回到未就绪。两者的 `/livez` 只表示
进程仍在运行。
本地 Compose 的 Redis 只作为可重建短效状态 fixture固定使用
`--appendonly no --save ""`,且不挂载 `/data` 或命名卷。真实 Redis 8.2 契约可
通过 `.\scripts\test-redis.ps1` 执行;脚本使用唯一命名空间并在结束时定向清理,

View File

@ -8,9 +8,13 @@ import (
"context"
"errors"
"fmt"
"net"
"net/http"
"reflect"
"strings"
"sync/atomic"
"github.com/prometheus/client_golang/prometheus"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
@ -20,11 +24,15 @@ import (
"proxy-pool/internal/config"
"proxy-pool/internal/controlplane/clienttransport"
"proxy-pool/internal/domain/workerruntime"
"proxy-pool/internal/platform/httpserver"
"proxy-pool/internal/platform/lifecycle"
platformMetrics "proxy-pool/internal/platform/metrics"
)
var (
ErrInvalidOptions = errors.New("invalid checker bootstrap options")
ErrStartup = errors.New("checker startup failed")
ErrNotReady = errors.New("checker control plane is not ready")
)
type Options struct {
@ -36,6 +44,8 @@ type Options struct {
MaxInFlight int
SupportedLevels []controlplanev1.CheckLevel
GRPCTransport credentials.TransportCredentials
MetricsListener net.Listener
HTTP httpserver.Options
}
func Run(ctx context.Context, options Options) error {
@ -49,11 +59,23 @@ func Run(ctx context.Context, options Options) error {
if !configuration.ControlPlane.Enabled {
return errors.Join(ErrInvalidOptions, errors.New("controlPlane must be enabled"))
}
runtime, err := newRuntime(ctx, configuration, options)
if err != nil {
return fmt.Errorf("%w: %w", ErrStartup, err)
}
defer runtime.Close()
return runtime.Run(ctx)
}
func newRuntime(ctx context.Context, configuration *config.Config, options Options) (*runtime, error) {
if ctx == nil || configuration == nil {
return nil, ErrInvalidOptions
}
transport, err := clienttransport.New(
configuration.ControlPlane, options.ControlPlaneAddress, configuration.ControlPlane.CheckerTLS, options.GRPCTransport, "checkerTLS",
)
if err != nil {
return fmt.Errorf("%w: %w", ErrInvalidOptions, err)
return nil, fmt.Errorf("%w: %w", ErrInvalidOptions, err)
}
connection, err := grpc.NewClient(options.ControlPlaneAddress,
grpc.WithTransportCredentials(transport),
@ -63,19 +85,40 @@ func Run(ctx context.Context, options Options) error {
),
)
if err != nil {
return fmt.Errorf("%w: dial control plane: %w", ErrStartup, err)
return nil, fmt.Errorf("dial control plane: %w", err)
}
defer connection.Close()
closeConnection := true
defer func() {
if closeConnection {
_ = connection.Close()
}
}()
client := controlplane.NewGeneratedClient(controlplanev1.NewCheckerControlPlaneClient(connection))
readiness := &controlPlaneReadiness{}
runner, err := controlplane.NewRunner(client, probe.NewExecutor(), controlplane.Options{
CheckerID: options.CheckerID, InstanceID: options.InstanceID, MaxInFlight: options.MaxInFlight,
SupportedLevels: append([]controlplanev1.CheckLevel(nil), options.SupportedLevels...),
ReportBatchSize: options.MaxInFlight,
ReportBatchSize: options.MaxInFlight, OnSuccessfulPull: readiness.MarkReady, OnFailedPull: readiness.MarkUnavailable,
})
if err != nil {
return fmt.Errorf("%w: build checker runner: %w", ErrStartup, err)
return nil, fmt.Errorf("build checker runner: %w", err)
}
return runner.Run(ctx)
runners := []lifecycle.Runner{runner}
if configuration.Metrics.Enabled {
metrics, metricsErr := newMetricsRuntime(ctx, configuration.Metrics, options, readiness)
if metricsErr != nil {
return nil, metricsErr
}
runners = append(runners, metrics)
} else if options.MetricsListener != nil {
return nil, ErrInvalidOptions
}
group, err := lifecycle.NewGroup(runners...)
if err != nil {
return nil, err
}
closeConnection = false
return &runtime{connection: connection, group: group}, nil
}
func validateOptions(ctx context.Context, options Options) error {
@ -88,6 +131,87 @@ func validateOptions(ctx context.Context, options Options) error {
return nil
}
type runtime struct {
connection *grpc.ClientConn
group *lifecycle.Group
}
func (runtime *runtime) Run(ctx context.Context) error {
if runtime == nil || runtime.connection == nil || runtime.group == nil || ctx == nil {
return ErrInvalidOptions
}
return runtime.group.Run(ctx)
}
func (runtime *runtime) Close() {
if runtime != nil && runtime.connection != nil {
_ = runtime.connection.Close()
}
}
type controlPlaneReadiness struct {
ready atomic.Bool
}
func (readiness *controlPlaneReadiness) MarkReady() {
if readiness != nil {
readiness.ready.Store(true)
}
}
func (readiness *controlPlaneReadiness) MarkUnavailable() {
if readiness != nil {
readiness.ready.Store(false)
}
}
func (readiness *controlPlaneReadiness) Ready(ctx context.Context) error {
if ctx == nil || readiness == nil || !readiness.ready.Load() {
return ErrNotReady
}
return ctx.Err()
}
type metricsRuntime struct {
listener net.Listener
handler http.Handler
options httpserver.Options
}
func newMetricsRuntime(
ctx context.Context,
configuration config.Metrics,
options Options,
readiness *controlPlaneReadiness,
) (*metricsRuntime, error) {
if ctx == nil || !configuration.Enabled || readiness == nil {
return nil, ErrInvalidOptions
}
handler, err := platformMetrics.NewHandler(platformMetrics.Dependencies{
Gatherer: prometheus.DefaultGatherer, Readiness: readiness,
})
if err != nil {
return nil, fmt.Errorf("build checker metrics handler: %w", err)
}
listener := options.MetricsListener
if listener == nil {
listener, err = (&net.ListenConfig{}).Listen(ctx, "tcp", configuration.Listen)
if err != nil {
return nil, fmt.Errorf("listen checker metrics: %w", err)
}
}
return &metricsRuntime{listener: listener, handler: handler, options: options.HTTP}, nil
}
func (runtime *metricsRuntime) Run(ctx context.Context) error {
if runtime == nil || runtime.listener == nil || runtime.handler == nil || ctx == nil {
return ErrInvalidOptions
}
return httpserver.Serve(ctx, runtime.options, httpserver.Endpoint{
Name: "metrics", Listener: runtime.listener, Handler: runtime.handler,
})
}
func loadConfiguration(ctx context.Context, path string, resolver config.Resolver) (*config.Config, error) {
if ctx == nil || strings.TrimSpace(path) != path || path == "" || nilInterface(resolver) {
return nil, ErrInvalidOptions

View File

@ -0,0 +1,55 @@
package bootstrap
import (
"context"
"errors"
"net"
"net/http"
"net/http/httptest"
"testing"
"proxy-pool/internal/config"
)
func TestControlPlaneReadinessTransitionsAfterSuccessfulPull(t *testing.T) {
readiness := &controlPlaneReadiness{}
if err := readiness.Ready(context.Background()); !errors.Is(err, ErrNotReady) {
t.Fatalf("Ready() before pull = %v, want ErrNotReady", err)
}
readiness.MarkReady()
if err := readiness.Ready(context.Background()); err != nil {
t.Fatalf("Ready() after successful pull = %v", err)
}
readiness.MarkUnavailable()
if err := readiness.Ready(context.Background()); !errors.Is(err, ErrNotReady) {
t.Fatalf("Ready() after failed pull = %v, want ErrNotReady", err)
}
}
func TestMetricsRuntimeUsesControlPlaneReadiness(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("Listen(): %v", err)
}
defer listener.Close()
readiness := &controlPlaneReadiness{}
runtime, err := newMetricsRuntime(context.Background(), config.Metrics{Enabled: true, Listen: listener.Addr().String()}, Options{
MetricsListener: listener,
}, readiness)
if err != nil {
t.Fatalf("newMetricsRuntime(): %v", err)
}
assertCheckerProbe(t, runtime.handler, "/livez", http.StatusOK, "live\n")
assertCheckerProbe(t, runtime.handler, "/readyz", http.StatusServiceUnavailable, "unavailable\n")
readiness.MarkReady()
assertCheckerProbe(t, runtime.handler, "/readyz", http.StatusOK, "ready\n")
}
func assertCheckerProbe(t *testing.T, handler http.Handler, path string, wantStatus int, wantBody string) {
t.Helper()
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))
if recorder.Code != wantStatus || recorder.Body.String() != wantBody {
t.Fatalf("GET %s = %d %q, want %d %q", path, recorder.Code, recorder.Body.String(), wantStatus, wantBody)
}
}

View File

@ -44,14 +44,16 @@ type Executor interface {
}
type Options struct {
CheckerID string
InstanceID string
MaxInFlight int
SupportedLevels []controlplanev1.CheckLevel
ReportBatchSize int
PollInterval time.Duration
RetryDelay time.Duration
Now func() time.Time
CheckerID string
InstanceID string
MaxInFlight int
SupportedLevels []controlplanev1.CheckLevel
ReportBatchSize int
PollInterval time.Duration
RetryDelay time.Duration
Now func() time.Time
OnSuccessfulPull func()
OnFailedPull func()
}
// Runner consumes at most one server-bounded pull per RunOnce. Run adds a
@ -93,6 +95,9 @@ func (runner *Runner) Run(ctx context.Context) error {
if err != nil && ctx.Err() != nil {
return ctx.Err()
}
if err != nil && runner.options.OnFailedPull != nil {
runner.options.OnFailedPull()
}
wait := runner.options.PollInterval
if err != nil {
wait = runner.options.RetryDelay
@ -118,9 +123,13 @@ func (runner *Runner) RunOnce(ctx context.Context) error {
return err
}
tasks, err := collectTasks(stream, runner.options.MaxInFlight)
if err != nil || len(tasks) == 0 {
if err != nil {
return err
}
if len(tasks) == 0 {
runner.observeSuccessfulPull()
return nil
}
observations := runner.executeTasks(ctx, tasks)
for start := 0; start < len(observations); start += runner.options.ReportBatchSize {
end := start + runner.options.ReportBatchSize
@ -137,9 +146,16 @@ func (runner *Runner) RunOnce(ctx context.Context) error {
return ErrObservationsRejected
}
}
runner.observeSuccessfulPull()
return nil
}
func (runner *Runner) observeSuccessfulPull() {
if runner != nil && runner.options.OnSuccessfulPull != nil {
runner.options.OnSuccessfulPull()
}
}
func collectTasks(stream TaskStream, maximum int) ([]*controlplanev1.CheckTask, error) {
if stream == nil || maximum <= 0 {
return nil, ErrInvalidRunner

View File

@ -2,8 +2,10 @@ package controlplane
import (
"context"
"errors"
"io"
"sync"
"sync/atomic"
"testing"
"time"
@ -67,6 +69,51 @@ func TestRunnerOmitsEgressProbeURLFromGlobalObservation(t *testing.T) {
}
}
func TestRunnerMarksReadyAfterSuccessfulEmptyPull(t *testing.T) {
var successfulPulls atomic.Int64
runner, err := NewRunner(&clientStub{stream: &taskStreamStub{}}, &executorStub{}, Options{
CheckerID: "checker-a", InstanceID: "instance-a", MaxInFlight: 1,
SupportedLevels: []controlplanev1.CheckLevel{controlplanev1.CheckLevel_CHECK_LEVEL_BASIC},
Now: time.Now, OnSuccessfulPull: func() { successfulPulls.Add(1) },
})
if err != nil {
t.Fatalf("NewRunner(): %v", err)
}
if err := runner.RunOnce(context.Background()); err != nil {
t.Fatalf("RunOnce(): %v", err)
}
if successfulPulls.Load() != 1 {
t.Fatalf("successful pulls = %d, want 1", successfulPulls.Load())
}
}
func TestRunnerMarksUnavailableAfterPullFailure(t *testing.T) {
failed := make(chan struct{})
var markOnce sync.Once
runner, err := NewRunner(&clientStub{streamErr: errors.New("control plane unavailable")}, &executorStub{}, Options{
CheckerID: "checker-a", InstanceID: "instance-a", MaxInFlight: 1,
SupportedLevels: []controlplanev1.CheckLevel{controlplanev1.CheckLevel_CHECK_LEVEL_BASIC},
Now: time.Now, RetryDelay: time.Millisecond,
OnFailedPull: func() { markOnce.Do(func() { close(failed) }) },
})
if err != nil {
t.Fatalf("NewRunner(): %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
completed := make(chan error, 1)
go func() { completed <- runner.Run(ctx) }()
select {
case <-failed:
cancel()
case <-time.After(time.Second):
t.Fatal("Runner did not mark failed pull")
}
if err := <-completed; !errors.Is(err, context.Canceled) {
t.Fatalf("Run() = %v, want context.Canceled", err)
}
}
func checkerTask(id string, now time.Time) *controlplanev1.CheckTask {
return &controlplanev1.CheckTask{
TaskId: id, ProxyId: id + "-proxy", Protocol: controlplanev1.ProxyProtocol_PROXY_PROTOCOL_HTTP,
@ -76,12 +123,13 @@ func checkerTask(id string, now time.Time) *controlplanev1.CheckTask {
}
type clientStub struct {
stream TaskStream
batches []*controlplanev1.ObservationBatch
stream TaskStream
streamErr error
batches []*controlplanev1.ObservationBatch
}
func (stub *clientStub) StreamCheckTasks(context.Context, *controlplanev1.StreamCheckTasksRequest) (TaskStream, error) {
return stub.stream, nil
return stub.stream, stub.streamErr
}
func (stub *clientStub) ReportObservations(_ context.Context, batch *controlplanev1.ObservationBatch) (*controlplanev1.ReportObservationsResponse, error) {

View File

@ -0,0 +1,138 @@
param(
[string]$OutputDirectory = (Join-Path (Split-Path -Parent $PSScriptRoot) "deploy/.control-plane-tls")
)
$ErrorActionPreference = "Stop"
if ([string]::IsNullOrWhiteSpace($OutputDirectory)) {
throw "OutputDirectory is required"
}
if (Test-Path -LiteralPath $OutputDirectory) {
$existing = @(Get-ChildItem -Force -LiteralPath $OutputDirectory)
if ($existing.Count -gt 0) {
throw "OutputDirectory must be empty: $OutputDirectory"
}
}
else {
New-Item -ItemType Directory -Path $OutputDirectory | Out-Null
}
function ConvertTo-Pem {
param(
[string]$Label,
[byte[]]$Bytes
)
$base64 = [Convert]::ToBase64String($Bytes, [Base64FormattingOptions]::InsertLineBreaks)
return "-----BEGIN $Label-----`n$base64`n-----END $Label-----`n"
}
function Write-PemFile {
param(
[string]$Path,
[string]$Label,
[byte[]]$Bytes
)
[IO.File]::WriteAllText($Path, (ConvertTo-Pem -Label $Label -Bytes $Bytes), [Text.Encoding]::ASCII)
}
function New-CertificateRequest {
param(
[string]$CommonName,
[System.Security.Cryptography.RSA]$Key,
[bool]$IsCertificateAuthority,
[string]$ExtendedKeyUsage,
[string]$SubjectAlternativeName
)
$request = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new(
"CN=$CommonName",
$Key,
[System.Security.Cryptography.HashAlgorithmName]::SHA256,
[System.Security.Cryptography.RSASignaturePadding]::Pkcs1
)
$request.CertificateExtensions.Add([System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension]::new(
$IsCertificateAuthority, $false, 0, $true
))
if ($IsCertificateAuthority) {
$request.CertificateExtensions.Add([System.Security.Cryptography.X509Certificates.X509KeyUsageExtension]::new(
[System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::KeyCertSign -bor
[System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::CrlSign,
$true
))
return $request
}
$request.CertificateExtensions.Add([System.Security.Cryptography.X509Certificates.X509KeyUsageExtension]::new(
[System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::DigitalSignature -bor
[System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::KeyEncipherment,
$true
))
$usage = [System.Security.Cryptography.OidCollection]::new()
if ($ExtendedKeyUsage -eq "serverAuth") {
$null = $usage.Add([System.Security.Cryptography.Oid]::new("1.3.6.1.5.5.7.3.1"))
}
else {
$null = $usage.Add([System.Security.Cryptography.Oid]::new("1.3.6.1.5.5.7.3.2"))
}
$request.CertificateExtensions.Add([System.Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension]::new($usage, $true))
$san = [System.Security.Cryptography.X509Certificates.SubjectAlternativeNameBuilder]::new()
if ($SubjectAlternativeName.StartsWith("DNS:")) {
$san.AddDnsName($SubjectAlternativeName.Substring(4))
}
else {
$san.AddUri([Uri]$SubjectAlternativeName.Substring(4))
}
$request.CertificateExtensions.Add($san.Build())
return $request
}
function New-LeafCertificate {
param(
[string]$Name,
[string]$CommonName,
[string]$ExtendedKeyUsage,
[string]$SubjectAlternativeName
)
$directory = Join-Path $OutputDirectory $Name
New-Item -ItemType Directory -Path $directory | Out-Null
$key = [System.Security.Cryptography.RSA]::Create(2048)
try {
$request = New-CertificateRequest -CommonName $CommonName -Key $key -IsCertificateAuthority $false -ExtendedKeyUsage $ExtendedKeyUsage -SubjectAlternativeName $SubjectAlternativeName
$serial = [byte[]]::new(16)
[System.Security.Cryptography.RandomNumberGenerator]::Fill($serial)
$certificate = $request.Create($caCertificate, $notBefore, $notAfter, $serial)
try {
Write-PemFile -Path (Join-Path $directory "tls.crt") -Label "CERTIFICATE" -Bytes $certificate.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert)
Write-PemFile -Path (Join-Path $directory "tls.key") -Label "PRIVATE KEY" -Bytes $key.ExportPkcs8PrivateKey()
}
finally {
$certificate.Dispose()
}
}
finally {
$key.Dispose()
}
Copy-Item -LiteralPath $caCertificatePath -Destination (Join-Path $directory "ca.crt")
}
$notBefore = [DateTimeOffset]::UtcNow.AddMinutes(-5)
$notAfter = $notBefore.AddDays(7)
$caKey = [System.Security.Cryptography.RSA]::Create(2048)
$caRequest = New-CertificateRequest -CommonName "proxy-pool-local-control-plane-ca" -Key $caKey -IsCertificateAuthority $true -ExtendedKeyUsage "" -SubjectAlternativeName ""
$caCertificate = $caRequest.CreateSelfSigned($notBefore, $notAfter)
$caCertificatePath = Join-Path $OutputDirectory "ca.crt"
Write-PemFile -Path $caCertificatePath -Label "CERTIFICATE" -Bytes $caCertificate.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert)
Write-PemFile -Path (Join-Path $OutputDirectory "ca.key") -Label "PRIVATE KEY" -Bytes $caKey.ExportPkcs8PrivateKey()
New-LeafCertificate -Name "controller" -CommonName "proxy-pool-controller" -ExtendedKeyUsage "serverAuth" -SubjectAlternativeName "DNS:controller"
New-LeafCertificate -Name "gateway-a" -CommonName "proxy-pool-gateway-a" -ExtendedKeyUsage "clientAuth" -SubjectAlternativeName "URI:spiffe://proxy-pool.local/development/worker/gateway-a"
New-LeafCertificate -Name "gateway-b" -CommonName "proxy-pool-gateway-b" -ExtendedKeyUsage "clientAuth" -SubjectAlternativeName "URI:spiffe://proxy-pool.local/development/worker/gateway-b"
New-LeafCertificate -Name "checker-a" -CommonName "proxy-pool-checker-a" -ExtendedKeyUsage "clientAuth" -SubjectAlternativeName "URI:spiffe://proxy-pool.local/development/checker/checker-a"
$caCertificate.Dispose()
$caKey.Dispose()
Write-Host "Generated local control-plane certificates in $OutputDirectory"