master
go 334 lines 7.94 KB
Raw
1 // Package as400 provides IBM i (AS/400) database access helpers for the ibm.d framework.
2 // SPDX-License-Identifier: GPL-3.0-or-later
3
4 //go:build cgo
5
6 package as400
7
8 import (
9 "context"
10 "database/sql"
11 "errors"
12 "fmt"
13 "strings"
14 "time"
15
16 _ "github.com/netdata/netdata/go/plugins/plugin/ibm.d/pkg/dbdriver"
17 )
18
19 var (
20 // ErrFeatureUnavailable indicates that a requested SQL service is not available on this IBM i system.
21 ErrFeatureUnavailable = errors.New("as400 protocol: feature unavailable")
22 // ErrTemporaryFailure indicates a transient database error that may succeed on retry.
23 ErrTemporaryFailure = errors.New("as400 protocol: temporary failure")
24 )
25
26 // RowScanner receives a row's column names and values.
27 // Implementations should treat the values slice as read-only for the duration of the callback.
28 type RowScanner func(columns []string, values []string) error
29
30 // Config represents the connection options required to talk to IBM i.
31 type Config struct {
32 DSN string
33 Timeout time.Duration
34 MaxOpenConns int
35 ConnMaxLife time.Duration
36 }
37
38 // Client wraps the SQL connection and exposes typed query helpers used by the collector.
39 type Client struct {
40 cfg Config
41 db *sql.DB
42 }
43
44 // NewClient creates a new IBM i client with the supplied configuration.
45 func NewClient(cfg Config) *Client {
46 return &Client{cfg: cfg}
47 }
48
49 // Connect ensures the underlying connection is ready.
50 func (c *Client) Connect(ctx context.Context) error {
51 if c.db != nil {
52 return nil
53 }
54
55 if c.cfg.DSN == "" {
56 return errors.New("as400 protocol: DSN is required")
57 }
58
59 db, err := sql.Open("odbcbridge", c.cfg.DSN)
60 if err != nil {
61 return fmt.Errorf("as400 protocol: opening connection failed: %w", err)
62 }
63
64 if c.cfg.MaxOpenConns > 0 {
65 db.SetMaxOpenConns(c.cfg.MaxOpenConns)
66 }
67 if c.cfg.ConnMaxLife > 0 {
68 db.SetConnMaxLifetime(c.cfg.ConnMaxLife)
69 }
70
71 pingCtx, cancel := context.WithTimeout(ctx, c.effectiveTimeout())
72 defer cancel()
73
74 if err := db.PingContext(pingCtx); err != nil {
75 _ = db.Close()
76 return fmt.Errorf("as400 protocol: ping failed: %w", err)
77 }
78
79 c.db = db
80 return nil
81 }
82
83 // Ping verifies connectivity on an existing connection.
84 func (c *Client) Ping(ctx context.Context) error {
85 if c.db == nil {
86 return errors.New("as400 protocol: ping called before connect")
87 }
88 pingCtx, cancel := context.WithTimeout(ctx, c.effectiveTimeout())
89 defer cancel()
90 return c.db.PingContext(pingCtx)
91 }
92
93 // Close terminates the connection.
94 func (c *Client) Close() error {
95 if c.db == nil {
96 return nil
97 }
98 err := c.db.Close()
99 c.db = nil
100 return err
101 }
102
103 // DoQuery executes a query and streams rows to the handler.
104 func (c *Client) DoQuery(ctx context.Context, query string, fn func(column, value string, lineEnd bool)) error {
105 rows, cancel, err := c.QueryRows(ctx, query)
106 if err != nil {
107 return err
108 }
109 defer cancel()
110 defer rows.Close()
111
112 return c.readRows(rows, fn)
113 }
114
115 // DoQueryRow executes a query expected to return a single row.
116 func (c *Client) DoQueryRow(ctx context.Context, query string, fn func(column, value string)) error {
117 rows, cancel, err := c.QueryRows(ctx, query)
118 if err != nil {
119 return err
120 }
121 defer cancel()
122 defer rows.Close()
123
124 columns, err := rows.Columns()
125 if err != nil {
126 return fmt.Errorf("as400 protocol: fetching columns failed: %w", err)
127 }
128
129 scan := make([]sql.RawBytes, len(columns))
130 pointers := make([]any, len(columns))
131 for i := range scan {
132 pointers[i] = &scan[i]
133 }
134
135 if rows.Next() {
136 if err := rows.Scan(pointers...); err != nil {
137 return fmt.Errorf("as400 protocol: scanning row failed: %w", err)
138 }
139 for idx, col := range columns {
140 fn(col, string(scan[idx]))
141 }
142 }
143
144 return rows.Err()
145 }
146
147 // QueryRows runs the query and returns the raw rows.
148 func (c *Client) QueryRows(ctx context.Context, query string) (*sql.Rows, context.CancelFunc, error) {
149 if err := c.Connect(ctx); err != nil {
150 return nil, nil, err
151 }
152
153 queryCtx, cancel := context.WithTimeout(ctx, c.effectiveTimeout())
154 rows, err := c.db.QueryContext(queryCtx, query)
155 if err != nil {
156 cancel()
157 return nil, nil, classifySQLError(err)
158 }
159
160 return rows, cancel, nil
161 }
162
163 // Query executes the query and streams rows to the provided scanner.
164 func (c *Client) Query(ctx context.Context, query string, scan RowScanner) error {
165 rows, cancel, err := c.QueryRows(ctx, query)
166 if err != nil {
167 return err
168 }
169 defer cancel()
170 defer rows.Close()
171
172 columns, err := rows.Columns()
173 if err != nil {
174 return fmt.Errorf("as400 protocol: reading columns failed: %w", err)
175 }
176
177 raw := make([]sql.RawBytes, len(columns))
178 ptrs := make([]any, len(columns))
179 for i := range raw {
180 ptrs[i] = &raw[i]
181 }
182
183 values := make([]string, len(columns))
184
185 for rows.Next() {
186 if err := rows.Scan(ptrs...); err != nil {
187 return fmt.Errorf("as400 protocol: scanning row failed: %w", err)
188 }
189 for i := range raw {
190 if raw[i] == nil {
191 values[i] = "NULL"
192 } else {
193 values[i] = string(raw[i])
194 }
195 }
196 if err := scan(columns, values); err != nil {
197 return err
198 }
199 }
200
201 return rows.Err()
202 }
203
204 // QueryWithLimit executes the query and enforces a FETCH FIRST limit when limit > 0.
205 func (c *Client) QueryWithLimit(ctx context.Context, query string, limit int, scan RowScanner) error {
206 return c.Query(ctx, applyFetchLimit(query, limit), scan)
207 }
208
209 // Exec runs a statement that does not return rows.
210 func (c *Client) Exec(ctx context.Context, statement string) error {
211 if err := c.Connect(ctx); err != nil {
212 return err
213 }
214 execCtx, cancel := context.WithTimeout(ctx, c.effectiveTimeout())
215 defer cancel()
216 if _, err := c.db.ExecContext(execCtx, statement); err != nil {
217 return classifySQLError(err)
218 }
219 return nil
220 }
221
222 func applyFetchLimit(query string, limit int) string {
223 if limit <= 0 {
224 return query
225 }
226 trimmed := strings.TrimSpace(query)
227 upper := strings.ToUpper(trimmed)
228 if strings.Contains(upper, "FETCH FIRST") {
229 return trimmed
230 }
231 return fmt.Sprintf("%s FETCH FIRST %d ROWS ONLY", trimmed, limit)
232 }
233
234 func (c *Client) readRows(rows *sql.Rows, fn func(column, value string, lineEnd bool)) error {
235 columns, err := rows.Columns()
236 if err != nil {
237 return fmt.Errorf("as400 protocol: reading columns failed: %w", err)
238 }
239
240 scan := make([]sql.RawBytes, len(columns))
241 pointers := make([]any, len(columns))
242 for i := range scan {
243 pointers[i] = &scan[i]
244 }
245
246 for rows.Next() {
247 if err := rows.Scan(pointers...); err != nil {
248 return fmt.Errorf("as400 protocol: scanning row failed: %w", err)
249 }
250 for idx, col := range columns {
251 fn(col, string(scan[idx]), idx == len(columns)-1)
252 }
253 }
254
255 return rows.Err()
256 }
257
258 func (c *Client) effectiveTimeout() time.Duration {
259 if c.cfg.Timeout > 0 {
260 return c.cfg.Timeout
261 }
262 return 5 * time.Second
263 }
264
265 // DB exposes the underlying handle for operations that still rely on *sql.DB.
266 func (c *Client) DB() *sql.DB {
267 return c.db
268 }
269
270 var featureErrorTokens = []string{
271 "SQL0204",
272 "SQL0206",
273 "SQL0443",
274 "SQL0551",
275 "SQL7024",
276 "SQL0707",
277 "SQLCODE=-204",
278 "SQLCODE=-206",
279 "SQLCODE=-443",
280 "SQLCODE=-551",
281 "SQLCODE=-707",
282 }
283
284 var temporaryErrorTokens = []string{
285 "SQL0519",
286 "SQLCODE=-519",
287 }
288
289 // IsFeatureError reports whether err indicates an unavailable SQL service.
290 func IsFeatureError(err error) bool {
291 if err == nil {
292 return false
293 }
294 if errors.Is(err, ErrFeatureUnavailable) {
295 return true
296 }
297 msg := strings.ToUpper(err.Error())
298 for _, token := range featureErrorTokens {
299 if strings.Contains(msg, token) {
300 return true
301 }
302 }
303 return false
304 }
305
306 // IsTemporaryError reports whether err is a transient database error that may succeed on retry.
307 func IsTemporaryError(err error) bool {
308 if err == nil {
309 return false
310 }
311 if errors.Is(err, ErrTemporaryFailure) {
312 return true
313 }
314 msg := strings.ToUpper(err.Error())
315 for _, token := range temporaryErrorTokens {
316 if strings.Contains(msg, token) {
317 return true
318 }
319 }
320 return false
321 }
322
323 func classifySQLError(err error) error {
324 if err == nil {
325 return nil
326 }
327 if IsFeatureError(err) {
328 return fmt.Errorf("%w: %s", ErrFeatureUnavailable, err)
329 }
330 if IsTemporaryError(err) {
331 return fmt.Errorf("%w: %s", ErrTemporaryFailure, err)
332 }
333 return fmt.Errorf("as400 protocol: query failed: %w", err)
334 }