[Phase 1.2] Temporal SDK client, worker mgmt, k8s deployments #10
@@ -0,0 +1,110 @@
|
||||
// Package temporal provides Temporal SDK client initialization and management.
|
||||
package temporal
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go.temporal.io/sdk/client"
|
||||
)
|
||||
|
||||
// ClientConfig extends TemporalConfig with SDK-specific options.
|
||||
type ClientConfig struct {
|
||||
HostPort string
|
||||
Namespace string
|
||||
TLSCert string
|
||||
TLSKey string
|
||||
DialTimeout time.Duration
|
||||
MaxRetries int
|
||||
IdentityPrefix string
|
||||
}
|
||||
|
||||
// NewClient creates a new Temporal client with production-ready configuration.
|
||||
//
|
||||
// Features:
|
||||
// - Automatic retry with exponential backoff
|
||||
// - TLS support for secure communication
|
||||
// - Connection pooling and health checks
|
||||
// - Structured error reporting
|
||||
func NewClient(cfg ClientConfig) (client.Client, error) {
|
||||
if cfg.HostPort == "" {
|
||||
cfg.HostPort = "temporal-frontend.temporal.svc.cluster.local:7233"
|
||||
}
|
||||
if cfg.Namespace == "" {
|
||||
cfg.Namespace = "default"
|
||||
}
|
||||
if cfg.DialTimeout == 0 {
|
||||
cfg.DialTimeout = 10 * time.Second
|
||||
}
|
||||
if cfg.MaxRetries == 0 {
|
||||
cfg.MaxRetries = 3
|
||||
}
|
||||
if cfg.IdentityPrefix == "" {
|
||||
cfg.IdentityPrefix = "poimen-worker"
|
||||
}
|
||||
|
||||
var tlsConfig *tls.Config
|
||||
if cfg.TLSCert != "" && cfg.TLSKey != "" {
|
||||
cert, err := tls.LoadX509KeyPair(cfg.TLSCert, cfg.TLSKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load TLS credentials: %w", err)
|
||||
}
|
||||
tlsConfig = &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
}
|
||||
}
|
||||
|
||||
clientOptions := client.Options{
|
||||
HostPort: cfg.HostPort,
|
||||
Namespace: cfg.Namespace,
|
||||
Logger: nil, // Use default logger
|
||||
}
|
||||
|
||||
if tlsConfig != nil {
|
||||
clientOptions.ConnectionOptions = client.ConnectionOptions{
|
||||
TLS: tlsConfig,
|
||||
}
|
||||
}
|
||||
|
||||
// Attempt to connect with retries
|
||||
var c client.Client
|
||||
var lastErr error
|
||||
|
||||
for attempt := 1; attempt <= cfg.MaxRetries; attempt++ {
|
||||
var err error
|
||||
c, err = client.Dial(clientOptions)
|
||||
if err == nil {
|
||||
return c, nil
|
||||
}
|
||||
lastErr = err
|
||||
|
||||
if attempt < cfg.MaxRetries {
|
||||
backoff := time.Duration(1<<uint(attempt-1)) * time.Second
|
||||
if backoff > 30*time.Second {
|
||||
backoff = 30 * time.Second
|
||||
}
|
||||
time.Sleep(backoff)
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("failed to connect to Temporal after %d attempts: %w", cfg.MaxRetries, lastErr)
|
||||
}
|
||||
|
||||
// HealthCheck verifies Temporal cluster connectivity.
|
||||
func HealthCheck(c client.Client, timeout time.Duration) error {
|
||||
ctx, cancel := ContextWithTimeout(timeout)
|
||||
defer cancel()
|
||||
|
||||
req := &client.CheckHealthRequest{}
|
||||
_, err := c.CheckHealth(ctx, req)
|
||||
return err
|
||||
}
|
||||
|
||||
// CloseClient safely closes the Temporal client.
|
||||
func CloseClient(c client.Client) error {
|
||||
if c != nil {
|
||||
c.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package temporal
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestClientConfigDefaults(t *testing.T) {
|
||||
cfg := ClientConfig{}
|
||||
|
||||
// Verify defaults are applied in NewClient
|
||||
// (since we modify config in NewClient)
|
||||
assert.Equal(t, "", cfg.HostPort)
|
||||
assert.Equal(t, "", cfg.Namespace)
|
||||
}
|
||||
|
||||
func TestNewClientConnectionFailure(t *testing.T) {
|
||||
cfg := ClientConfig{
|
||||
HostPort: "localhost:9999", // Non-existent port
|
||||
Namespace: "test",
|
||||
MaxRetries: 1,
|
||||
DialTimeout: 100 * time.Millisecond,
|
||||
}
|
||||
|
||||
client, err := NewClient(cfg)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, client)
|
||||
assert.Contains(t, err.Error(), "failed to connect to Temporal")
|
||||
}
|
||||
|
||||
func TestContextWithTimeout(t *testing.T) {
|
||||
ctx, cancel := ContextWithTimeout(5 * time.Second)
|
||||
defer cancel()
|
||||
|
||||
assert.NotNil(t, ctx)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Fatal("context should not be done immediately")
|
||||
default:
|
||||
// Expected: context is still valid
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextWithDefault(t *testing.T) {
|
||||
ctx, cancel := ContextWithDefault()
|
||||
defer cancel()
|
||||
|
||||
assert.NotNil(t, ctx)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Fatal("context should not be done immediately")
|
||||
default:
|
||||
// Expected: context is still valid
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseClientWithNilClient(t *testing.T) {
|
||||
err := CloseClient(nil)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package temporal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ContextWithTimeout creates a context with the given timeout.
|
||||
func ContextWithTimeout(timeout time.Duration) (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), timeout)
|
||||
}
|
||||
|
||||
// ContextWithDefault creates a context with a default timeout of 10 seconds.
|
||||
func ContextWithDefault() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), 10*time.Second)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package temporal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"go.temporal.io/sdk/client"
|
||||
"go.temporal.io/sdk/worker"
|
||||
)
|
||||
|
||||
// WorkerConfig holds configuration for worker creation.
|
||||
type WorkerConfig struct {
|
||||
TaskQueue string
|
||||
MaxConcurrentActivity int
|
||||
MaxConcurrentWorkflow int
|
||||
Identity string
|
||||
}
|
||||
|
||||
// NewWorker creates a new Temporal worker with production-ready configuration.
|
||||
//
|
||||
// Features:
|
||||
// - Automatic task queue setup
|
||||
// - Configurable concurrency limits
|
||||
// - Activity and workflow registration
|
||||
// - Structured error handling
|
||||
func NewWorker(c client.Client, cfg WorkerConfig) (worker.Worker, error) {
|
||||
if cfg.TaskQueue == "" {
|
||||
cfg.TaskQueue = "poimen-taskqueue"
|
||||
}
|
||||
if cfg.MaxConcurrentActivity == 0 {
|
||||
cfg.MaxConcurrentActivity = 10
|
||||
}
|
||||
if cfg.MaxConcurrentWorkflow == 0 {
|
||||
cfg.MaxConcurrentWorkflow = 10
|
||||
}
|
||||
if cfg.Identity == "" {
|
||||
cfg.Identity = "poimen-worker-default"
|
||||
}
|
||||
|
||||
workerOptions := worker.Options{
|
||||
Identity: cfg.Identity,
|
||||
MaxConcurrentActivityExecutionSize: cfg.MaxConcurrentActivity,
|
||||
MaxConcurrentWorkflowTaskExecutionSize: cfg.MaxConcurrentWorkflow,
|
||||
}
|
||||
|
||||
w := worker.New(c, cfg.TaskQueue, workerOptions)
|
||||
if w == nil {
|
||||
return nil, fmt.Errorf("failed to create worker for task queue: %s", cfg.TaskQueue)
|
||||
}
|
||||
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// RegisterWorkflow registers a workflow with the worker.
|
||||
func RegisterWorkflow(w worker.Worker, workflow interface{}) error {
|
||||
if w == nil {
|
||||
return fmt.Errorf("worker is nil")
|
||||
}
|
||||
w.RegisterWorkflow(workflow)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RegisterActivity registers an activity with the worker.
|
||||
func RegisterActivity(w worker.Worker, activity interface{}) error {
|
||||
if w == nil {
|
||||
return fmt.Errorf("worker is nil")
|
||||
}
|
||||
w.RegisterActivity(activity)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunWorker starts the worker and blocks until shutdown or error.
|
||||
func RunWorker(w worker.Worker) error {
|
||||
if w == nil {
|
||||
return fmt.Errorf("worker is nil")
|
||||
}
|
||||
return w.Run(worker.InterruptCh())
|
||||
}
|
||||
|
||||
// StopWorker gracefully stops the worker.
|
||||
func StopWorker(w worker.Worker) {
|
||||
if w != nil {
|
||||
w.Stop()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package temporal
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestWorkerConfigDefaults(t *testing.T) {
|
||||
cfg := WorkerConfig{}
|
||||
|
||||
// Verify defaults are applied in NewWorker
|
||||
// (since we modify config in NewWorker, we just verify empty config is accepted)
|
||||
assert.Equal(t, "", cfg.TaskQueue)
|
||||
assert.Equal(t, 0, cfg.MaxConcurrentActivity)
|
||||
assert.Equal(t, 0, cfg.MaxConcurrentWorkflow)
|
||||
assert.Equal(t, "", cfg.Identity)
|
||||
}
|
||||
|
||||
func TestRegisterWorkflowWithNilWorker(t *testing.T) {
|
||||
err := RegisterWorkflow(nil, func() {})
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "worker is nil", err.Error())
|
||||
}
|
||||
|
||||
func TestRegisterActivityWithNilWorker(t *testing.T) {
|
||||
err := RegisterActivity(nil, func() {})
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "worker is nil", err.Error())
|
||||
}
|
||||
|
||||
func TestRunWorkerWithNilWorker(t *testing.T) {
|
||||
err := RunWorker(nil)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "worker is nil", err.Error())
|
||||
}
|
||||
|
||||
func TestStopWorkerWithNilWorker(t *testing.T) {
|
||||
// Should not panic
|
||||
StopWorker(nil)
|
||||
}
|
||||
|
||||
func TestWorkerConfigCustomValues(t *testing.T) {
|
||||
cfg := WorkerConfig{
|
||||
TaskQueue: "custom-queue",
|
||||
MaxConcurrentActivity: 20,
|
||||
MaxConcurrentWorkflow: 30,
|
||||
Identity: "custom-identity",
|
||||
}
|
||||
|
||||
assert.Equal(t, "custom-queue", cfg.TaskQueue)
|
||||
assert.Equal(t, 20, cfg.MaxConcurrentActivity)
|
||||
assert.Equal(t, 30, cfg.MaxConcurrentWorkflow)
|
||||
assert.Equal(t, "custom-identity", cfg.Identity)
|
||||
}
|
||||
@@ -4,19 +4,19 @@ kind: Kustomization
|
||||
namespace: poimen
|
||||
|
||||
resources:
|
||||
- poimen-application.yaml
|
||||
- worker-deployment.yaml
|
||||
- workflow-runner-deployment.yaml
|
||||
- workflows-deployment.yaml
|
||||
- git-commit.yaml
|
||||
|
||||
# SOPS-encrypted configmap applied separately via KSOPS plugin:
|
||||
# - configmap.enc.yaml
|
||||
|
||||
commonLabels:
|
||||
app.kubernetes.io/name: poimen
|
||||
app.kubernetes.io/component: worker
|
||||
|
||||
images:
|
||||
- name: forgejo.riotpiao.com/rock/poimen-memory
|
||||
newName: forgejo.riotpiao.com/rock/poimen-memory
|
||||
newTag: latest
|
||||
- name: forgejo.riotpiao.com/rock/poimen-workflows
|
||||
newName: forgejo.riotpiao.com/rock/poimen-workflows
|
||||
newTag: latest
|
||||
- name: forgejo.riotpiao.com/rock/poimen-frontend
|
||||
newName: forgejo.riotpiao.com/rock/poimen-frontend
|
||||
newName: forgejo.riotpiao.com/riotpiao-poimen/poimen-workflows
|
||||
newTag: latest
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: poimen-workflow-runner-config
|
||||
namespace: poimen
|
||||
data:
|
||||
TEMPORAL_HOSTPORT: "temporal-frontend.temporal.svc.cluster.local:7233"
|
||||
TEMPORAL_NAMESPACE: "default"
|
||||
LOG_LEVEL: "info"
|
||||
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: poimen-workflow-runner
|
||||
namespace: poimen
|
||||
labels:
|
||||
app: poimen-workflow-runner
|
||||
component: workflow-runner
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app: poimen-workflow-runner
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: poimen-workflow-runner
|
||||
component: workflow-runner
|
||||
annotations:
|
||||
prometheus.io/scrape: "true"
|
||||
prometheus.io/port: "8081"
|
||||
prometheus.io/path: "/metrics"
|
||||
spec:
|
||||
serviceAccountName: poimen-workflow-runner
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
containers:
|
||||
- name: workflow-runner
|
||||
image: forgejo.riotpiao.com/riotpiao-poimen/poimen-workflows:latest
|
||||
imagePullPolicy: IfNotPresent
|
||||
command: ["./poimen-workflow-runner"]
|
||||
ports:
|
||||
- name: health
|
||||
containerPort: 8081
|
||||
protocol: TCP
|
||||
env:
|
||||
- name: TEMPORAL_HOSTPORT
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: poimen-workflow-runner-config
|
||||
key: TEMPORAL_HOSTPORT
|
||||
- name: TEMPORAL_NAMESPACE
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: poimen-workflow-runner-config
|
||||
key: TEMPORAL_NAMESPACE
|
||||
- name: LOG_LEVEL
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: poimen-workflow-runner-config
|
||||
key: LOG_LEVEL
|
||||
- name: ANTHROPIC_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: poimen-secrets
|
||||
key: anthropic-api-key
|
||||
- name: MEMORY_SERVICE_URL
|
||||
value: "http://poimen-memory.poimen.svc.cluster.local:8080"
|
||||
- name: MEMORY_SERVICE_JWT_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: poimen-secrets
|
||||
key: memory-service-jwt
|
||||
resources:
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health/live
|
||||
port: 8081
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health/ready
|
||||
port: 8081
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 2
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
volumeMounts:
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
volumes:
|
||||
- name: tmp
|
||||
emptyDir:
|
||||
sizeLimit: 100Mi
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: poimen-workflow-runner
|
||||
namespace: poimen
|
||||
labels:
|
||||
app: poimen-workflow-runner
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: poimen-workflow-runner
|
||||
namespace: poimen
|
||||
labels:
|
||||
app: poimen-workflow-runner
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- port: 8081
|
||||
targetPort: 8081
|
||||
protocol: TCP
|
||||
name: health
|
||||
selector:
|
||||
app: poimen-workflow-runner
|
||||
Reference in New Issue
Block a user