Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 141 additions & 6 deletions sonic/script.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"bytes"
"context"
"embed"
"encoding/json"
"fmt"
"net/netip"
"os"
Expand All @@ -15,6 +16,7 @@ import (
"strconv"
"strings"
"text/template"
"time"

"github.com/ironcore-dev/fabric/cellruntime"
"sigs.k8s.io/controller-runtime/pkg/log"
Expand All @@ -23,6 +25,7 @@ import (
const (
ProviderName = "sonic"
doneFlagFile = "/etc/sonic/fabric-cell.done"
stateFile = "/etc/sonic/fabric-cell.state"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: Would it help if we called it fabric-cell-vlan.state since it's only storing the states of the VLANs at the moment?

scriptDir = "/etc/sonic"
)

Expand All @@ -39,12 +42,26 @@ const (
//go:embed templates
var templates embed.FS

type ScriptRuntime struct{}
type ScriptRuntime struct {
// probe functions; swapped by tests
uptime func() (time.Duration, error)
bgpEstablished func(ctx context.Context) (configured, established int, err error)
ifaceUp func(name string) (bool, error)
// path overrides for tests
doneFlagPath string
stateFilePath string
}

var _ cellruntime.Runtime = (*ScriptRuntime)(nil)

func NewScriptRuntime() *ScriptRuntime {
return &ScriptRuntime{}
r := &ScriptRuntime{}
r.uptime = readUptime
r.bgpEstablished = bgpEstablishedFromVtysh
r.ifaceUp = ifaceUpFromSysfs
r.doneFlagPath = doneFlagFile
r.stateFilePath = stateFile
return r
}

func (r *ScriptRuntime) ProviderName() string {
Expand All @@ -60,18 +77,38 @@ func (r *ScriptRuntime) ApplyCell(ctx context.Context, node string, cfg *cellrun
if err != nil {
return err
}
return runScript(ctx, script)
if err := runScript(ctx, script); err != nil {
return err
}
// Collect VLANs from the rendered south ports so CellStatus can check them
// without re-deriving them from the API.
var vlans []string
for _, peer := range cfg.Peers {
if peer.DHCPRelay == "" || peer.Interface == nil {
continue
}
portNum, err := strconv.Atoi(strings.TrimPrefix(peer.Interface.ID, "Ethernet"))
if err != nil {
continue
}
vlans = append(vlans, fmt.Sprintf("Vlan%d", portNum/4+1001))
}
return os.WriteFile(r.stateFilePath, []byte(strings.Join(vlans, "\n")), 0o644)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suspect that if runScript() has already written doneFile and executed reboot, the code might not get a chance to write the stateFile. Is this understanding correct?
In that case, it might be helpful to move the state-file writing block before runScript. wdyt?

}

func (r *ScriptRuntime) DeleteCell(ctx context.Context, node string) error {
if !fileExists(doneFlagFile) {
if !fileExists(r.doneFlagPath) {
return fmt.Errorf("cell %s %w", node, cellruntime.ErrNotFound)
}
script, err := r.renderReset(node)
if err != nil {
return err
}
return runScript(ctx, script)
if err := runScript(ctx, script); err != nil {
return err
}
_ = os.Remove(r.stateFilePath)
return nil
}

type southPort struct {
Expand Down Expand Up @@ -187,9 +224,46 @@ func (r *ScriptRuntime) renderReset(node string) ([]byte, error) {
}

func (r *ScriptRuntime) CellStatus(ctx context.Context, node string) (*cellruntime.CellStatus, error) {
if !fileExists(doneFlagFile) {
if !fileExists(r.doneFlagPath) {
return nil, fmt.Errorf("cell %s %w", node, cellruntime.ErrNotFound)
}

// Check if device has rebooted since the done flag was written.
flagInfo, err := os.Stat(r.doneFlagPath)
if err != nil {
return nil, fmt.Errorf("stat done flag: %w", err)
}
uptime, err := r.uptime()
if err != nil {
return nil, fmt.Errorf("reading uptime: %w", err)
}
bootTime := time.Now().Add(-uptime)
if flagInfo.ModTime().After(bootTime) {
// Flag is newer than boot — device hasn't rebooted yet after provisioning.
return &cellruntime.CellStatus{Phase: cellruntime.CellPhaseCreated}, nil
}

// Check BGP sessions.
configured, established, err := r.bgpEstablished(ctx)
if err != nil {
return nil, fmt.Errorf("checking BGP: %w", err)
}
if configured > 0 && established < configured {
return &cellruntime.CellStatus{Phase: cellruntime.CellPhaseCreated}, nil
}

// Check VLAN interfaces from the state file.
vlans, err := readStateFile(r.stateFilePath)
if err != nil {
return nil, fmt.Errorf("reading state file: %w", err)
}
for _, vlan := range vlans {
up, err := r.ifaceUp(vlan)
if err != nil || !up {
return &cellruntime.CellStatus{Phase: cellruntime.CellPhaseCreated}, nil
}
}

return &cellruntime.CellStatus{Phase: cellruntime.CellPhaseActive}, nil
}

Expand Down Expand Up @@ -232,6 +306,67 @@ func render(name string, data any) ([]byte, error) {
return buf.Bytes(), nil
}

func readUptime() (time.Duration, error) {
data, err := os.ReadFile("/proc/uptime")
if err != nil {
return 0, err
}
fields := strings.Fields(string(data))
if len(fields) == 0 {
return 0, fmt.Errorf("unexpected /proc/uptime format")
}
secs, err := strconv.ParseFloat(fields[0], 64)
if err != nil {
return 0, fmt.Errorf("parsing /proc/uptime: %w", err)
}
return time.Duration(secs * float64(time.Second)), nil
}

func bgpEstablishedFromVtysh(ctx context.Context) (configured, established int, error error) {
out, err := exec.CommandContext(ctx, "vtysh", "-c", "show bgp neighbors json").Output()
if err != nil {
return 0, 0, fmt.Errorf("vtysh: %w", err)
}
var neighbors map[string]struct {
BGPState string `json:"bgpState"`
}
if err := json.Unmarshal(out, &neighbors); err != nil {
return 0, 0, fmt.Errorf("parsing BGP JSON: %w", err)
}
for _, n := range neighbors {
configured++
if n.BGPState == "Established" {
established++
}
}
return configured, established, nil
}

func ifaceUpFromSysfs(name string) (bool, error) {
data, err := os.ReadFile(filepath.Join("/sys/class/net", name, "operstate"))
if err != nil {
return false, err
}
return strings.TrimSpace(string(data)) == "up", nil
}

func readStateFile(path string) ([]string, error) {
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, err
}
var vlans []string
for line := range strings.SplitSeq(strings.TrimSpace(string(data)), "\n") {
if line != "" {
vlans = append(vlans, line)
}
}
return vlans, nil
}

func runScript(ctx context.Context, script []byte) error {
f, err := os.CreateTemp(scriptDir, "fabric-cell-*.sh")
if err != nil {
Expand Down
116 changes: 116 additions & 0 deletions sonic/script_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package sonic

import (
"context"
"errors"
"flag"
"fmt"
Expand All @@ -12,6 +13,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"

"github.com/google/go-cmp/cmp"
"github.com/ironcore-dev/fabric/cellruntime"
Expand Down Expand Up @@ -163,3 +165,117 @@ func TestRenderCellInvalid(t *testing.T) {
})
}
}

// backdateFlag creates the done-flag file with its mtime flagAge in the past.
func backdateFlag(t *testing.T, path string, flagAge time.Duration) {
t.Helper()
if err := os.WriteFile(path, nil, 0o644); err != nil {
t.Fatal(err)
}
mtime := time.Now().Add(-flagAge)
if err := os.Chtimes(path, mtime, mtime); err != nil {
t.Fatal(err)
}
}

func TestCellStatus(t *testing.T) {
ctx := context.Background()
const node = "swi1"
const vlan1, vlan2 = "Vlan1001", "Vlan1002"

for _, tc := range []struct {
name string
flagAge time.Duration // 0 = no flag
bootAge time.Duration
configured int
established int
stateVLANs []string
vlanUp map[string]bool
wantPhase cellruntime.CellPhase
wantErr bool
}{
{
name: "no done flag",
wantErr: true,
},
{
name: "flag present, not yet rebooted",
flagAge: 5 * time.Minute, // flag written 5m ago (after boot)
bootAge: 1 * time.Hour, // device booted 1h ago → flag newer than boot
wantPhase: cellruntime.CellPhaseCreated,
},
{
name: "rebooted, BGP not established",
flagAge: 2 * time.Hour,
bootAge: 1 * time.Hour,
configured: 2,
established: 1,
wantPhase: cellruntime.CellPhaseCreated,
},
{
name: "rebooted, BGP up, no VLANs in state file",
flagAge: 2 * time.Hour,
bootAge: 1 * time.Hour,
configured: 2,
established: 2,
wantPhase: cellruntime.CellPhaseActive,
},
{
name: "rebooted, BGP up, VLAN down",
flagAge: 2 * time.Hour,
bootAge: 1 * time.Hour,
configured: 2,
established: 2,
stateVLANs: []string{vlan1},
vlanUp: map[string]bool{vlan1: false},
wantPhase: cellruntime.CellPhaseCreated,
},
{
name: "all checks pass",
flagAge: 2 * time.Hour,
bootAge: 1 * time.Hour,
configured: 2,
established: 2,
stateVLANs: []string{vlan1, vlan2},
vlanUp: map[string]bool{vlan1: true, vlan2: true},
wantPhase: cellruntime.CellPhaseActive,
},
} {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
r := &ScriptRuntime{
doneFlagPath: filepath.Join(dir, "fabric-cell.done"),
stateFilePath: filepath.Join(dir, "fabric-cell.state"),
uptime: func() (time.Duration, error) { return tc.bootAge, nil },
bgpEstablished: func(context.Context) (int, int, error) {
return tc.configured, tc.established, nil
},
ifaceUp: func(name string) (bool, error) { return tc.vlanUp[name], nil },
}
if tc.flagAge > 0 {
backdateFlag(t, r.doneFlagPath, tc.flagAge)
}
if len(tc.stateVLANs) > 0 {
if err := os.WriteFile(r.stateFilePath, []byte(strings.Join(tc.stateVLANs, "\n")), 0o644); err != nil {
t.Fatal(err)
}
}
status, err := r.CellStatus(ctx, node)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error, got status %v", status)
}
if !errors.Is(err, cellruntime.ErrNotFound) {
t.Errorf("expected ErrNotFound, got %v", err)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if status.Phase != tc.wantPhase {
t.Errorf("phase = %q, want %q", status.Phase, tc.wantPhase)
}
})
}
}
Loading