diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 23e2075..115bd9f 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -8,7 +8,7 @@ on: doltdb_engine_version: description: "DoltDB engine version to test against" required: false - default: "1.83.6" + default: "2.4.1" issue_comment: types: [created] @@ -22,7 +22,7 @@ permissions: env: GO_VERSION_FILE: go.mod - DOLTDB_ENGINE_VERSION: ${{ github.event.inputs.doltdb_engine_version || '1.83.6' }} + DOLTDB_ENGINE_VERSION: ${{ github.event.inputs.doltdb_engine_version || '2.4.1' }} KIND_CLUSTER_NAME: dolt jobs: @@ -55,7 +55,7 @@ jobs: - name: Install kind run: | - KIND_VERSION="v0.30.0" + KIND_VERSION="v0.33.0" curl -Lo ./kind "https://kind.sigs.k8s.io/dl/${KIND_VERSION}/kind-linux-amd64" chmod +x ./kind sudo mv ./kind /usr/local/bin/kind diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml new file mode 100644 index 0000000..790ca64 --- /dev/null +++ b/.github/workflows/promote.yml @@ -0,0 +1,32 @@ +name: Promote Release + +on: + workflow_call: + inputs: + tag_name: + description: "Release tag to promote (e.g. v0.1.0)" + required: true + type: string + workflow_dispatch: + inputs: + tag_name: + description: "Release tag to promote (e.g. v0.1.0)" + required: true + type: string + +permissions: + contents: write + +jobs: + promote: + name: Promote Pre-release + runs-on: ubuntu-latest + steps: + - name: Promote to release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release edit "${{ inputs.tag_name }}" \ + --repo "${{ github.repository }}" \ + --prerelease=false \ + --latest diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..d754209 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,119 @@ +name: Publish Helm Charts + +on: + release: + types: [published] + workflow_dispatch: + inputs: + version: + description: "Release version (MAJOR.MINOR.PATCH)" + required: true + type: string + latest: + description: "Tag artifacts as latest" + required: false + type: boolean + default: false + promote: + description: "Promote the release after publishing" + required: false + type: boolean + default: false + +permissions: + contents: write + id-token: write + +env: + REGISTRY: public.ecr.aws/dolthub + IMAGE_NAME: doltdb-operator-image + +jobs: + release: + name: Build and Publish + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.release.tag_name || format('v{0}', inputs.version) }} + + - name: Compute version + id: version + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + VERSION="${{ inputs.version }}" + IS_LATEST="${{ inputs.latest }}" + else + TAG="${{ github.event.release.tag_name }}" + VERSION="${TAG#v}" + IS_LATEST="true" + fi + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "is_latest=${IS_LATEST}" >> "$GITHUB_OUTPUT" + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2 + with: + role-to-assume: ${{ secrets.ROLE_TO_ASSUME }} + aws-region: us-east-1 + + - name: Login to ECR Public + run: | + aws ecr-public get-login-password --region us-east-1 | \ + docker login --username AWS --password-stdin public.ecr.aws + aws ecr-public get-login-password --region us-east-1 | \ + helm registry login --username AWS --password-stdin public.ecr.aws + + - name: Set up QEMU + uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0 + + - name: Build and push container image + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }} + ${{ steps.version.outputs.is_latest == 'true' && format('{0}/{1}:latest', env.REGISTRY, env.IMAGE_NAME) || '' }} + + - name: Setup Helm + uses: azure/setup-helm@b9e51907a09c216f16ebe8536097933489208112 # v4.3.0 + + - name: Setup crane + uses: imjasonh/setup-crane@31b88efe9de28ae0ffa220711af4b60be9435f6e # v0.4 + + - name: Update chart image reference + run: | + VERSION="${{ steps.version.outputs.version }}" + sed -i "s|repository: .*|repository: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}|" charts/doltdb-operator/values.yaml + sed -i "s|tag: .*|tag: \"${VERSION}\"|" charts/doltdb-operator/values.yaml + + - name: Package and push Helm charts + run: | + VERSION="${{ steps.version.outputs.version }}" + IS_LATEST="${{ steps.version.outputs.is_latest }}" + + helm package charts/doltdb-operator-crds --version "$VERSION" --app-version "$VERSION" + helm package charts/doltdb-operator --version "$VERSION" --app-version "$VERSION" + + helm push "doltdb-operator-crds-${VERSION}.tgz" "oci://${{ env.REGISTRY }}" + helm push "doltdb-operator-${VERSION}.tgz" "oci://${{ env.REGISTRY }}" + + if [ "$IS_LATEST" = "true" ]; then + crane tag "${{ env.REGISTRY }}/doltdb-operator-crds:${VERSION}" latest + crane tag "${{ env.REGISTRY }}/doltdb-operator:${VERSION}" latest + fi + + promote: + name: Promote Release + needs: release + if: github.event_name == 'release' || inputs.promote + uses: ./.github/workflows/promote.yml + with: + tag_name: ${{ github.event.release.tag_name || format('v{0}', inputs.version) }} + secrets: inherit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..1e7ff91 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,79 @@ +name: Release + +on: + workflow_dispatch: + inputs: + version: + description: "Release version (MAJOR.MINOR.PATCH)" + required: true + type: string + +permissions: + contents: write + +jobs: + release: + name: Create Release + runs-on: ubuntu-latest + steps: + - name: Validate version + run: | + if [[ ! "${{ inputs.version }}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Version must be in MAJOR.MINOR.PATCH format (e.g. 0.2.0)" + exit 1 + fi + + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: main + + - name: Verify on main branch + run: | + if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]; then + echo "::error::Releases must be created from the main branch" + exit 1 + fi + + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v1.11.7 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Create release + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + VERSION: ${{ inputs.version }} + run: | + INSTALL_NOTES="$(cat </dev/null)) set -e ;\ mkdir -p $(dir $(KUBECTL)) ;\ OS=$(shell go env GOOS) && ARCH=$(shell go env GOARCH) && \ - curl -sSLo $(KUBECTL) https://dl.k8s.io/release/v1.31.0/bin/linux/$${ARCH}/kubectl ;\ + curl -sSLo $(KUBECTL) https://dl.k8s.io/release/v1.36.4/bin/$${OS}/$${ARCH}/kubectl ;\ chmod +x $(KUBECTL) ;\ } else diff --git a/pkg/builder/container_builder.go b/pkg/builder/container_builder.go index 18e5055..9919384 100644 --- a/pkg/builder/container_builder.go +++ b/pkg/builder/container_builder.go @@ -21,6 +21,9 @@ const ( DoltMetricsPortName = "http-metrics" DoltProfilerPortName = "http-profiler" DoltMCPPortName = "http-mcp" + // DoltUserRemotesAPIPortName names the user-facing remotes API port, which serves + // gRPC and plain HTTP table-file transfers on one port. + DoltUserRemotesAPIPortName = "http-remotesapi" DoltDataVolume = "dolt-data" DoltConfigVolume = "dolt-config" @@ -145,6 +148,13 @@ func doltContainerPorts(doltdb *doltv1alpha.DoltDB) []corev1.ContainerPort { }) } + if doltdb.Spec.Server.RemotesAPI != nil { + ports = append(ports, corev1.ContainerPort{ + ContainerPort: doltdb.Spec.Server.RemotesAPI.Port, + Name: DoltUserRemotesAPIPortName, + }) + } + return ports } diff --git a/pkg/builder/container_builder_test.go b/pkg/builder/container_builder_test.go index 90631c2..e55b9d8 100644 --- a/pkg/builder/container_builder_test.go +++ b/pkg/builder/container_builder_test.go @@ -180,3 +180,58 @@ func TestDoltContainerPortsMCP(t *testing.T) { }) } } + +func TestDoltContainerPortsRemotesAPI(t *testing.T) { + tests := []struct { + name string + remotesAPI *doltv1alpha.ServerRemotesAPI + expectedPort int32 + }{ + { + name: "remotes API disabled - no remotes API port", + }, + { + name: "remotes API enabled - default port", + remotesAPI: &doltv1alpha.ServerRemotesAPI{Port: 50052}, + expectedPort: 50052, + }, + { + name: "remotes API enabled - custom port", + remotesAPI: &doltv1alpha.ServerRemotesAPI{Port: 9000, ReadOnly: true}, + expectedPort: 9000, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + doltdb := &doltv1alpha.DoltDB{ + Spec: doltv1alpha.DoltDBSpec{ + Server: doltv1alpha.Server{ + Listener: doltv1alpha.Listener{Port: 3306}, + RemotesAPI: tt.remotesAPI, + }, + }, + } + + var remotesPort *int32 + for _, p := range doltContainerPorts(doltdb) { + if p.Name == DoltUserRemotesAPIPortName { + remotesPort = &p.ContainerPort + } + } + + if tt.expectedPort == 0 { + if remotesPort != nil { + t.Errorf("unexpected remotes API container port %d", *remotesPort) + } + return + } + if remotesPort == nil { + t.Fatal("expected remotes API container port, not found") + } + if *remotesPort != tt.expectedPort { + t.Errorf("remotes API port: expected %d, got %d", tt.expectedPort, *remotesPort) + } + }) + } +} diff --git a/pkg/builder/service_builder.go b/pkg/builder/service_builder.go index 3479424..ac306ee 100644 --- a/pkg/builder/service_builder.go +++ b/pkg/builder/service_builder.go @@ -10,8 +10,10 @@ import ( "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" ) -// doltServicePorts returns the service ports for the Dolt cluster. -func doltServicePorts(doltdb *doltv1alpha.DoltDB) []v1.ServicePort { +// doltServicePorts returns the service ports for the Dolt cluster. The user-facing remotes API port is +// only added when withRemotesAPI is set, i.e. for Services that never route to a standby: Dolt does not +// refuse pushes to a standby through that API. +func doltServicePorts(doltdb *doltv1alpha.DoltDB, withRemotesAPI bool) []v1.ServicePort { ports := []v1.ServicePort{ { Port: doltdb.Spec.Server.Listener.Port, @@ -26,6 +28,13 @@ func doltServicePorts(doltdb *doltv1alpha.DoltDB) []v1.ServicePort { }) } + if withRemotesAPI && doltdb.Spec.Server.RemotesAPI != nil { + ports = append(ports, v1.ServicePort{ + Port: doltdb.Spec.Server.RemotesAPI.Port, + Name: DoltUserRemotesAPIPortName, + }) + } + return ports } @@ -85,7 +94,7 @@ func (b *Builder) BuildDoltService(doltdb *doltv1alpha.DoltDB) (*v1.Service, err svc := &v1.Service{ ObjectMeta: objMeta, Spec: v1.ServiceSpec{ - Ports: doltServicePorts(doltdb), + Ports: doltServicePorts(doltdb, true), Type: v1.ServiceTypeClusterIP, Selector: labels, }, @@ -112,7 +121,7 @@ func (b *Builder) BuildDoltPrimaryService(doltdb *doltv1alpha.DoltDB) (*v1.Servi svc := &v1.Service{ ObjectMeta: objMeta, Spec: v1.ServiceSpec{ - Ports: doltServicePorts(doltdb), + Ports: doltServicePorts(doltdb, true), Type: v1.ServiceTypeClusterIP, Selector: labels, }, @@ -135,7 +144,7 @@ func (b *Builder) BuildDoltReaderService(doltdb *doltv1alpha.DoltDB) (*v1.Servic svc := &v1.Service{ ObjectMeta: objMeta, Spec: v1.ServiceSpec{ - Ports: doltServicePorts(doltdb), + Ports: doltServicePorts(doltdb, false), Type: v1.ServiceTypeClusterIP, Selector: labels, }, diff --git a/pkg/builder/service_builder_test.go b/pkg/builder/service_builder_test.go index 8d80d1e..85733e8 100644 --- a/pkg/builder/service_builder_test.go +++ b/pkg/builder/service_builder_test.go @@ -109,7 +109,7 @@ func TestDoltServicePorts(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - ports := doltServicePorts(tt.doltdb) + ports := doltServicePorts(tt.doltdb, true) if len(ports) != len(tt.expectedPorts) { t.Fatalf("expected %d ports, got %d", len(tt.expectedPorts), len(ports)) @@ -578,3 +578,101 @@ func TestBuildDoltReaderService(t *testing.T) { }) } } + +func TestServicesRemotesAPIPort(t *testing.T) { + builder := newTestBuilder() + + newDoltDB := func(remotesAPI *doltv1alpha.ServerRemotesAPI) *doltv1alpha.DoltDB { + return &doltv1alpha.DoltDB{ + ObjectMeta: metav1.ObjectMeta{ + Name: "doltdb-remotes", + Namespace: "default", + }, + Spec: doltv1alpha.DoltDBSpec{ + Server: doltv1alpha.Server{ + Listener: doltv1alpha.Listener{Port: 3306}, + Cluster: doltv1alpha.Cluster{ + RemotesAPI: doltv1alpha.RemotesAPI{Port: 50051}, + }, + RemotesAPI: remotesAPI, + }, + }, + Status: doltv1alpha.DoltDBStatus{ + CurrentPrimaryPodIndex: ptr.To(0), + }, + } + } + builders := map[string]func(*doltv1alpha.DoltDB) (*corev1.Service, error){ + "standalone": builder.BuildDoltService, + "primary": builder.BuildDoltPrimaryService, + "reader": builder.BuildDoltReaderService, + "internal": builder.BuildDoltInternalService, + } + + tests := []struct { + name string + service string + remotesAPI *doltv1alpha.ServerRemotesAPI + expectedPort int32 + }{ + { + name: "standalone service exposes the remotes API", + service: "standalone", + remotesAPI: &doltv1alpha.ServerRemotesAPI{Port: 50052}, + expectedPort: 50052, + }, + { + name: "primary service exposes the remotes API on a custom port", + service: "primary", + remotesAPI: &doltv1alpha.ServerRemotesAPI{Port: 9000}, + expectedPort: 9000, + }, + { + name: "reader service does not expose the remotes API", + service: "reader", + remotesAPI: &doltv1alpha.ServerRemotesAPI{Port: 50052}, + }, + { + name: "internal service does not expose the remotes API", + service: "internal", + remotesAPI: &doltv1alpha.ServerRemotesAPI{Port: 50052}, + }, + { + name: "standalone service without remotes API", + service: "standalone", + }, + { + name: "primary service without remotes API", + service: "primary", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc, err := builders[tt.service](newDoltDB(tt.remotesAPI)) + if err != nil { + t.Fatalf("unexpected error building %s service: %v", tt.service, err) + } + + var remotesPort *corev1.ServicePort + for i := range svc.Spec.Ports { + if svc.Spec.Ports[i].Name == DoltUserRemotesAPIPortName { + remotesPort = &svc.Spec.Ports[i] + } + } + + if tt.expectedPort == 0 { + if remotesPort != nil { + t.Errorf("unexpected remotes API port %d on %s service", remotesPort.Port, tt.service) + } + return + } + if remotesPort == nil { + t.Fatalf("expected remotes API port on %s service, not found", tt.service) + } + if remotesPort.Port != tt.expectedPort { + t.Errorf("remotes API port: expected %d, got %d", tt.expectedPort, remotesPort.Port) + } + }) + } +} diff --git a/pkg/dolt/config.go b/pkg/dolt/config.go index e7c6718..cf3b6bd 100644 --- a/pkg/dolt/config.go +++ b/pkg/dolt/config.go @@ -23,6 +23,14 @@ type Config struct { Listener Listener `yaml:"listener"` Metrics Metrics `yaml:"metrics,omitempty"` MCPServer *MCPServer `yaml:"mcp_server,omitempty"` + // RemotesAPI is the user-facing remotes API, separate from Cluster.RemotesAPI. + RemotesAPI *UserRemotesAPI `yaml:"remotesapi,omitempty"` +} + +// UserRemotesAPI represents the top-level remotesapi section of the configuration. +type UserRemotesAPI struct { + Port int32 `yaml:"port"` + ReadOnly bool `yaml:"read_only,omitempty"` } // MCPServer represents the mcp_server section of the configuration. @@ -35,6 +43,7 @@ type MCPServer struct { type Behavior struct { AutoGCBehavior AutoGCBehavior `yaml:"auto_gc_behavior"` + EventScheduler string `yaml:"event_scheduler,omitempty"` } type AutoGCBehavior struct { diff --git a/pkg/dolt/generate.go b/pkg/dolt/generate.go index fc0bb0d..dfbe4c0 100644 --- a/pkg/dolt/generate.go +++ b/pkg/dolt/generate.go @@ -28,6 +28,7 @@ func GenerateConfigMapData(doltdb *doltv1alpha.DoltDB) (map[string]string, error AutoGCBehavior: AutoGCBehavior{ Enable: doltdb.Spec.Server.Behavior.AutoGCBehavior.Enable, }, + EventScheduler: doltdb.Spec.Server.Behavior.EventScheduler, }, LogLevel: doltdb.Spec.Server.LogLevel, Listener: Listener{ @@ -70,6 +71,13 @@ func GenerateConfigMapData(doltdb *doltv1alpha.DoltDB) (map[string]string, error } } + if doltdb.Spec.Server.RemotesAPI != nil { + config.RemotesAPI = &UserRemotesAPI{ + Port: doltdb.Spec.Server.RemotesAPI.Port, + ReadOnly: doltdb.Spec.Server.RemotesAPI.ReadOnly, + } + } + yamlData, err := yaml.Marshal(config) if err != nil { return nil, fmt.Errorf("error marshaling DoltDB config to YAML: %v", err) diff --git a/pkg/dolt/generate_test.go b/pkg/dolt/generate_test.go index fc13e30..588d286 100644 --- a/pkg/dolt/generate_test.go +++ b/pkg/dolt/generate_test.go @@ -238,6 +238,35 @@ func TestGenerateConfigMapData(t *testing.T) { expectedData: readTestData(t, "mcp_server_default_creds.yaml"), expectedError: false, }, + { + name: "with user-facing remotes API and event scheduler", + doltdb: &doltv1alpha.DoltDB{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cluster", + Namespace: "default", + }, + Spec: doltv1alpha.DoltDBSpec{ + Replicas: 1, + Server: doltv1alpha.Server{ + Behavior: doltv1alpha.Behavior{ + EventScheduler: "OFF", + }, + Listener: doltv1alpha.Listener{ + Host: "0.0.0.0", + Port: 3306, + MaxConnections: 128, + }, + RemotesAPI: &doltv1alpha.ServerRemotesAPI{ + Port: 50052, + ReadOnly: true, + }, + LogLevel: "trace", + }, + }, + }, + expectedData: readTestData(t, "remotesapi_event_scheduler.yaml"), + expectedError: false, + }, } for _, tt := range tests { @@ -427,6 +456,24 @@ func TestConfigMCPServerOmitEmpty(t *testing.T) { } } +func TestConfigRemotesAPIAndEventSchedulerOmitEmpty(t *testing.T) { + config := Config{ + Behavior: Behavior{AutoGCBehavior: AutoGCBehavior{Enable: false}}, + LogLevel: "trace", + Listener: Listener{Host: "0.0.0.0", Port: 3306, MaxConnections: 128}, + } + yamlData, err := yaml.Marshal(config) + if err != nil { + t.Fatalf("unexpected error marshaling config: %v", err) + } + if strings.Contains(string(yamlData), "remotesapi:") { + t.Error("config with nil RemotesAPI should not contain 'remotesapi:' in YAML output") + } + if strings.Contains(string(yamlData), "event_scheduler:") { + t.Error("config without an event scheduler setting should not contain 'event_scheduler:' in YAML output") + } +} + func newTestDoltDBWithMCP(mcp *doltv1alpha.MCPServer) *doltv1alpha.DoltDB { return &doltv1alpha.DoltDB{ ObjectMeta: metav1.ObjectMeta{ diff --git a/pkg/dolt/testdata/remotesapi_event_scheduler.yaml b/pkg/dolt/testdata/remotesapi_event_scheduler.yaml new file mode 100644 index 0000000..efc8c0e --- /dev/null +++ b/pkg/dolt/testdata/remotesapi_event_scheduler.yaml @@ -0,0 +1,13 @@ +test-cluster-0.yaml: + behavior: + auto_gc_behavior: + enable: false + event_scheduler: "OFF" + log_level: trace + listener: + host: 0.0.0.0 + max_connections: 128 + port: 3306 + remotesapi: + port: 50052 + read_only: true