Skip to content

Commit d7926a7

Browse files
Refactor/iosxe separation of concerns (#75)
* Refactor driver code with clear separation of concerns * Add driver tests --------- Co-authored-by: Richard Cunningham <cunningr@cisco.com>
1 parent 537e4a0 commit d7926a7

File tree

13 files changed

+1607
-653
lines changed

13 files changed

+1607
-653
lines changed

internal/drivers/iosxe/README.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# IOS-XE Driver Package
2+
3+
This package implements the `CiscoKubernetesDeviceDriver` interface for Cisco IOS-XE devices using the AppHosting feature over RESTCONF.
4+
5+
## Design Intent
6+
7+
The code is organised into files with clear separation of concerns, roughly following these layers:
8+
9+
| Layer | File(s) | Responsibility |
10+
|-------|---------|----------------|
11+
| **Constructor** | `driver.go` | `XEDriver` struct, factory constructor, marshallers/unmarshallers |
12+
| **Types** | `types.go` | Shared types (`AppHostingConfig`, `AppPhase`, `networkConfig`, etc.) |
13+
| **Device** | `device.go` | Device connectivity checks and node resource reporting |
14+
| **Transport** | `client.go` | Low-level device operations (install, activate, start, …). Named protocol-agnostically to support future NETCONF transport |
15+
| **Reconciler** | `reconciler.go` | App lifecycle state machine — drives apps toward their desired state |
16+
| **Pod Lifecycle** | `pod_lifecycle.go` | Kubernetes-facing interface methods (`DeployPod`, `DeletePod`, `GetPodStatus`, `ListPods`) |
17+
| **Pod Transforms** | `pod_transforms.go` | Pod.Spec → IOS-XE AppHosting config conversion (network, resources, labels) |
18+
| **Status Transforms** | `status_transforms.go` | App operational data → Pod.Status / ContainerStatus conversion |
19+
| **IP Discovery** | `ip_discovery.go` | Pod IP resolution from app-hosting oper data with ARP table fallback |
20+
| **Models** | `models.go` | YANG/ygot generated structs (do not edit by hand) |
21+
22+
## Data Flow
23+
24+
```
25+
Pod.Spec
26+
27+
28+
pod_transforms.go ──► AppHostingConfig (types.go)
29+
30+
31+
client.go ◄──► IOS-XE Device (RESTCONF)
32+
33+
34+
reconciler.go (state machine: "" → DEPLOYED → ACTIVATED → RUNNING)
35+
36+
37+
ip_discovery.go + status_transforms.go
38+
39+
40+
Pod.Status
41+
```
42+
43+
## Adding a New Transport
44+
45+
`client.go` is intentionally named without a protocol prefix. To add NETCONF support, introduce a transport interface and provide RESTCONF / NETCONF implementations behind it, keeping the method signatures in `client.go` stable.
Lines changed: 0 additions & 201 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ import (
2222
"time"
2323

2424
"github.com/virtual-kubelet/virtual-kubelet/log"
25-
v1 "k8s.io/api/core/v1"
2625
)
2726

2827
// CreateAppHostingApp creates a single IOS-XE AppHosting app from an AppHostingConfig.
@@ -150,206 +149,6 @@ func (d *XEDriver) UninstallApp(ctx context.Context, appID string) error {
150149
return nil
151150
}
152151

153-
// ReconcileApp performs a single reconciliation step, driving the app one state
154-
// closer to its desired state and updating appConfig.Status in place.
155-
//
156-
// Forward (DesiredState = Running):
157-
//
158-
// "" (no config) → POST config + install RPC → Converging
159-
// "" (config, no oper) → re-issue install RPC → Converging
160-
// "DEPLOYED" → activate RPC → Converging
161-
// "ACTIVATED" → start RPC → Converging
162-
// "RUNNING" → no-op → Ready
163-
//
164-
// Reverse (DesiredState = Deleted):
165-
//
166-
// "RUNNING" → stop RPC → Deleting
167-
// "ACTIVATED"/"STOPPED" → deactivate RPC → Deleting
168-
// "DEPLOYED" → uninstall RPC → Deleting
169-
// "" (no oper) → delete config → Deleted
170-
func (d *XEDriver) ReconcileApp(ctx context.Context, appConfig *AppHostingConfig) {
171-
appID := appConfig.AppName()
172-
desired := appConfig.Spec.DesiredState
173-
174-
// 1. Observe current device state.
175-
state := d.getAppState(ctx, appID)
176-
appConfig.Status.ObservedState = state
177-
appConfig.Status.LastTransition = time.Now()
178-
179-
log.G(ctx).Infof("ReconcileApp %s: observed=%q desired=%s phase=%s",
180-
appID, state, desired, appConfig.Status.Phase)
181-
182-
// ── Forward path: drive toward RUNNING ────────────────────────────
183-
if desired == AppDesiredStateRunning {
184-
switch state {
185-
case "RUNNING":
186-
appConfig.Status.Phase = AppPhaseReady
187-
appConfig.Status.Message = "App is running"
188-
return
189-
190-
case "ACTIVATED":
191-
// ACTIVATED → start
192-
appConfig.Status.Phase = AppPhaseConverging
193-
appConfig.Status.Message = "Starting app"
194-
if err := d.StartApp(ctx, appID); err != nil {
195-
log.G(ctx).Warnf("ReconcileApp %s: start failed: %v", appID, err)
196-
appConfig.Status.Phase = AppPhaseError
197-
appConfig.Status.Message = fmt.Sprintf("start failed: %v", err)
198-
}
199-
return
200-
201-
case "DEPLOYED":
202-
// DEPLOYED → activate
203-
appConfig.Status.Phase = AppPhaseConverging
204-
appConfig.Status.Message = "Activating app"
205-
if err := d.ActivateApp(ctx, appID); err != nil {
206-
log.G(ctx).Warnf("ReconcileApp %s: activate failed: %v", appID, err)
207-
appConfig.Status.Phase = AppPhaseError
208-
appConfig.Status.Message = fmt.Sprintf("activate failed: %v", err)
209-
}
210-
return
211-
212-
default:
213-
// No oper data (or unexpected state) — the install likely hasn't
214-
// happened or failed silently. Re-issue install if we have an image.
215-
imagePath := appConfig.ImagePath()
216-
if imagePath == "" {
217-
log.G(ctx).Warnf("ReconcileApp %s: no oper data and no image path; cannot install", appID)
218-
appConfig.Status.Phase = AppPhaseError
219-
appConfig.Status.Message = "no image path available for install"
220-
return
221-
}
222-
appConfig.Status.Phase = AppPhaseConverging
223-
appConfig.Status.Message = "Re-issuing install"
224-
log.G(ctx).Warnf("ReconcileApp %s: no oper data; re-issuing install (image: %s)", appID, imagePath)
225-
if err := d.InstallApp(ctx, appID, imagePath); err != nil {
226-
log.G(ctx).Warnf("ReconcileApp %s: install failed: %v", appID, err)
227-
appConfig.Status.Phase = AppPhaseError
228-
appConfig.Status.Message = fmt.Sprintf("install failed: %v", err)
229-
}
230-
return
231-
}
232-
}
233-
234-
// ── Reverse path: drive toward deletion ───────────────────────────
235-
if desired == AppDesiredStateDeleted {
236-
switch state {
237-
case "RUNNING":
238-
appConfig.Status.Phase = AppPhaseDeleting
239-
appConfig.Status.Message = "Stopping app"
240-
if err := d.StopApp(ctx, appID); err != nil {
241-
log.G(ctx).Warnf("ReconcileApp %s: stop failed: %v", appID, err)
242-
appConfig.Status.Phase = AppPhaseError
243-
appConfig.Status.Message = fmt.Sprintf("stop failed: %v", err)
244-
}
245-
return
246-
247-
case "ACTIVATED", "STOPPED":
248-
appConfig.Status.Phase = AppPhaseDeleting
249-
appConfig.Status.Message = "Deactivating app"
250-
if err := d.DeactivateApp(ctx, appID); err != nil {
251-
log.G(ctx).Warnf("ReconcileApp %s: deactivate failed: %v", appID, err)
252-
appConfig.Status.Phase = AppPhaseError
253-
appConfig.Status.Message = fmt.Sprintf("deactivate failed: %v", err)
254-
}
255-
return
256-
257-
case "DEPLOYED":
258-
appConfig.Status.Phase = AppPhaseDeleting
259-
appConfig.Status.Message = "Uninstalling app"
260-
if err := d.UninstallApp(ctx, appID); err != nil {
261-
log.G(ctx).Warnf("ReconcileApp %s: uninstall failed: %v", appID, err)
262-
appConfig.Status.Phase = AppPhaseError
263-
appConfig.Status.Message = fmt.Sprintf("uninstall failed: %v", err)
264-
}
265-
return
266-
267-
default:
268-
// No operational data — safe to remove config.
269-
appConfig.Status.Phase = AppPhaseDeleting
270-
appConfig.Status.Message = "Removing config"
271-
path := fmt.Sprintf("/restconf/data/Cisco-IOS-XE-app-hosting-cfg:app-hosting-cfg-data/apps/app=%s", appID)
272-
if err := d.client.Delete(ctx, path); err != nil {
273-
log.G(ctx).Warnf("ReconcileApp %s: config delete failed: %v", appID, err)
274-
appConfig.Status.Phase = AppPhaseError
275-
appConfig.Status.Message = fmt.Sprintf("config delete failed: %v", err)
276-
return
277-
}
278-
appConfig.Status.Phase = AppPhaseDeleted
279-
appConfig.Status.Message = "App fully removed"
280-
log.G(ctx).Infof("ReconcileApp %s: fully deleted", appID)
281-
return
282-
}
283-
}
284-
}
285-
286-
// containerImagePath returns the image path for a named container in a pod spec.
287-
func containerImagePath(pod *v1.Pod, containerName string) string {
288-
for i := range pod.Spec.Containers {
289-
if pod.Spec.Containers[i].Name == containerName {
290-
return pod.Spec.Containers[i].Image
291-
}
292-
}
293-
return ""
294-
}
295-
296-
// getAppState returns the current operational state string for appID, or ""
297-
// if the app has no oper data or the state cannot be determined.
298-
func (d *XEDriver) getAppState(ctx context.Context, appID string) string {
299-
if d.client == nil {
300-
return ""
301-
}
302-
allOper, err := d.GetAppOperationalData(ctx)
303-
if err != nil {
304-
log.G(ctx).Warnf("Could not fetch oper data to check state of app %s: %v", appID, err)
305-
return ""
306-
}
307-
operData, ok := allOper[appID]
308-
if !ok || operData == nil || operData.Details == nil || operData.Details.State == nil {
309-
return ""
310-
}
311-
return *operData.Details.State
312-
}
313-
314-
// DeleteApp orchestrates a reconciler-driven teardown of the app lifecycle.
315-
//
316-
// It creates a transient AppHostingConfig with DesiredState=Deleted and
317-
// repeatedly invokes ReconcileApp until the app reaches the Deleted phase
318-
// or a timeout is exceeded.
319-
//
320-
// RUNNING → stop → ACTIVATED → deactivate → DEPLOYED → uninstall → (absent) → config delete
321-
func (d *XEDriver) DeleteApp(ctx context.Context, appID string) error {
322-
appConfig := &AppHostingConfig{
323-
Metadata: AppHostingMetadata{AppName: appID},
324-
Spec: AppHostingSpec{DesiredState: AppDesiredStateDeleted},
325-
Status: AppHostingStatus{Phase: AppPhaseDeleting},
326-
}
327-
328-
const maxAttempts = 15
329-
const reconcileInterval = 4 * time.Second
330-
331-
for attempt := 1; attempt <= maxAttempts; attempt++ {
332-
d.ReconcileApp(ctx, appConfig)
333-
334-
if appConfig.Status.Phase == AppPhaseDeleted {
335-
log.G(ctx).Infof("Successfully deleted app %s after %d reconcile pass(es)", appID, attempt)
336-
return nil
337-
}
338-
339-
log.G(ctx).Debugf("DeleteApp %s: attempt %d/%d, phase=%s observed=%q msg=%s",
340-
appID, attempt, maxAttempts, appConfig.Status.Phase, appConfig.Status.ObservedState, appConfig.Status.Message)
341-
342-
select {
343-
case <-ctx.Done():
344-
return fmt.Errorf("context cancelled while deleting app %s", appID)
345-
case <-time.After(reconcileInterval):
346-
}
347-
}
348-
349-
return fmt.Errorf("app %s not fully deleted after %d reconcile attempts (last phase: %s, observed: %q)",
350-
appID, maxAttempts, appConfig.Status.Phase, appConfig.Status.ObservedState)
351-
}
352-
353152
// WaitForAppStatus polls the device until the app reaches the expected status or times out
354153
func (d *XEDriver) WaitForAppStatus(ctx context.Context, appID string, expectedStatus string, maxWaitTime time.Duration) error {
355154
log.G(ctx).Infof("Waiting for app %s to reach status: %s", appID, expectedStatus)

internal/drivers/iosxe/device.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// Copyright © 2026 Cisco Systems Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package iosxe
16+
17+
import (
18+
"context"
19+
"fmt"
20+
"regexp"
21+
22+
"github.com/cisco/virtual-kubelet-cisco/internal/drivers/common"
23+
"github.com/virtual-kubelet/virtual-kubelet/log"
24+
v1 "k8s.io/api/core/v1"
25+
"k8s.io/apimachinery/pkg/api/resource"
26+
)
27+
28+
// CheckConnection validates connectivity to the device and fetches device info
29+
func (d *XEDriver) CheckConnection(ctx context.Context) error {
30+
res := &common.HostMeta{}
31+
32+
err := d.client.Get(ctx, "/.well-known/host-meta", res, d.gethostMetaUnmarshaller())
33+
if err != nil {
34+
return fmt.Errorf("connectivity check failed: %w", err)
35+
}
36+
37+
log.G(ctx).Debugf("Restconf Root: %s\n", res.Links[0].Href)
38+
39+
d.deviceInfo = d.fetchDeviceInfo(ctx)
40+
return nil
41+
}
42+
43+
func (d *XEDriver) fetchDeviceInfo(ctx context.Context) *common.DeviceInfo {
44+
info := &common.DeviceInfo{}
45+
46+
resp := &Cisco_IOS_XEDeviceHardwareOper_DeviceHardwareData{}
47+
err := d.client.Get(ctx, "/restconf/data/Cisco-IOS-XE-device-hardware-oper:device-hardware-data", resp, d.unmarshaller)
48+
if err != nil {
49+
log.G(ctx).WithError(err).Debug("Failed to fetch device hardware info")
50+
return info
51+
}
52+
53+
// Get software version from device-system-data and extract just the version number
54+
if resp.DeviceHardware != nil && resp.DeviceHardware.DeviceSystemData != nil {
55+
if resp.DeviceHardware.DeviceSystemData.SoftwareVersion != nil {
56+
info.SoftwareVersion = parseVersionNumber(*resp.DeviceHardware.DeviceSystemData.SoftwareVersion)
57+
}
58+
}
59+
60+
// Find the chassis inventory entry for serial and part number
61+
if resp.DeviceHardware != nil && resp.DeviceHardware.DeviceInventory != nil {
62+
for _, inv := range resp.DeviceHardware.DeviceInventory {
63+
if inv.HwType == Cisco_IOS_XEDeviceHardwareOper_HwType_hw_type_chassis && inv.SerialNumber != nil && *inv.SerialNumber != "" {
64+
info.SerialNumber = *inv.SerialNumber
65+
if inv.PartNumber != nil {
66+
info.ProductID = *inv.PartNumber
67+
}
68+
break
69+
}
70+
}
71+
}
72+
73+
if info.SerialNumber != "" {
74+
log.G(ctx).Infof("Device info: Serial=%s, Version=%s, Product=%s",
75+
info.SerialNumber, info.SoftwareVersion, info.ProductID)
76+
}
77+
78+
return info
79+
}
80+
81+
// parseVersionNumber extracts the version number (e.g., "17.18.2") from the full software-version string
82+
func parseVersionNumber(fullVersion string) string {
83+
re := regexp.MustCompile(`Version\s+(\d+\.\d+\.\d+)`)
84+
matches := re.FindStringSubmatch(fullVersion)
85+
if len(matches) > 1 {
86+
return matches[1]
87+
}
88+
return fullVersion
89+
}
90+
91+
// GetDeviceInfo returns cached device information
92+
func (d *XEDriver) GetDeviceInfo(ctx context.Context) (*common.DeviceInfo, error) {
93+
if d.deviceInfo == nil {
94+
return &common.DeviceInfo{}, nil
95+
}
96+
return d.deviceInfo, nil
97+
}
98+
99+
// GetDeviceResources returns the available resources on the device
100+
func (d *XEDriver) GetDeviceResources(ctx context.Context) (*v1.ResourceList, error) {
101+
resources := v1.ResourceList{
102+
v1.ResourceCPU: resource.MustParse("8"),
103+
v1.ResourceMemory: resource.MustParse("16Gi"),
104+
v1.ResourceStorage: resource.MustParse("100Gi"),
105+
v1.ResourcePods: resource.MustParse("16"),
106+
}
107+
108+
return &resources, nil
109+
}

0 commit comments

Comments
 (0)