Skip to content

Commit 0aac214

Browse files
privateipclaude
andcommitted
refactor(cni): dedupe internal/cni vs internal/cnitap into internal/cnimaster
Rebased onto the updated fix/cni-review-followups-doc-placement (which picked up my own rebase-and-reconcile of that branch after PR #307 moved out from under it) after that branch's history moved out from under this one -- same ripple as #306 -> #307 -> #315 -> #316. Conflicts resolved: internal/cni/resource.go and internal/cnitap/ resource.go each had two independent changes touching the same resourceTracker/cleanup() region -- this PR's own extraction of the shared k8s-client-construction (newK8sClient/cniScheme) and interface+ VRF rollback (veth.Delete/tap.Delete + vrf.Delete) into internal/cnimaster's NewK8sClient/CleanupAttachment, and PR #305/#306's unrelated addition of ipamDelegated/ipamType/ipamStdin fields plus an IPAM-release rollback step (this PR's diff predates that feature entirely, same gap #311/#312/#315 each hit one level up). Kept both: call cnimaster.CleanupAttachment for the interface+VRF half (this PR's whole point), and kept the IPAM release step ahead of it, unchanged. internal/cnitap/ops_check.go had one similar import-only conflict (this PR drops the netlink/rest/ctrl/vrf imports cnimaster. CheckNodeLevelState/ProbeAPIServer/RunStatus now cover internally) -- its own IPAM CHECK delegation step (ipam.ExecCheck, same predates-this- PR gap) sat entirely outside the conflicted hunk and needed no resolution beyond keeping the "github.com/containernetworking/plugins/ pkg/ipam" import alive. Verification: task lint, task build (all 8 binaries), task test:unit all pass on the rebased tree, including the new internal/cnimaster package. tests/e2e not run in this sandbox (no Kind cluster / root), same caveat as every PR in this stack. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent d9c08ec commit 0aac214

17 files changed

Lines changed: 1268 additions & 1508 deletions

internal/cni/cni_test.go

Lines changed: 13 additions & 558 deletions
Large diffs are not rendered by default.

internal/cni/config.go

Lines changed: 7 additions & 300 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,8 @@
55
package cni
66

77
import (
8-
"encoding/json"
9-
"errors"
10-
"fmt"
11-
"log/slog"
12-
"os"
13-
"path/filepath"
14-
"strings"
15-
16-
"github.com/containernetworking/cni/pkg/types"
17-
type100 "github.com/containernetworking/cni/pkg/types/100"
18-
8+
"go.datum.net/galactic/internal/cnimaster"
199
"go.datum.net/galactic/internal/config"
20-
"go.datum.net/galactic/internal/hostconf"
2110
)
2211

2312
var ConfFile = config.DefaultConfFile
@@ -33,293 +22,11 @@ func InitCNIConfig() {
3322
cniConfig = config.NewCNIConfig()
3423
}
3524

36-
const sanitizeForErrorBinary = "<binary>"
37-
38-
// errInvalidCNIConfig is the message for CNI config parse errors (code 7).
39-
const errInvalidCNIConfig = "invalid CNI config"
40-
41-
// errVPCRequired and errVPCAttachmentRequired are messages for missing
42-
// identifier fields (code 7).
43-
const (
44-
errVPCRequired = "vpc is required and must be a non-empty base62 string"
45-
errVPCAttachmentRequired = "vpcattachment is required and must be a non-empty base62 string"
46-
)
47-
48-
// isValidBase62 reports whether s contains only valid base62 characters
49-
// ([0-9a-zA-Z]) and is non-empty. VPC and VPCAttachment identifiers are
50-
// base62-encoded and used throughout the ADD path (interface naming,
51-
// BGP CRD population). Rejecting them early in parseConf prevents cryptic
52-
// errors deep in the stack after partial kernel state has been created.
53-
func isValidBase62(s string) bool {
54-
if len(s) == 0 {
55-
return false
56-
}
57-
for _, c := range s {
58-
if (c < '0' || c > '9') && (c < 'a' || c > 'z') && (c < 'A' || c > 'Z') {
59-
return false
60-
}
61-
}
62-
return true
63-
}
64-
65-
// loadHostConf loads node-local settings from the static per-node conflist.
66-
// If the file is missing, it returns a zero-value HostConf (tolerating local
67-
// test runs) but still defaulting Namespace to config.DefaultNamespace.
68-
func loadHostConf(filePath string) (*HostConf, error) {
69-
if filePath == "" {
70-
filePath = config.DefaultConfFile
71-
}
72-
conf, err := hostconf.Load(filePath, hostconf.PluginType)
73-
if err != nil {
74-
if os.IsNotExist(unwrapPathError(err)) {
75-
return &HostConf{Namespace: config.DefaultNamespace}, nil
76-
}
77-
return nil, err
78-
}
79-
if conf.Namespace == "" {
80-
conf.Namespace = config.DefaultNamespace
81-
}
82-
return conf, nil
83-
}
84-
85-
// unwrapPathError returns the innermost *os.PathError-shaped error wrapped
86-
// by err, if any, so os.IsNotExist (which does not itself traverse %w
87-
// wrapping) can still recognize a missing conflist file wrapped by
88-
// hostconf.Load's fmt.Errorf("read conflist file %q: %w", ...).
89-
func unwrapPathError(err error) error {
90-
for {
91-
unwrapped := errors.Unwrap(err)
92-
if unwrapped == nil {
93-
return err
94-
}
95-
err = unwrapped
96-
}
97-
}
98-
99-
// parseLogLevel maps a config-supplied level name to a slog.Level. Matching is
100-
// case-insensitive. An empty string resolves to config.DefaultLogLevel.
101-
// Unrecognized values return an error alongside the info-level fallback, so
102-
// callers can warn without failing the CNI operation over a typo'd setting.
103-
func parseLogLevel(s string) (slog.Level, error) {
104-
switch strings.ToLower(strings.TrimSpace(s)) {
105-
case "":
106-
return parseLogLevel(config.DefaultLogLevel)
107-
case config.LogLevelDebug:
108-
return slog.LevelDebug, nil
109-
case config.DefaultLogLevel:
110-
return slog.LevelInfo, nil
111-
case config.LogLevelWarn, config.LogLevelWarning:
112-
return slog.LevelWarn, nil
113-
case config.LogLevelError:
114-
return slog.LevelError, nil
115-
default:
116-
return slog.LevelInfo, fmt.Errorf("unknown log level %q (want %s, %s, %s, or %s)",
117-
s, config.LogLevelDebug, config.DefaultLogLevel, config.LogLevelWarn, config.LogLevelError)
118-
}
119-
}
120-
121-
// setupLogging configures the slog default logger to write to the specified
122-
// path at the specified verbosity. If opening the file fails, it logs a
123-
// warning to os.Stderr and falls back. An unrecognized logLevel also logs a
124-
// warning and falls back to config.DefaultLogLevel rather than failing the
125-
// operation.
126-
func setupLogging(logPath, logLevel string) {
127-
if logPath == "" {
128-
logPath = config.DefaultLogFile
129-
}
130-
level, err := parseLogLevel(logLevel)
131-
if err != nil {
132-
slog.Warn("Invalid log level, falling back to default",
133-
"value", logLevel, "default", config.DefaultLogLevel, "err", err)
134-
}
135-
// Ensure parent directory exists.
136-
if err := os.MkdirAll(filepath.Dir(logPath), 0755); err != nil {
137-
slog.Warn("Failed to create log directory", "path", filepath.Dir(logPath), "err", err)
138-
return
139-
}
140-
file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
141-
if err != nil {
142-
slog.Warn("Failed to open log file, falling back to Stderr", "path", logPath, "err", err)
143-
return
144-
}
145-
// Use JSON handler for structured logging to file.
146-
handler := slog.NewJSONHandler(file, &slog.HandlerOptions{Level: level})
147-
slog.SetDefault(slog.New(handler))
148-
}
149-
150-
// statusConf holds the minimal CNI config fields needed for STATUS validation.
151-
//
152-
// STATUS only checks that the config is parseable and the API server is
153-
// reachable; it does not validate attachment-specific fields (VPC,
154-
// VPCAttachment) because STATUS must succeed before any ADD has ever run.
155-
type statusConf struct {
156-
CNIVersion string `json:"cniVersion"`
157-
Type string `json:"type"`
158-
}
159-
160-
// parseStatusConf validates that the CNI config is parseable and contains the
161-
// required top-level fields (cniVersion, type). Unlike parseConf, it does not
162-
// validate VPC or VPCAttachment because STATUS must succeed on a freshly
163-
// started node before any ADD has run.
164-
func parseStatusConf(data []byte) error {
165-
var sc statusConf
166-
if err := json.Unmarshal(data, &sc); err != nil {
167-
return &types.Error{Code: 7, Msg: errInvalidCNIConfig, Details: err.Error()}
168-
}
169-
if sc.CNIVersion == "" {
170-
return &types.Error{Code: 7, Msg: "cniVersion is required"}
171-
}
172-
if sc.Type == "" {
173-
return &types.Error{Code: 7, Msg: "type is required"}
174-
}
175-
return nil
176-
}
177-
178-
// validatePrevResult checks that the prevResult (from a preceding plugin in
179-
// the CNI chain) is a valid, parseable CNI result. Returns an error if the
180-
// result is non-nil but cannot be parsed as a versioned CNI result, ensuring
181-
// galactic-cni fails fast rather than silently operating on garbage state.
182-
func validatePrevResult(res types.Result) error {
183-
if res == nil {
184-
return nil
185-
}
186-
// Marshal to JSON and re-parse to verify the result is structurally valid.
187-
// This catches malformed results that survived CNI framework unmarshaling.
188-
jsonBytes, err := json.Marshal(res)
189-
if err != nil {
190-
return fmt.Errorf("marshal prevResult: %w", err)
191-
}
192-
if _, err := type100.NewResult(jsonBytes); err != nil {
193-
return fmt.Errorf("parse prevResult: %w", err)
194-
}
195-
return nil
196-
}
197-
198-
// validatePrevResultAdd performs content-level validation of prevResult during
199-
// the ADD operation. It ensures the preceding plugin produced a result with at
200-
// least one interface or IP assignment, which is the minimum expected structure
201-
// for any meaningful CNI chain. Returns nil when prevResult is nil (no
202-
// preceding plugin) or structurally valid with expected content.
203-
func validatePrevResultAdd(res types.Result) error {
204-
if res == nil {
205-
return nil
206-
}
207-
jsonBytes, err := json.Marshal(res)
208-
if err != nil {
209-
return fmt.Errorf("marshal prevResult: %w", err)
210-
}
211-
result, err := type100.NewResult(jsonBytes)
212-
if err != nil {
213-
return fmt.Errorf("parse prevResult: %w", err)
214-
}
215-
versioned, err := type100.GetResult(result)
216-
if err != nil {
217-
return fmt.Errorf("get prevResult version: %w", err)
218-
}
219-
// A valid prevResult must declare at least one interface or IP assignment.
220-
if len(versioned.Interfaces) == 0 && len(versioned.IPs) == 0 {
221-
return errors.New("prevResult declares no interfaces or IP assignments")
222-
}
223-
return nil
224-
}
225-
226-
// parseConf unmarshals the CNI configuration from stdin data and validates
227-
// the base62-encoded identifier fields. It resolves the host configuration
228-
// and sets up process environment variables and logging.
25+
// parseConf unmarshals the CNI configuration from stdin data, validates the
26+
// base62-encoded identifier fields, and resolves logging. The actual logic
27+
// is shared with galactic-tap-cni — see internal/cnimaster.ParseConf — since
28+
// none of it is veth-specific; this is a thin wrapper binding it to this
29+
// binary's own cniConfig/ConfFile.
22930
func parseConf(data []byte) (*PluginConf, error) {
230-
conf := &PluginConf{}
231-
if err := json.Unmarshal(data, &conf); err != nil {
232-
return nil, &types.Error{Code: 7, Msg: errInvalidCNIConfig, Details: err.Error()}
233-
}
234-
if !isValidBase62(conf.VPC) {
235-
if len(conf.VPC) == 0 {
236-
return nil, &types.Error{Code: 7, Msg: errVPCRequired}
237-
}
238-
return nil, &types.Error{
239-
Code: 7,
240-
Msg: fmt.Sprintf("invalid base62 value for field 'vpc': %q", sanitizeForError(conf.VPC)),
241-
}
242-
}
243-
if !isValidBase62(conf.VPCAttachment) {
244-
if len(conf.VPCAttachment) == 0 {
245-
return nil, &types.Error{Code: 7, Msg: errVPCAttachmentRequired}
246-
}
247-
return nil, &types.Error{
248-
Code: 7,
249-
Msg: fmt.Sprintf("invalid base62 value for field 'vpcattachment': %q", sanitizeForError(conf.VPCAttachment)),
250-
}
251-
}
252-
253-
// Load host CNI config
254-
hostConf, err := loadHostConf(ConfFile)
255-
if err != nil {
256-
return nil, fmt.Errorf("load host CNI config: %w", err)
257-
}
258-
259-
// Resolve config: env var > conflist > default.
260-
cniConfig.Resolve(&config.ConflistValues{
261-
NodeName: hostConf.NodeName,
262-
Kubeconfig: hostConf.Kubeconfig,
263-
Namespace: hostConf.Namespace,
264-
LogFile: hostConf.LogFile,
265-
LogLevel: hostConf.LogLevel,
266-
})
267-
268-
// NodeName fallback: auto-detect from the Kubernetes API by matching local
269-
// interface addresses against node InternalIPs. This handles cases where
270-
// the conflist file is missing (e.g. hostPath mount issues in container-
271-
// based environments like Kind).
272-
if cniConfig.NodeName == "" {
273-
detected, detectErr := hostconf.DetectNodeNameFromAPI()
274-
if detectErr != nil {
275-
slog.Warn("Node name auto-detection failed", "err", detectErr)
276-
}
277-
cniConfig.NodeName = detected
278-
}
279-
if cniConfig.NodeName == "" {
280-
return nil, &types.Error{Code: 4, Msg: "node name is required (or set GALACTIC_CNI_NODE_NAME)"}
281-
}
282-
_ = os.Setenv("NODE_NAME", cniConfig.NodeName)
283-
284-
// Propagate Kubeconfig
285-
_ = os.Setenv("KUBECONFIG", cniConfig.Kubeconfig)
286-
287-
// Resolve and propagate Namespace fallback
288-
namespace := conf.Namespace
289-
if namespace == "" {
290-
namespace = cniConfig.Namespace
291-
}
292-
conf.Namespace = namespace
293-
294-
// Setup Logging
295-
setupLogging(cniConfig.LogFile, cniConfig.LogLevel)
296-
slog.Debug("CNI config received", "stdin", string(data))
297-
298-
// Whether IPAM runs at all is decided entirely by whether "ipam" is
299-
// present — no environment variable or sibling field can trigger or
300-
// suppress that. Addressing fields (ipv6_subnet, ipv4_subnet,
301-
// address_families, static_ip) and their own default-filling/CIDR
302-
// validation live inside internal/cniipam, since they're only ever
303-
// read by whichever binary "ipam.type" names — this plugin passes its
304-
// own StdinData straight through unmodified when it delegates
305-
// (ops_add.go/ops_del.go), so validating them here too would just be
306-
// redundant work on the same bytes.
307-
308-
if conf.PrevResult != nil {
309-
if err := validatePrevResult(conf.PrevResult); err != nil {
310-
return nil, &types.Error{Code: 6, Msg: fmt.Sprintf("invalid prevResult: %v", err)}
311-
}
312-
}
313-
return conf, nil
314-
}
315-
316-
// sanitizeForError returns s unchanged if it contains only printable ASCII
317-
// characters; otherwise returns "<binary>" to avoid corrupting log output.
318-
func sanitizeForError(s string) string {
319-
for _, c := range s {
320-
if c < 0x20 || c > 0x7e {
321-
return sanitizeForErrorBinary
322-
}
323-
}
324-
return s
31+
return cnimaster.ParseConf(data, cniConfig, ConfFile)
32532
}

internal/cni/ops_add.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"github.com/vishvananda/netlink"
1616

1717
"go.datum.net/galactic/internal/cni/veth"
18+
"go.datum.net/galactic/internal/cnimaster"
1819
"go.datum.net/galactic/internal/nadpatch"
1920
"go.datum.net/galactic/internal/plumbing/intf"
2021
"go.datum.net/galactic/internal/plumbing/vrf"
@@ -38,7 +39,7 @@ func cmdAdd(args *skel.CmdArgs) (err error) {
3839
// assignment. A nil or structurally broken prevResult indicates a mis-
3940
// configured chain that galactic-cni should not silently ignore.
4041
if pluginConf.PrevResult != nil {
41-
if err := validatePrevResultAdd(pluginConf.PrevResult); err != nil {
42+
if err := cnimaster.ValidatePrevResultAdd(pluginConf.PrevResult); err != nil {
4243
return &types.Error{Code: 6, Msg: fmt.Sprintf("prevResult validation in ADD: %v", err)}
4344
}
4445
}
@@ -102,7 +103,7 @@ func cmdAdd(args *skel.CmdArgs) (err error) {
102103
// Annotate the NAD with the host interface name. The NAD must already
103104
// exist (created by the external VPC operator); a missing or otherwise
104105
// unpatchable NAD is a hard failure.
105-
k8sClient, err := newK8sClient()
106+
k8sClient, err := cnimaster.NewK8sClient()
106107
if err != nil {
107108
return fmt.Errorf("create k8s client: %w", err)
108109
}

0 commit comments

Comments
 (0)