diff --git a/Justfile b/Justfile index f1bf425767..1216713f22 100644 --- a/Justfile +++ b/Justfile @@ -46,7 +46,8 @@ DOCKER_IMAGES := ''' "cron": {}, "http-ingress": {}, "runner": {}, - "runner-jvm": {} + "runner-jvm": {}, + "timeline": {} } ''' diff --git a/backend/controller/admin/local_client_test.go b/backend/controller/admin/local_client_test.go index db95b4ac15..46a20ede60 100644 --- a/backend/controller/admin/local_client_test.go +++ b/backend/controller/admin/local_client_test.go @@ -29,6 +29,7 @@ func TestDiskSchemaRetrieverWithBuildArtefact(t *testing.T) { in.WithFTLConfig("ftl-project-dr.toml"), in.WithoutController(), in.WithoutProvisioner(), + in.WithoutTimeline(), in.CopyModule("dischema"), in.Build("dischema"), func(t testing.TB, ic in.TestContext) { @@ -47,6 +48,7 @@ func TestDiskSchemaRetrieverWithNoSchema(t *testing.T) { in.WithFTLConfig("ftl-project-dr.toml"), in.WithoutController(), in.WithoutProvisioner(), + in.WithoutTimeline(), in.CopyModule("dischema"), func(t testing.TB, ic in.TestContext) { _, err := getDiskSchema(t, ic.Context) diff --git a/backend/controller/encryption/integration_test.go b/backend/controller/encryption/integration_test.go index 79898ea739..df9960a14a 100644 --- a/backend/controller/encryption/integration_test.go +++ b/backend/controller/encryption/integration_test.go @@ -62,6 +62,7 @@ func TestKMSEncryptorLocalstack(ts *testing.T) { in.WithLocalstack(), in.WithoutController(), in.WithoutProvisioner(), + in.WithoutTimeline(), in.Action(func(t testing.TB, ic in.TestContext) { endpoint := "http://localhost:4566" diff --git a/backend/controller/sql/database_integration_test.go b/backend/controller/sql/database_integration_test.go index d6f1fb7c86..2e475b0537 100644 --- a/backend/controller/sql/database_integration_test.go +++ b/backend/controller/sql/database_integration_test.go @@ -54,6 +54,7 @@ func TestMigrate(t *testing.T) { in.Run(t, in.WithoutController(), in.WithoutProvisioner(), + in.WithoutTimeline(), in.DropDBAction(t, dbName), in.Fail(q(), "Should fail because the database does not exist."), in.Exec("ftl", "migrate", "--dsn", dbUri), diff --git a/charts/ftl/templates/_helpers.tpl b/charts/ftl/templates/_helpers.tpl index c773eabaca..6581edbd3c 100644 --- a/charts/ftl/templates/_helpers.tpl +++ b/charts/ftl/templates/_helpers.tpl @@ -56,4 +56,9 @@ app.kubernetes.io/instance: {{ .Release.Name }} app.kubernetes.io/name: {{ include "ftl.fullname" . }} app.kubernetes.io/component: http-ingress app.kubernetes.io/instance: {{ .Release.Name }} -{{- end -}} \ No newline at end of file +{{- end -}} +{{- define "ftl-timeline.selectorLabels" -}} +app.kubernetes.io/name: {{ include "ftl.fullname" . }} +app.kubernetes.io/component: timeline +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end -}} diff --git a/charts/ftl/templates/controller.yaml b/charts/ftl/templates/controller.yaml index 132e09ee30..3fc7605ee1 100644 --- a/charts/ftl/templates/controller.yaml +++ b/charts/ftl/templates/controller.yaml @@ -53,6 +53,8 @@ spec: - name: FTL_CONTROLLER_DSN value: "postgres://{{ .Release.Name }}-postgresql:5432/{{ .Values.postgresql.global.postgresql.auth.database }}?sslmode=disable&user={{ .Values.postgresql.global.postgresql.auth.username }}&password={{ .Values.postgresql.global.postgresql.auth.password }}" {{- end }} + - name: FTL_TIMELINE_ENDPOINT + value: "http://{{ .Values.timeline.service.name }}:{{ .Values.timeline.service.port }}" {{- if .Values.controller.kmsUri }} - name: FTL_KMS_URI value: "{{ .Values.controller.kmsUri }}" diff --git a/charts/ftl/templates/timeline-deployment.yaml b/charts/ftl/templates/timeline-deployment.yaml new file mode 100644 index 0000000000..a6c826ce94 --- /dev/null +++ b/charts/ftl/templates/timeline-deployment.yaml @@ -0,0 +1,83 @@ +{{- if .Values.timeline.enabled }} +{{ $version := printf "v%s" .Chart.Version -}} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "ftl.fullname" . }}-timeline + labels: + {{- include "ftl.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.timeline.replicas }} + revisionHistoryLimit: {{ .Values.timeline.revisionHistoryLimit }} + selector: + matchLabels: + {{- include "ftl-timeline.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "ftl-timeline.selectorLabels" . | nindent 8 }} + {{- if .Values.timeline.podAnnotations }} + annotations: + {{- toYaml .Values.timeline.podAnnotations | nindent 8 }} + {{- end }} + spec: + serviceAccountName: {{ .Values.timeline.serviceAccountName }} + containers: + - name: app + image: "{{ .Values.timeline.image.repository }}:{{ .Values.timeline.image.tag | default $version }}" + imagePullPolicy: {{ .Values.timeline.image.pullPolicy }} + resources: + limits: + cpu: "{{ .Values.timeline.resources.limits.cpu }}" + memory: "{{ .Values.timeline.resources.limits.memory }}" + requests: + cpu: "{{ .Values.timeline.resources.requests.cpu }}" + memory: "{{ .Values.timeline.resources.requests.memory }}" + {{- if .Values.timeline.envFrom }} + envFrom: + {{- if .Values.timeline.envFrom }} + {{- toYaml .Values.timeline.envFrom | nindent 12 }} + {{- end }} + {{- end }} + env: + {{- if .Values.timeline.env }} + {{- toYaml .Values.timeline.env | nindent 12 }} + {{- end }} + + ports: + {{- range .Values.timeline.ports }} + - name: {{ .name }} + containerPort: {{ .containerPort }} + protocol: {{ .protocol | default "TCP" }} + {{- end }} + readinessProbe: + {{- if .Values.timeline.readinessProbe }} + {{- toYaml .Values.timeline.readinessProbe | nindent 12 }} + {{- else }} + httpGet: + path: /healthz + port: 8892 + initialDelaySeconds: 1 + periodSeconds: 2 + timeoutSeconds: 2 + successThreshold: 1 + failureThreshold: 15 + {{- end }} + {{- if .Values.timeline.nodeSelector }} + nodeSelector: + {{- toYaml .Values.timeline.nodeSelector | nindent 8 }} + {{- end }} + {{- if .Values.timeline.affinity }} + affinity: + {{- toYaml .Values.timeline.affinity | nindent 8 }} + {{- end }} + {{- if .Values.timeline.topologySpreadConstraints }} + topologySpreadConstraints: + {{- toYaml .Values.timeline.topologySpreadConstraints | nindent 8 }} + {{- end }} + {{- if .Values.timeline.tolerations }} + tolerations: + {{- toYaml .Values.timeline.tolerations | nindent 8 }} + {{- end }} + + {{- end }} diff --git a/charts/ftl/templates/timeline-role.yaml b/charts/ftl/templates/timeline-role.yaml new file mode 100644 index 0000000000..bf6f92aa3e --- /dev/null +++ b/charts/ftl/templates/timeline-role.yaml @@ -0,0 +1,8 @@ +{{- if .Values.timeline.enabled }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ .Values.timeline.serviceAccountName }} + namespace: {{ .Release.Namespace }} + +{{- end }} diff --git a/charts/ftl/templates/timeline-services.yaml b/charts/ftl/templates/timeline-services.yaml new file mode 100644 index 0000000000..edb8473aea --- /dev/null +++ b/charts/ftl/templates/timeline-services.yaml @@ -0,0 +1,24 @@ +{{- if .Values.timeline.enabled }} +apiVersion: v1 +kind: Service +metadata: + labels: + {{- include "ftl.labels" . | nindent 4 }} + name: {{ include "ftl.fullname" . }}-timeline + {{- if .Values.timeline.service.annotations }} + annotations: + {{- toYaml .Values.timeline.service.annotations | nindent 4 }} + {{- end }} +spec: + ports: + {{- range .Values.timeline.service.ports }} + - name: {{ .name }} + port: {{ .port }} + protocol: {{ .protocol | default "TCP" }} + targetPort: {{ .targetPort }} + {{- end }} + selector: + {{- include "ftl-timeline.selectorLabels" . | nindent 4 }} + type: {{ .Values.timeline.service.type | default "ClusterIP" }} + +{{- end }} diff --git a/charts/ftl/values.yaml b/charts/ftl/values.yaml index aba69b132c..52839c4414 100644 --- a/charts/ftl/values.yaml +++ b/charts/ftl/values.yaml @@ -305,3 +305,63 @@ ingress: affinity: null topologySpreadConstraints: null tolerations: null + +timeline: + enabled: true + env: + - name: MY_POD_IP + valueFrom: + fieldRef: + fieldPath: status.podIP + - name: FTL_ENDPOINT + value: "http://ftl-controller:8892" + - name: FTL_BIND + value: "http://$(MY_POD_IP):8892" + - name: HOST_IP + valueFrom: + fieldRef: + fieldPath: status.hostIP + ports: + - name: http + containerPort: 8892 + protocol: TCP + port: 8892 + + ingressAnnotations: + kubernetes.io/ingress.class: nginx + replicas: 1 + revisionHistoryLimit: 0 + + image: + repository: "ftl0/ftl-timeline" + pullPolicy: IfNotPresent + + resources: + requests: + memory: 512Mi + cpu: 10m + limits: + memory: 512Mi + cpu: 2 + + envFrom: null + serviceAccountName: ftl-timeline + + readinessProbe: null + + service: + type: ClusterIP + annotations: null + ports: + - name: "http-8892" + port: 80 + protocol: TCP + targetPort: 8892 + + podAnnotations: + proxy.istio.io/config: | + holdApplicationUntilProxyStarts: true + nodeSelector: null + affinity: null + topologySpreadConstraints: null + tolerations: null diff --git a/cmd/ftl-controller/main.go b/cmd/ftl-controller/main.go index 5c15034858..453910c9d7 100644 --- a/cmd/ftl-controller/main.go +++ b/cmd/ftl-controller/main.go @@ -3,6 +3,7 @@ package main import ( "context" "fmt" + "net/url" "os" "strconv" "time" @@ -13,6 +14,7 @@ import ( "github.com/TBD54566975/ftl" "github.com/TBD54566975/ftl/backend/controller" + "github.com/TBD54566975/ftl/backend/protos/xyz/block/ftl/timeline/v1/timelinev1connect" _ "github.com/TBD54566975/ftl/internal/automaxprocs" // Set GOMAXPROCS to match Linux container CPU quota. cf "github.com/TBD54566975/ftl/internal/configuration" cfdal "github.com/TBD54566975/ftl/internal/configuration/dal" @@ -22,6 +24,7 @@ import ( "github.com/TBD54566975/ftl/internal/dsn" "github.com/TBD54566975/ftl/internal/log" "github.com/TBD54566975/ftl/internal/observability" + "github.com/TBD54566975/ftl/internal/rpc" ) var cli struct { @@ -31,6 +34,7 @@ var cli struct { ControllerConfig controller.Config `embed:""` ConfigFlag string `name:"config" short:"C" help:"Path to FTL project cf file." env:"FTL_CONFIG" placeholder:"FILE"` DisableIstio bool `help:"Disable Istio integration. This will prevent the creation of Istio policies to limit network traffic." env:"FTL_DISABLE_ISTIO"` + TimelineEndpoint *url.URL `help:"Timeline endpoint." env:"FTL_TIMELINE_ENDPOINT" default:"http://127.0.0.1:8894"` } func main() { @@ -65,6 +69,9 @@ func main() { cm, err := manager.New(ctx, configResolver, providers.NewDatabaseConfig(configDal)) kctx.FatalIfErrorf(err) + timelineServiceClient := rpc.Dial(timelinev1connect.NewTimelineServiceClient, cli.TimelineEndpoint.String(), log.Error) + ctx = rpc.ContextWithClient(ctx, timelineServiceClient) + // The FTL controller currently only supports AWS Secrets Manager as a secrets provider. awsConfig, err := config.LoadDefaultConfig(ctx) kctx.FatalIfErrorf(err) diff --git a/deployment/Justfile b/deployment/Justfile index 1212519636..21c1a01e5e 100644 --- a/deployment/Justfile +++ b/deployment/Justfile @@ -27,6 +27,7 @@ full-deploy: build-all-images setup-istio-cluster kubectl rollout restart deployment ftl-provisioner || true # if this exists already restart it to get the latest image kubectl rollout restart deployment ftl-cron || true # if this exists already restart it to get the latest image kubectl rollout restart deployment ftl-http-ingress || true # if this exists already restart it to get the latest image + kubectl rollout restart deployment ftl-timeline || true # if this exists already restart it to get the latest image just apply || sleep 5 # wait for CRDs to be created, the initial apply will usually fail just apply @@ -35,6 +36,7 @@ wait-for-kube: #!/bin/bash while [ -z "$(kubectl get pod ftl-postgresql-0)" ]; do sleep 1; done kubectl wait --for=condition=ready pod/ftl-postgresql-0 --timeout=5m + kubectl wait --for=condition=available deployment/ftl-timeline --timeout=5m kubectl wait --for=condition=available deployment/ftl-controller --timeout=5m kubectl wait --for=condition=available deployment/ftl-http-ingress --timeout=5m kubectl wait --for=condition=available deployment/registry --timeout=5m @@ -59,7 +61,7 @@ setup-cluster: setup-registry #!/bin/bash bash -c "cd ../ && just chart dep-update" if [ -z "$(k3d cluster list | grep ftl)" ]; then - k3d cluster create ftl --api-port 6550 -p "8892:80@loadbalancer" -p "8891:80@loadbalancer" -p "8893:80@loadbalancer" --agents 2 \ + k3d cluster create ftl --api-port 6550 -p "8892:80@loadbalancer" -p "8891:80@loadbalancer" -p "8893:80@loadbalancer" --agents 2 \ --registry-use {{registry_full}} \ --registry-config '{{mirrors}}'\ --k3s-arg '--kubelet-arg=eviction-hard=imagefs.available<1%,nodefs.available<1%@agent:*' \ diff --git a/deployment/values.yaml b/deployment/values.yaml index b46ae4439c..f4cb955199 100644 --- a/deployment/values.yaml +++ b/deployment/values.yaml @@ -41,6 +41,12 @@ provisioner: ports: - name: "http-8893" port: 8893 +timeline: + image: + repository: "ftl:5000/ftl-timeline" + tag: "latest" + pullPolicy: Always + cron: image: repository: "ftl:5000/ftl-cron" diff --git a/frontend/cli/integration_test.go b/frontend/cli/integration_test.go index 06456d0be8..80fd18d5ab 100644 --- a/frontend/cli/integration_test.go +++ b/frontend/cli/integration_test.go @@ -17,7 +17,7 @@ func TestConfigsWithController(t *testing.T) { } func TestConfigsWithoutController(t *testing.T) { - Run(t, configActions(t, WithoutController(), WithoutProvisioner())...) + Run(t, configActions(t, WithoutController(), WithoutProvisioner(), WithoutTimeline())...) } func configActions(t *testing.T, prepend ...ActionOrOption) []ActionOrOption { @@ -44,7 +44,7 @@ func TestSecretsWithController(t *testing.T) { } func TestSecretsWithoutController(t *testing.T) { - Run(t, secretActions(t, WithoutController(), WithoutProvisioner())...) + Run(t, secretActions(t, WithoutController(), WithoutProvisioner(), WithoutTimeline())...) } func secretActions(t *testing.T, prepend ...ActionOrOption) []ActionOrOption { @@ -97,6 +97,7 @@ func testImportExport(t *testing.T, object string) { Run(t, WithoutController(), WithoutProvisioner(), + WithoutTimeline(), // duplicate project file in the temp directory Exec("cp", firstProjFile, secondProjFile), // import into first project file diff --git a/go-runtime/ftl/ftltest/ftltest_integration_test.go b/go-runtime/ftl/ftltest/ftltest_integration_test.go index ece545c14e..2e7cfd5437 100644 --- a/go-runtime/ftl/ftltest/ftltest_integration_test.go +++ b/go-runtime/ftl/ftltest/ftltest_integration_test.go @@ -13,6 +13,7 @@ func TestModuleUnitTests(t *testing.T) { in.WithFTLConfig("wrapped/ftl-project.toml"), in.WithoutController(), in.WithoutProvisioner(), + in.WithoutTimeline(), in.GitInit(), in.CopyModule("time"), in.CopyModule("wrapped"), diff --git a/internal/buildengine/languageplugin/plugin_integration_test.go b/internal/buildengine/languageplugin/plugin_integration_test.go index 2e6af93566..8c7456ce78 100644 --- a/internal/buildengine/languageplugin/plugin_integration_test.go +++ b/internal/buildengine/languageplugin/plugin_integration_test.go @@ -69,6 +69,7 @@ func TestBuilds(t *testing.T) { in.WithLanguages("go"), in.WithoutController(), in.WithoutProvisioner(), + in.WithoutTimeline(), in.CopyModule(MODULE_NAME), startPlugin(), setUpModuleConfig(MODULE_NAME), @@ -130,6 +131,7 @@ func TestDependenciesUpdate(t *testing.T) { in.WithLanguages("go"), //no java support yet, as it relies on writeGenericSchemaFiles in.WithoutController(), in.WithoutProvisioner(), + in.WithoutTimeline(), in.CopyModule(MODULE_NAME), startPlugin(), setUpModuleConfig(MODULE_NAME), @@ -165,6 +167,7 @@ func TestBuildLock(t *testing.T) { in.WithLanguages("go"), in.WithoutController(), in.WithoutProvisioner(), + in.WithoutTimeline(), in.CopyModule(MODULE_NAME), startPlugin(), setUpModuleConfig(MODULE_NAME), @@ -194,6 +197,7 @@ func TestBuildsWhenAlreadyLocked(t *testing.T) { in.WithLanguages("go"), in.WithoutController(), in.WithoutProvisioner(), + in.WithoutTimeline(), in.CopyModule(MODULE_NAME), startPlugin(), setUpModuleConfig(MODULE_NAME), diff --git a/internal/integration/actions.go b/internal/integration/actions.go index ccd1923e7f..1d15b80a32 100644 --- a/internal/integration/actions.go +++ b/internal/integration/actions.go @@ -30,6 +30,7 @@ import ( ftlv1 "github.com/TBD54566975/ftl/backend/protos/xyz/block/ftl/v1" schemapb "github.com/TBD54566975/ftl/backend/protos/xyz/block/ftl/schema/v1" + timelinepb "github.com/TBD54566975/ftl/backend/protos/xyz/block/ftl/timeline/v1" "github.com/TBD54566975/ftl/internal/dsn" ftlexec "github.com/TBD54566975/ftl/internal/exec" "github.com/TBD54566975/ftl/internal/log" @@ -473,6 +474,20 @@ func VerifySchemaVerb(module string, verb string, check func(ctx context.Context } } +// VerifyTimeline lets you test the current timeline +func VerifyTimeline(filters []*timelinepb.GetTimelineRequest_Filter, check func(ctx context.Context, t testing.TB, events []*timelinepb.Event)) Action { + return func(t testing.TB, ic TestContext) { + resp, err := ic.Timeline.GetTimeline(ic, connect.NewRequest(&timelinepb.GetTimelineRequest{ + Filters: filters, + })) + if err != nil { + t.Errorf("failed to get timeline: %v", err) + return + } + check(ic.Context, t, resp.Msg.Events) + } +} + // Fail expects the next action to Fail. func Fail(next Action, msg string, args ...any) Action { return func(t testing.TB, ic TestContext) { diff --git a/internal/integration/harness.go b/internal/integration/harness.go index 07f0e9ccac..3e032f0ed8 100644 --- a/internal/integration/harness.go +++ b/internal/integration/harness.go @@ -30,6 +30,7 @@ import ( "github.com/TBD54566975/ftl/backend/protos/xyz/block/ftl/console/v1/pbconsoleconnect" provisionerconnect "github.com/TBD54566975/ftl/backend/protos/xyz/block/ftl/provisioner/v1beta1/provisionerpbconnect" + "github.com/TBD54566975/ftl/backend/protos/xyz/block/ftl/timeline/v1/timelinev1connect" ftlv1 "github.com/TBD54566975/ftl/backend/protos/xyz/block/ftl/v1" "github.com/TBD54566975/ftl/backend/protos/xyz/block/ftl/v1/ftlv1connect" "github.com/TBD54566975/ftl/backend/provisioner/scaling/k8sscaling" @@ -152,6 +153,13 @@ func WithoutProvisioner() Option { } } +// WithoutTimeline is a Run* option that disables starting the timeline service. +func WithoutTimeline() Option { + return func(o *options) { + o.startTimeline = false + } +} + // WithProvisionerConfig is a Run* option that specifies the provisioner config to use. func WithProvisionerConfig(config string) Option { return func(o *options) { @@ -173,6 +181,7 @@ type options struct { startController bool devMode bool startProvisioner bool + startTimeline bool provisionerConfig string requireJava bool envars map[string]string @@ -192,6 +201,7 @@ func run(t *testing.T, actionsOrOptions ...ActionOrOption) { opts := options{ startController: true, startProvisioner: true, + startTimeline: true, languages: []string{"go"}, envars: map[string]string{}, } @@ -353,6 +363,16 @@ func run(t *testing.T, actionsOrOptions ...ActionOrOption) { } defer dumpKubePods(ctx, ic.kubeClient, ic.kubeNamespace) + if opts.startTimeline && !opts.kube { + ic.Timeline = rpc.Dial(timelinev1connect.NewTimelineServiceClient, "http://localhost:8894", log.Debug) + + Infof("Waiting for timeline to be ready") + ic.AssertWithRetry(t, func(t testing.TB, ic TestContext) { + _, err := ic.Timeline.Ping(ic, connect.NewRequest(&ftlv1.PingRequest{})) + assert.NoError(t, err) + }) + } + if opts.startController || opts.kube { ic.Controller = controller ic.Schema = schema @@ -436,6 +456,7 @@ type TestContext struct { Schema ftlv1connect.SchemaServiceClient Console pbconsoleconnect.ConsoleServiceClient Verbs ftlv1connect.VerbServiceClient + Timeline timelinev1connect.TimelineServiceClient realT *testing.T } diff --git a/internal/projectconfig/integration_test.go b/internal/projectconfig/integration_test.go index 889dbe7497..5f7b67f538 100644 --- a/internal/projectconfig/integration_test.go +++ b/internal/projectconfig/integration_test.go @@ -30,6 +30,7 @@ func TestConfigCmdWithoutController(t *testing.T) { in.WithFTLConfig("configs-ftl-project.toml"), in.WithoutController(), in.WithoutProvisioner(), + in.WithoutTimeline(), in.ExecWithExpectedOutput("\"value\"\n", "ftl", "config", "get", "key"), ) } @@ -56,6 +57,7 @@ func TestFindConfig(t *testing.T) { in.Run(t, in.WithoutController(), in.WithoutProvisioner(), + in.WithoutTimeline(), in.CopyModule("findconfig"), checkConfig("findconfig"), checkConfig("findconfig/subdir"),