Skip to content

Commit 865a2cd

Browse files
committed
Add dbconnect foundation: result types and env-key mapping
First of a stacked series splitting the databricks dbconnect feature (umbrella branch dbconnect-init-sync / PR #5690) into small, single- concern PRs. Each layer is independently reviewable and adds no user-facing surface until the final PR wires the command in. This PR is the foundation the rest of the stack builds on: - result.go: the result types and the --json / E_* error contract that every phase reports through (Result, PipelineError, ErrorCode, PhaseName, PhaseStatus, Mode, TargetInfo, ResolvedInfo, Plan, Warning). - envkey.go: mapping a compute target to an environment key (EnvKeyForServerless, EnvKeyForSparkVersion, NormalizeServerless) and parsing the Python minor from a requires-python specifier. Nothing imports this package yet, so the CLI is unchanged. The unexported filesystem/artifact constants and the canonical phase-order slice live with the pipeline that consumes them (a later PR in the stack), keeping this layer to just the contract types. Co-authored-by: Isaac
1 parent 47f40db commit 865a2cd

4 files changed

Lines changed: 268 additions & 0 deletions

File tree

libs/dbconnect/envkey.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package dbconnect
2+
3+
import (
4+
"fmt"
5+
"regexp"
6+
"strings"
7+
)
8+
9+
var pythonVersionRe = regexp.MustCompile(`(\d+)\.(\d+)`)
10+
11+
// NormalizeServerless returns the canonical "vN" spelling of a serverless
12+
// version accepting "4", "v4", or "V4".
13+
func NormalizeServerless(version string) string {
14+
return "v" + strings.TrimPrefix(strings.ToLower(version), "v")
15+
}
16+
17+
// EnvKeyForServerless returns the environment key for a serverless version.
18+
func EnvKeyForServerless(version string) string {
19+
return "serverless/serverless-" + NormalizeServerless(version)
20+
}
21+
22+
// EnvKeyForSparkVersion returns the environment key for a Spark version.
23+
func EnvKeyForSparkVersion(sparkVersion string) string {
24+
return "dbr/" + sparkVersion
25+
}
26+
27+
// PythonMinorFromRequires parses a PEP 440 requires-python string and extracts MAJOR.MINOR.
28+
func PythonMinorFromRequires(requiresPython string) (string, error) {
29+
match := pythonVersionRe.FindStringSubmatch(requiresPython)
30+
if match == nil {
31+
return "", fmt.Errorf("cannot parse python version from %q", requiresPython)
32+
}
33+
return fmt.Sprintf("%s.%s", match[1], match[2]), nil
34+
}

libs/dbconnect/envkey_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package dbconnect
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
func TestEnvKeyForServerless(t *testing.T) {
11+
for _, in := range []string{"4", "v4", "V4"} {
12+
assert.Equal(t, "serverless/serverless-v4", EnvKeyForServerless(in))
13+
}
14+
}
15+
16+
func TestEnvKeyForSparkVersion(t *testing.T) {
17+
assert.Equal(t, "dbr/15.4.x-scala2.12", EnvKeyForSparkVersion("15.4.x-scala2.12"))
18+
}
19+
20+
func TestPythonMinorFromRequires(t *testing.T) {
21+
cases := map[string]string{
22+
"==3.12.*": "3.12",
23+
">=3.12": "3.12",
24+
"==3.12.3": "3.12",
25+
"~=3.11": "3.11",
26+
}
27+
for in, want := range cases {
28+
got, err := PythonMinorFromRequires(in)
29+
require.NoError(t, err)
30+
assert.Equal(t, want, got)
31+
}
32+
_, err := PythonMinorFromRequires("garbage")
33+
assert.Error(t, err)
34+
}

libs/dbconnect/result.go

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
package dbconnect
2+
3+
import "fmt"
4+
5+
// Command path components, defined once so a rename touches a single place
6+
// (spec §0 / invariant 8 / scenario 21). The cmd layer builds the Cobra
7+
// command from CommandGroup/CommandVerb; the --json "command" field uses
8+
// CommandName. No other string re-spells the command path.
9+
const (
10+
CommandGroup = "dbconnect"
11+
CommandVerb = "sync"
12+
CommandName = CommandGroup + " " + CommandVerb
13+
14+
// SchemaVersion is the version of the --json output contract (spec §6).
15+
// Bump it on any breaking change to the JSON shape.
16+
SchemaVersion = 1
17+
)
18+
19+
// Mode is the provisioning mode: a full environment (default) or the
20+
// constraints-only variant that omits the databricks-connect dependency.
21+
type Mode int
22+
23+
const (
24+
ModeDefault Mode = iota
25+
ModeConstraintsOnly
26+
)
27+
28+
// String returns the JSON/text spelling of the mode ("default" | "constraints-only").
29+
func (m Mode) String() string {
30+
if m == ModeConstraintsOnly {
31+
return "constraints-only"
32+
}
33+
return "default"
34+
}
35+
36+
// PhaseName is a canonical execution phase (spec §3 / §6). The set is fixed and
37+
// ordered; the --json "phases" array reports every phase in this order.
38+
type PhaseName string
39+
40+
const (
41+
PhasePreflight PhaseName = "preflight"
42+
PhaseResolve PhaseName = "resolve"
43+
PhaseFetch PhaseName = "fetch"
44+
PhaseMerge PhaseName = "merge"
45+
PhaseProvision PhaseName = "provision"
46+
PhaseValidate PhaseName = "validate"
47+
)
48+
49+
// Phase status values (spec §6.2).
50+
const (
51+
StatusOK = "ok"
52+
StatusError = "error"
53+
StatusPending = "pending"
54+
)
55+
56+
// ErrorCode is a stable failure-class identifier surfaced in --json error.code
57+
// (spec §7). Values are compared via the ErrorCode constants, never by
58+
// string-matching messages, and are defined once here.
59+
type ErrorCode string
60+
61+
const (
62+
ErrNoTarget ErrorCode = "E_NO_TARGET"
63+
ErrManagerUnsupported ErrorCode = "E_MANAGER_UNSUPPORTED"
64+
ErrUvMissing ErrorCode = "E_UV_MISSING"
65+
ErrNotWritable ErrorCode = "E_NOT_WRITABLE"
66+
ErrResolve ErrorCode = "E_RESOLVE"
67+
ErrEnvUnsupported ErrorCode = "E_ENV_UNSUPPORTED"
68+
ErrFetch ErrorCode = "E_FETCH"
69+
ErrWrite ErrorCode = "E_WRITE"
70+
ErrMerge ErrorCode = "E_MERGE"
71+
ErrPythonInstall ErrorCode = "E_PYTHON_INSTALL"
72+
ErrProvision ErrorCode = "E_PROVISION"
73+
ErrValidate ErrorCode = "E_VALIDATE"
74+
)
75+
76+
// PipelineError is a failure carrying a stable code, the phase at which it
77+
// occurred, and whether disk was mutated before the failure. It marshals to the
78+
// --json error object (spec §6.2). Code and FailurePhase are the stable
79+
// contract; Err holds the wrapped cause for errors.Is/As and is not serialized.
80+
type PipelineError struct {
81+
Code ErrorCode `json:"code"`
82+
FailurePhase PhaseName `json:"failurePhase"`
83+
Msg string `json:"message"`
84+
DiskMutated bool `json:"diskMutated"`
85+
Err error `json:"-"`
86+
}
87+
88+
func (e *PipelineError) Error() string {
89+
if e.Err != nil {
90+
return e.Msg + ": " + e.Err.Error()
91+
}
92+
return e.Msg
93+
}
94+
95+
func (e *PipelineError) Unwrap() error {
96+
return e.Err
97+
}
98+
99+
// NewError creates a PipelineError with a code and message. FailurePhase and
100+
// DiskMutated are filled in by the pipeline when it records the failure. The
101+
// message is formatted with fmt.Sprintf(format, args...); err may be nil.
102+
func NewError(code ErrorCode, err error, format string, args ...any) *PipelineError {
103+
return &PipelineError{
104+
Code: code,
105+
Msg: fmt.Sprintf(format, args...),
106+
Err: err,
107+
}
108+
}
109+
110+
// TargetInfo is the resolved compute target (spec §6 "target"). Source records
111+
// which of the four precedence sources was used. SparkVersion is the raw cluster
112+
// runtime string the resolver read; it is folded into EnvKey (dbr/<SparkVersion>)
113+
// and is not part of the JSON contract, kept only as intermediate resolver state.
114+
type TargetInfo struct {
115+
Source string `json:"source"`
116+
ClusterID string `json:"clusterId,omitempty"`
117+
ServerlessVersion string `json:"serverlessVersion,omitempty"`
118+
EnvKey string `json:"envKey"`
119+
120+
SparkVersion string `json:"-"`
121+
}
122+
123+
// ResolvedInfo is the resolved environment definition (spec §6 "resolved").
124+
// DBConnectVersion is omitted in constraints-only mode.
125+
type ResolvedInfo struct {
126+
PythonVersion string `json:"pythonVersion"`
127+
DBConnectVersion string `json:"dbconnectVersion,omitempty"`
128+
ArtifactSource string `json:"artifactSource"`
129+
}
130+
131+
// Plan describes the changes a --check run would apply (spec §6.3).
132+
// ChangedRegions is retained for text output only and is not serialized.
133+
type Plan struct {
134+
WouldWrite string `json:"wouldWrite"`
135+
WouldBackup string `json:"wouldBackup,omitempty"`
136+
WouldInstallPython string `json:"wouldInstallPython,omitempty"`
137+
Diff string `json:"diff"`
138+
139+
ChangedRegions []string `json:"-"`
140+
}
141+
142+
// PhaseStatus is one entry in the --json "phases" array (spec §6). Detail is
143+
// used for human-readable text output only and is not serialized.
144+
type PhaseStatus struct {
145+
Phase PhaseName `json:"phase"`
146+
Status string `json:"status"`
147+
148+
Detail string `json:"-"`
149+
}
150+
151+
// Warning is a non-fatal advisory surfaced in --json "warnings" (spec §6).
152+
type Warning struct {
153+
Code string `json:"code"`
154+
Message string `json:"message"`
155+
}
156+
157+
// Result is the full outcome of a sync run and the root of the --json object
158+
// (spec §6). Field order matches the spec's schema so JSON key order is stable.
159+
type Result struct {
160+
SchemaVersion int `json:"schemaVersion"`
161+
Command string `json:"command"`
162+
OK bool `json:"ok"`
163+
Mode string `json:"mode"`
164+
DryRun bool `json:"dryRun"`
165+
Target *TargetInfo `json:"target,omitempty"`
166+
Resolved *ResolvedInfo `json:"resolved,omitempty"`
167+
Greenfield bool `json:"greenfield"`
168+
Plan *Plan `json:"plan,omitempty"`
169+
VenvPath string `json:"venvPath,omitempty"`
170+
Phases []PhaseStatus `json:"phases"`
171+
Warnings []Warning `json:"warnings"`
172+
Error *PipelineError `json:"error"`
173+
BackupPath string `json:"backupPath,omitempty"`
174+
// DurationMs is part of the §6 contract but reserved for now: the pipeline
175+
// does not measure wall time (a real clock would make acceptance goldens
176+
// non-deterministic), so it is always emitted as 0 until timing is wired
177+
// through a clock the tests can control.
178+
DurationMs int64 `json:"durationMs"`
179+
}

libs/dbconnect/result_test.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package dbconnect
2+
3+
import (
4+
"errors"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
)
9+
10+
func TestPipelineErrorWrapsAndExposesCode(t *testing.T) {
11+
base := errors.New("boom")
12+
err := NewError(ErrFetch, base, "fetch %s", "x")
13+
assert.Equal(t, "fetch x: boom", err.Error())
14+
assert.Equal(t, ErrFetch, err.Code)
15+
assert.ErrorIs(t, err, base)
16+
}
17+
18+
func TestModeString(t *testing.T) {
19+
assert.Equal(t, "default", ModeDefault.String())
20+
assert.Equal(t, "constraints-only", ModeConstraintsOnly.String())
21+
}

0 commit comments

Comments
 (0)