Skip to content

Commit 96d1684

Browse files
fix: MariaDB compatibility for JSON queries and native password auth. Fixes argoproj#15413 (argoproj#15936)
Signed-off-by: Mario Apostolov <mario.apostolov@mariadb.com>
1 parent 351d8cd commit 96d1684

10 files changed

Lines changed: 241 additions & 19 deletions

.spelling

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ Lifecycle-Hook
9191
LitmusChaos
9292
MLOps
9393
Makefile
94+
MariaDB
9495
Metaflow
9596
MinIO
9697
Minikube

docs/offloading-large-workflows.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
55
Argo stores workflows as Kubernetes resources (i.e. within EtcD). This creates a limit to their size as resources must be under 1MB. Each resource includes the status of each node, which is stored in the `/status/nodes` field for the resource. This can be over 1MB. If this happens, we try and compress the node status and store it in `/status/compressedNodes`. If the status is still too large, we then try and store it in an SQL database.
66

7-
To enable this feature, configure a Postgres or MySQL database under `persistence` in [your configuration](workflow-controller-configmap.yaml) and set `nodeStatusOffLoad: true`.
7+
To enable this feature, configure a Postgres, MySQL, or MariaDB database under `persistence` in [your configuration](workflow-controller-configmap.yaml) and set `nodeStatusOffLoad: true`.
88

99
## FAQ
1010

docs/synchronization-config.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ Database-based limits allow multiple workflow controllers (typically across diff
3131

3232
Before you can manage database limits via the API, you must:
3333

34-
1. Configure a PostgreSQL or MySQL database for synchronization (see [workflow synchronization](synchronization.md#database-configuration))
34+
1. Configure a PostgreSQL, MySQL, or MariaDB database for synchronization (see [workflow synchronization](synchronization.md#database-configuration))
3535
2. Enable the synchronization API in your workflow controller configuration
3636

3737
### Enable the API

docs/synchronization.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ spec:
6868
Multiple controllers can share locks using a database as an intermediary.
6969
This would normally be used to share locks across multiple clusters, but can also be used to share locks across multiple controllers in the same cluster.
7070
71-
To configure multiple controller locks, you need to set up a database (either PostgreSQL or MySQL) and [configure it](#database-configuration) in the workflow-controller-configmap ConfigMap.
71+
To configure multiple controller locks, you need to set up a database (either PostgreSQL, MySQL, or MariaDB) and [configure it](#database-configuration) in the workflow-controller-configmap ConfigMap.
7272
All controllers which want to share locks must share all of these tables.
7373
If you do not configure the database you will get an error if you try to use database locks.
7474

docs/workflow-archive.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
> v2.5 and after
44
5-
If you want to keep completed workflows for a long time, you can use the workflow archive to save them in a Postgres (>=9.4) or MySQL (>= 5.7.8) database.
5+
If you want to keep completed workflows for a long time, you can use the workflow archive to save them in a Postgres (>=9.4), MySQL (>= 5.7.8), or MariaDB (>= 10.2) database.
66
The workflow archive stores the status of the workflow, which pods have been executed, what was the result etc.
77
The job logs of the workflow pods will not be archived.
88
If you need to save the logs of the pods, you must setup an [artifact repository](artifact-repository-ref.md) according to [this doc](configure-artifact-repository.md).

persist/sqldb/workflow_archive.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -184,14 +184,14 @@ func (r *workflowArchive) ListWorkflows(ctx context.Context, options sutils.List
184184
baseSelector := s.SQL().Select("name", "namespace", "uid", "phase", "startedat", "finishedat", "creationtimestamp")
185185
selectQuery := baseSelector.
186186
Columns(
187-
db.Raw("coalesce(workflow->'$.metadata.labels', '{}') as labels"),
188-
db.Raw("coalesce(workflow->'$.metadata.annotations', '{}') as annotations"),
189-
db.Raw("coalesce(workflow->>'$.status.progress', '') as progress"),
190-
db.Raw("workflow->>'$.spec.suspend'"),
191-
db.Raw("coalesce(workflow->>'$.spec.arguments', '{}') as arguments"),
192-
db.Raw("coalesce(workflow->>'$.status.message', '') as message"),
193-
db.Raw("coalesce(workflow->>'$.status.estimatedDuration', '0') as estimatedduration"),
194-
db.Raw("coalesce(workflow->'$.status.resourcesDuration', '{}') as resourcesduration"),
187+
db.Raw("coalesce(JSON_EXTRACT(workflow, '$.metadata.labels'), '{}') as labels"),
188+
db.Raw("coalesce(JSON_EXTRACT(workflow, '$.metadata.annotations'), '{}') as annotations"),
189+
db.Raw("coalesce(JSON_UNQUOTE(JSON_EXTRACT(workflow, '$.status.progress')), '') as progress"),
190+
db.Raw("JSON_UNQUOTE(JSON_EXTRACT(workflow, '$.spec.suspend')) as suspend"),
191+
db.Raw("coalesce(JSON_EXTRACT(workflow, '$.spec.arguments'), '{}') as arguments"),
192+
db.Raw("coalesce(JSON_UNQUOTE(JSON_EXTRACT(workflow, '$.status.message')), '') as message"),
193+
db.Raw("coalesce(JSON_UNQUOTE(JSON_EXTRACT(workflow, '$.status.estimatedDuration')), '0') as estimatedduration"),
194+
db.Raw("coalesce(JSON_EXTRACT(workflow, '$.status.resourcesDuration'), '{}') as resourcesduration"),
195195
).
196196
From(archiveTableName).
197197
Where(r.clusterManagedNamespaceAndInstanceID())
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
//go:build !windows
2+
3+
package sqldb
4+
5+
import (
6+
"context"
7+
"strconv"
8+
"testing"
9+
"time"
10+
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
testcontainers "github.com/testcontainers/testcontainers-go"
14+
testmysql "github.com/testcontainers/testcontainers-go/modules/mysql"
15+
"github.com/testcontainers/testcontainers-go/wait"
16+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
17+
"k8s.io/apimachinery/pkg/types"
18+
19+
"github.com/argoproj/argo-workflows/v4/config"
20+
wfv1 "github.com/argoproj/argo-workflows/v4/pkg/apis/workflow/v1alpha1"
21+
sutils "github.com/argoproj/argo-workflows/v4/server/utils"
22+
"github.com/argoproj/argo-workflows/v4/util/instanceid"
23+
"github.com/argoproj/argo-workflows/v4/util/logging"
24+
usqldb "github.com/argoproj/argo-workflows/v4/util/sqldb"
25+
)
26+
27+
// setupMySQLArchiveTest starts a MySQL or MariaDB container, runs migrations, and returns a WorkflowArchive.
28+
func setupMySQLArchiveTest(ctx context.Context, t *testing.T, v usqldb.MySQLVariant) WorkflowArchive {
29+
t.Helper()
30+
31+
c, err := testmysql.Run(ctx,
32+
v.Image,
33+
testmysql.WithDatabase("argo"),
34+
testmysql.WithUsername("argo"),
35+
testmysql.WithPassword("argo"),
36+
testcontainers.WithWaitStrategy(
37+
wait.ForAll(
38+
wait.ForLog(v.WaitMessage).WithStartupTimeout(60*time.Second),
39+
wait.ForListeningPort("3306/tcp"),
40+
)),
41+
)
42+
require.NoError(t, err)
43+
t.Cleanup(func() {
44+
if termErr := testcontainers.TerminateContainer(c); termErr != nil {
45+
t.Logf("failed to terminate container: %s", termErr)
46+
}
47+
})
48+
49+
host, err := c.Host(ctx)
50+
require.NoError(t, err)
51+
p, err := c.MappedPort(ctx, "3306/tcp")
52+
require.NoError(t, err)
53+
port, err := strconv.Atoi(p.Port())
54+
require.NoError(t, err)
55+
56+
proxy, err := usqldb.NewSessionProxy(ctx, usqldb.SessionProxyConfig{
57+
DBConfig: config.DBConfig{
58+
MySQL: &config.MySQLConfig{
59+
DatabaseConfig: config.DatabaseConfig{
60+
Database: "argo",
61+
Host: host,
62+
Port: port,
63+
},
64+
},
65+
},
66+
Username: "argo",
67+
Password: "argo",
68+
})
69+
require.NoError(t, err)
70+
71+
err = Migrate(ctx, proxy.Session(), "test", "argo_workflows", proxy.DBType())
72+
require.NoError(t, err)
73+
74+
t.Cleanup(func() { proxy.Close() })
75+
76+
return NewWorkflowArchive(proxy, "test", "", instanceid.NewService(""))
77+
}
78+
79+
// TestMySQLListWorkflows verifies that JSON_EXTRACT/JSON_UNQUOTE queries in
80+
// ListWorkflows execute correctly against both MySQL and MariaDB.
81+
func TestMySQLListWorkflows(t *testing.T) {
82+
for name, variant := range usqldb.MySQLVariants {
83+
t.Run(name, func(t *testing.T) {
84+
ctx := logging.TestContext(t.Context())
85+
archive := setupMySQLArchiveTest(ctx, t, variant)
86+
87+
now := metav1.Now()
88+
err := archive.ArchiveWorkflow(ctx, &wfv1.Workflow{
89+
ObjectMeta: metav1.ObjectMeta{
90+
Name: "test-wf",
91+
Namespace: "default",
92+
UID: types.UID("test-uid-001"),
93+
CreationTimestamp: now,
94+
Labels: map[string]string{"env": "test"},
95+
Annotations: map[string]string{"note": "integration-test"},
96+
},
97+
Spec: wfv1.WorkflowSpec{
98+
Suspend: new(true),
99+
Arguments: wfv1.Arguments{
100+
Parameters: []wfv1.Parameter{
101+
{Name: "msg", Value: wfv1.AnyStringPtr("hello")},
102+
},
103+
},
104+
},
105+
Status: wfv1.WorkflowStatus{
106+
Phase: wfv1.WorkflowSucceeded,
107+
StartedAt: now,
108+
FinishedAt: now,
109+
Progress: "1/1",
110+
Message: "completed",
111+
EstimatedDuration: wfv1.EstimatedDuration(30),
112+
},
113+
})
114+
require.NoError(t, err)
115+
116+
results, err := archive.ListWorkflows(ctx, sutils.ListOptions{Namespace: "default", Limit: 10})
117+
require.NoError(t, err)
118+
require.Len(t, results, 1)
119+
120+
wf := results[0]
121+
assert.Equal(t, "test-wf", wf.Name)
122+
assert.Equal(t, wfv1.WorkflowSucceeded, wf.Status.Phase)
123+
assert.Equal(t, wfv1.Progress("1/1"), wf.Status.Progress)
124+
assert.Equal(t, "completed", wf.Status.Message)
125+
assert.Equal(t, "test", wf.GetLabels()["env"])
126+
assert.Equal(t, "integration-test", wf.GetAnnotations()["note"])
127+
assert.Equal(t, new(true), wf.Spec.Suspend)
128+
assert.Equal(t, "hello", wf.Spec.Arguments.Parameters[0].Value.String())
129+
assert.Equal(t, wfv1.EstimatedDuration(30), wf.Status.EstimatedDuration)
130+
})
131+
}
132+
}

util/sqldb/mysql_test_helper.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package sqldb
2+
3+
// MySQLVariant defines a MySQL-compatible database image for integration testing.
4+
type MySQLVariant struct {
5+
Image string
6+
WaitMessage string
7+
}
8+
9+
// MySQLVariants contains the set of MySQL-compatible databases to test against.
10+
var MySQLVariants = map[string]MySQLVariant{
11+
"MySQL": {Image: "mysql:8.4", WaitMessage: "port: 3306 MySQL Community Server"},
12+
"MariaDB": {Image: "mariadb:11.4", WaitMessage: "mariadbd: ready for connections"},
13+
}

util/sqldb/sqldb.go

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -196,13 +196,14 @@ func createPostGresDBSessionWithCreds(cfg *config.PostgreSQLConfig, persistPool
196196
func createMySQLDBSessionWithCreds(cfg *config.MySQLConfig, persistPool *config.ConnectionPool, username, password string) (db.Session, error) {
197197
// Build MySQL DSN using mysql.Config to safely handle special characters in credentials
198198
mysqlCfg := mysql.Config{
199-
User: username,
200-
Passwd: password,
201-
Net: "tcp",
202-
Addr: cfg.GetHostname(),
203-
DBName: cfg.Database,
204-
ParseTime: true,
205-
Params: cfg.Options,
199+
User: username,
200+
Passwd: password,
201+
Net: "tcp",
202+
Addr: cfg.GetHostname(),
203+
DBName: cfg.Database,
204+
ParseTime: true,
205+
AllowNativePasswords: true, // Required for MariaDB which uses mysql_native_password by default
206+
Params: cfg.Options,
206207
}
207208
dsn := mysqlCfg.FormatDSN()
208209

util/sqldb/sqldb_test.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
//go:build !windows
2+
3+
package sqldb
4+
5+
import (
6+
"context"
7+
"strconv"
8+
"testing"
9+
"time"
10+
11+
"github.com/stretchr/testify/require"
12+
testcontainers "github.com/testcontainers/testcontainers-go"
13+
testmysql "github.com/testcontainers/testcontainers-go/modules/mysql"
14+
"github.com/testcontainers/testcontainers-go/wait"
15+
16+
"github.com/argoproj/argo-workflows/v4/config"
17+
"github.com/argoproj/argo-workflows/v4/util/logging"
18+
)
19+
20+
// setupMySQLContainer starts a MySQL or MariaDB container and returns the corresponding DBConfig.
21+
func setupMySQLContainer(ctx context.Context, t *testing.T, v MySQLVariant) config.DBConfig {
22+
t.Helper()
23+
24+
c, err := testmysql.Run(ctx,
25+
v.Image,
26+
testmysql.WithDatabase("argo"),
27+
testmysql.WithUsername("argo"),
28+
testmysql.WithPassword("argo"),
29+
testcontainers.WithWaitStrategy(
30+
wait.ForAll(
31+
wait.ForLog(v.WaitMessage).WithStartupTimeout(60*time.Second),
32+
wait.ForListeningPort("3306/tcp"),
33+
)),
34+
)
35+
require.NoError(t, err)
36+
t.Cleanup(func() {
37+
if termErr := testcontainers.TerminateContainer(c); termErr != nil {
38+
t.Logf("failed to terminate container: %s", termErr)
39+
}
40+
})
41+
42+
host, err := c.Host(ctx)
43+
require.NoError(t, err)
44+
p, err := c.MappedPort(ctx, "3306/tcp")
45+
require.NoError(t, err)
46+
port, err := strconv.Atoi(p.Port())
47+
require.NoError(t, err)
48+
49+
return config.DBConfig{
50+
MySQL: &config.MySQLConfig{
51+
DatabaseConfig: config.DatabaseConfig{
52+
Database: "argo",
53+
Host: host,
54+
Port: port,
55+
},
56+
},
57+
}
58+
}
59+
60+
// TestMySQLSessionConnect verifies that CreateDBSessionWithCreds can connect
61+
// to both MySQL and MariaDB. MariaDB requires AllowNativePasswords.
62+
func TestMySQLSessionConnect(t *testing.T) {
63+
for name, variant := range MySQLVariants {
64+
t.Run(name, func(t *testing.T) {
65+
ctx := logging.TestContext(t.Context())
66+
dbConfig := setupMySQLContainer(ctx, t, variant)
67+
68+
sess, _, err := CreateDBSessionWithCreds(dbConfig, "argo", "argo")
69+
require.NoError(t, err)
70+
defer sess.Close()
71+
72+
require.NoError(t, sess.Ping())
73+
})
74+
}
75+
}

0 commit comments

Comments
 (0)