From 0d981b44bb8769d2e46d82b6cca72d20df128f6b Mon Sep 17 00:00:00 2001 From: Minmin Lin Date: Fri, 3 Apr 2026 10:41:09 +0800 Subject: [PATCH] Add TFTP provisioning support --- .../controller-manager-tftp-service.yaml | 21 ++ .../templates/manager/manager.yaml | 6 + charts/network-operator/values.yaml | 12 +- cmd/main.go | 36 +++- config/default/kustomization.yaml | 7 + config/default/manager_tftp_patch.yaml | 7 + config/default/tftp_service.yaml | 19 ++ go.mod | 7 +- go.sum | 14 ++ internal/controller/core/device_controller.go | 12 ++ .../controller/core/device_controller_test.go | 32 +++ internal/tftp/server.go | 197 ++++++++++++++++++ internal/tftp/server_test.go | 161 ++++++++++++++ 13 files changed, 514 insertions(+), 17 deletions(-) create mode 100644 charts/network-operator/templates/extras/controller-manager-tftp-service.yaml create mode 100644 config/default/manager_tftp_patch.yaml create mode 100644 config/default/tftp_service.yaml create mode 100644 internal/tftp/server.go create mode 100644 internal/tftp/server_test.go diff --git a/charts/network-operator/templates/extras/controller-manager-tftp-service.yaml b/charts/network-operator/templates/extras/controller-manager-tftp-service.yaml new file mode 100644 index 00000000..1ad42a10 --- /dev/null +++ b/charts/network-operator/templates/extras/controller-manager-tftp-service.yaml @@ -0,0 +1,21 @@ +apiVersion: v1 +kind: Service +metadata: + labels: + app.kubernetes.io/managed-by: {{ .Release.Service }} + app.kubernetes.io/name: {{ include "network-operator.name" . }} + helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} + app.kubernetes.io/instance: {{ .Release.Name }} + control-plane: controller-manager + name: {{ include "network-operator.resourceName" (dict "suffix" "controller-manager-tftp-service" "context" $) }} + namespace: {{ .Release.Namespace }} +spec: + ports: + - name: tftp + port: 1069 + protocol: UDP + targetPort: 1069 + selector: + app.kubernetes.io/name: {{ include "network-operator.name" . }} + control-plane: controller-manager + type: ClusterIP diff --git a/charts/network-operator/templates/manager/manager.yaml b/charts/network-operator/templates/manager/manager.yaml index d062713f..91857c5a 100644 --- a/charts/network-operator/templates/manager/manager.yaml +++ b/charts/network-operator/templates/manager/manager.yaml @@ -35,6 +35,9 @@ spec: {{- with .Values.manager.nodeSelector }} nodeSelector: {{ toYaml . | nindent 10 }} {{- end }} + {{- with .Values.manager.imagePullSecrets }} + imagePullSecrets: {{ toYaml . | nindent 8 }} + {{- end }} containers: - args: {{- if .Values.metrics.enable }} @@ -54,6 +57,9 @@ spec: - /manager image: "{{ .Values.manager.image.repository }}:{{ .Values.manager.image.tag }}" imagePullPolicy: {{ .Values.manager.image.pullPolicy }} + {{- with .Values.manager.env }} + env: {{ toYaml . | nindent 10 }} + {{- end }} livenessProbe: httpGet: path: /healthz diff --git a/charts/network-operator/values.yaml b/charts/network-operator/values.yaml index 845208f0..151bae24 100644 --- a/charts/network-operator/values.yaml +++ b/charts/network-operator/values.yaml @@ -34,24 +34,24 @@ manager: podSecurityContext: runAsNonRoot: true seccompProfile: - type: RuntimeDefault + type: RuntimeDefault ## Container-level security settings ## securityContext: allowPrivilegeEscalation: false capabilities: - drop: - - ALL + drop: + - ALL ## Resource limits and requests ## resources: limits: - memory: 512Mi + memory: 512Mi requests: - cpu: 150m - memory: 256Mi + cpu: 150m + memory: 256Mi ## Manager pod's affinity ## diff --git a/cmd/main.go b/cmd/main.go index 8109fc48..e6ec7009 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -17,9 +17,8 @@ import ( _ "k8s.io/client-go/plugin/pkg/client/auth" // Set runtime concurrency to match CPU limit imposed by Kubernetes - _ "go.uber.org/automaxprocs" - "github.com/sapcc/go-api-declarations/bininfo" + _ "go.uber.org/automaxprocs" "go.uber.org/zap/zapcore" coordinationv1 "k8s.io/api/coordination/v1" "k8s.io/apimachinery/pkg/runtime" @@ -36,11 +35,6 @@ import ( metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" "sigs.k8s.io/controller-runtime/pkg/webhook" - // Import all supported provider implementations. - _ "github.com/ironcore-dev/network-operator/internal/provider/cisco/iosxr" - _ "github.com/ironcore-dev/network-operator/internal/provider/cisco/nxos" - _ "github.com/ironcore-dev/network-operator/internal/provider/openconfig" - nxv1alpha1 "github.com/ironcore-dev/network-operator/api/cisco/nx/v1alpha1" "github.com/ironcore-dev/network-operator/api/core/v1alpha1" nxcontroller "github.com/ironcore-dev/network-operator/internal/controller/cisco/nx" @@ -48,8 +42,14 @@ import ( "github.com/ironcore-dev/network-operator/internal/provider" "github.com/ironcore-dev/network-operator/internal/provisioning" "github.com/ironcore-dev/network-operator/internal/resourcelock" + tftpserver "github.com/ironcore-dev/network-operator/internal/tftp" webhooknxv1alpha1 "github.com/ironcore-dev/network-operator/internal/webhook/cisco/nx/v1alpha1" webhookv1alpha1 "github.com/ironcore-dev/network-operator/internal/webhook/core/v1alpha1" + + // Import all supported provider implementations. + _ "github.com/ironcore-dev/network-operator/internal/provider/cisco/iosxr" + _ "github.com/ironcore-dev/network-operator/internal/provider/cisco/nxos" + _ "github.com/ironcore-dev/network-operator/internal/provider/openconfig" // +kubebuilder:scaffold:imports ) @@ -81,6 +81,8 @@ func main() { var watchFilterValue string var providerName string var requeueInterval time.Duration + var tftpPort int + var tftpValidateSourceIP bool var maxConcurrentReconciles int var lockerNamespace string var lockerDuration time.Duration @@ -102,12 +104,14 @@ func main() { flag.StringVar(&watchFilterValue, "watch-filter", "", fmt.Sprintf("Label value that the controller watches to reconcile api objects. Label key is always %q. If unspecified, the controller watches for all api objects.", v1alpha1.WatchLabel)) flag.StringVar(&providerName, "provider", "openconfig", "The provider to use for the controller. If not specified, the default provider is used. Available providers: "+strings.Join(provider.Providers(), ", ")) flag.DurationVar(&requeueInterval, "requeue-interval", time.Hour, "The interval after which Kubernetes resources should be reconciled again regardless of whether they have changed.") + flag.IntVar(&tftpPort, "tftp-port", 1069, "The port on which the inline TFTP server listens. Set to 0 to disable the TFTP server.") + flag.BoolVar(&tftpValidateSourceIP, "tftp-validate-source-ip", false, "If set, the TFTP server validates the source IP and requested serial-based filename against the same Device.") flag.IntVar(&maxConcurrentReconciles, "max-concurrent-reconciles", 1, "The maximum number of concurrent reconciles per controller. Defaults to 1.") flag.StringVar(&lockerNamespace, "locker-namespace", "", "The namespace to use for resource locker coordination. If not specified, uses the namespace the manager is deployed in, or 'default' if undetectable.") flag.DurationVar(&lockerDuration, "locker-duration", 5*time.Second, "The duration of the resource locker lease.") flag.DurationVar(&lockerRenewInterval, "locker-renew-interval", time.Second, "The interval at which the resource locker lease is renewed.") flag.IntVar(&provisioningHTTPPort, "provisioning-http-port", 8080, "The port on which the provisioning HTTP server listens.") - flag.BoolVar(&provisioningHTTPValidateSourceIP, "provisioning-http-validate-source-ip", false, "If set, the provisioning HTTP server will validate the source IP of incoming requests against the DeviceIPLabel of Device resources.") + flag.BoolVar(&provisioningHTTPValidateSourceIP, "provisioning-http-validate-source-ip", false, "If set, the provisioning HTTP server will validate the source IP of incoming requests against Device.spec.endpoint.address.") opts := zap.Options{ Development: true, TimeEncoder: zapcore.ISO8601TimeEncoder, @@ -672,6 +676,22 @@ func main() { } } + // Start inline TFTP server when the configured port is non-zero. + if tftpPort != 0 { + tftpAddr := fmt.Sprintf(":%d", tftpPort) + srv, err := tftpserver.New(ctx, tftpAddr, tftpValidateSourceIP, mgr, klog.NewKlogr().WithName("tftp")) + if err != nil { + setupLog.Error(err, "unable to initialize TFTP server") + os.Exit(1) + } + + setupLog.Info("Adding inline TFTP server to manager", "address", tftpAddr, "validateSourceIP", tftpValidateSourceIP) + if err := mgr.Add(srv); err != nil { + setupLog.Error(err, "unable to add TFTP server to manager") + os.Exit(1) + } + } + // +kubebuilder:scaffold:builder if metricsCertWatcher != nil { diff --git a/config/default/kustomization.yaml b/config/default/kustomization.yaml index e0b4d529..fd3c7529 100644 --- a/config/default/kustomization.yaml +++ b/config/default/kustomization.yaml @@ -29,6 +29,8 @@ resources: - metrics_service.yaml # [PROVISIONING] Expose the controller manager provisioning service. - provisioning_service.yaml +# [TFTP] Expose the controller manager TFTP service. +- tftp_service.yaml # [NETWORK POLICY] Protect the /metrics endpoint and Webhook Server with NetworkPolicy. # Only Pod(s) running a namespace labeled with 'metrics: enabled' will be able to gather the metrics. # Only CR(s) which requires webhooks and are applied on namespaces labeled with 'webhooks: enabled' will @@ -61,6 +63,11 @@ patches: target: kind: Deployment +# [TFTP] The following patch will add the TFTP port to the manager container. +- path: manager_tftp_patch.yaml + target: + kind: Deployment + # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER' prefix. # Uncomment the following replacements to add the cert-manager CA injection annotations replacements: diff --git a/config/default/manager_tftp_patch.yaml b/config/default/manager_tftp_patch.yaml new file mode 100644 index 00000000..f745ae4b --- /dev/null +++ b/config/default/manager_tftp_patch.yaml @@ -0,0 +1,7 @@ +# This patch adds the container port for the TFTP service. +- op: add + path: /spec/template/spec/containers/0/ports/- + value: + containerPort: 1069 + name: tftp + protocol: UDP diff --git a/config/default/tftp_service.yaml b/config/default/tftp_service.yaml new file mode 100644 index 00000000..6e058080 --- /dev/null +++ b/config/default/tftp_service.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: Service +metadata: + labels: + control-plane: controller-manager + app.kubernetes.io/name: network-operator + app.kubernetes.io/managed-by: kustomize + name: controller-manager-tftp-service + namespace: system +spec: + ports: + - name: tftp + port: 1069 + protocol: UDP + targetPort: 1069 + selector: + control-plane: controller-manager + app.kubernetes.io/name: network-operator + type: ClusterIP diff --git a/go.mod b/go.mod index fb8aa548..1a4b3011 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/openconfig/goyang v1.6.3 github.com/openconfig/ygnmi v0.14.0 github.com/openconfig/ygot v0.34.0 + github.com/pin/tftp/v3 v3.1.0 github.com/sapcc/go-api-declarations v1.21.0 github.com/stretchr/testify v1.11.1 github.com/tidwall/gjson v1.18.0 @@ -70,7 +71,7 @@ require ( github.com/google/gnostic-models v0.7.1 // indirect github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/kylelemons/godebug v1.1.0 // indirect @@ -113,8 +114,8 @@ require ( golang.org/x/text v0.35.0 // indirect golang.org/x/time v0.14.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index de5b8841..d4f8feb5 100644 --- a/go.sum +++ b/go.sum @@ -109,6 +109,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4 h1:kEISI/Gx67NzH3nJxAmY/dGac80kKZgZt134u7Y/k1s= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4/go.mod h1:6Nz966r3vQYCqIzWsuEl9d7cf7mRhtDmm++sOxlnfxI= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= @@ -153,6 +155,8 @@ github.com/openconfig/ygnmi v0.14.0 h1:WXkZU8NcMVJzZBn1mDm7GMdJ+WyU2ab/4ls1kMKPN github.com/openconfig/ygnmi v0.14.0/go.mod h1:c5ThNBAhrg5o3ZP5V9xagjlJZaKLPFMcJ2Mc3mcNJ8g= github.com/openconfig/ygot v0.34.0 h1:9OkVjy3SGi4mbvAZc4HTQBU9u4MT6k4j5DdX+hgRiC4= github.com/openconfig/ygot v0.34.0/go.mod h1:eMNQHrJpanet+pQoBw/P3ua4sLY/tRTXyJ7ALkWCvl4= +github.com/pin/tftp/v3 v3.1.0 h1:rQaxd4pGwcAJnpId8zC+O2NX3B2/NscjDZQaqEjuE7c= +github.com/pin/tftp/v3 v3.1.0/go.mod h1:xwQaN4viYL019tM4i8iecm++5cGxSqen6AJEOEyEI0w= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -210,6 +214,8 @@ go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 h1:in9O8ESIOlwJAEGTkkf34DesGRAc/Pn8qJ7k3r/42LM= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0/go.mod h1:Rp0EXBm5tfnv0WL+ARyO/PHBEaEAT8UUHQ6AGJcSq6c= go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= @@ -234,22 +240,26 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0= golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= @@ -262,8 +272,12 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b h1:uA40e2M6fYRBf0+8uN5mLlqUtV192iiksiICIBkYJ1E= google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:Xa7le7qx2vmqB/SzWUBa7KdMjpdpAHlh5QCSnjessQk= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU= google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/internal/controller/core/device_controller.go b/internal/controller/core/device_controller.go index b875c494..07f04d4b 100644 --- a/internal/controller/core/device_controller.go +++ b/internal/controller/core/device_controller.go @@ -319,6 +319,18 @@ func (r *DeviceReconciler) reconcile(ctx context.Context, device *v1alpha1.Devic Message: "Device is healthy", }) + log := ctrl.LoggerFrom(ctx) + if device.Labels == nil { + device.Labels = map[string]string{} + } + if serial := strings.ToLower(device.Status.SerialNumber); serial != "" { + if device.Labels[v1alpha1.DeviceSerialLabel] == "" { + device.Labels[v1alpha1.DeviceSerialLabel] = serial + } else if device.Labels[v1alpha1.DeviceSerialLabel] != serial { + log.Info("Device serial label does not match observed device serial number", "labelSerial", device.Labels[v1alpha1.DeviceSerialLabel], "observedSerial", serial) + } + } + return nil } diff --git a/internal/controller/core/device_controller_test.go b/internal/controller/core/device_controller_test.go index ce28009d..e1afc1e2 100644 --- a/internal/controller/core/device_controller_test.go +++ b/internal/controller/core/device_controller_test.go @@ -120,6 +120,7 @@ var _ = Describe("Device Controller", func() { g.Expect(resource.Status.Manufacturer).To(Equal("Manufacturer")) g.Expect(resource.Status.Model).To(Equal("Model")) g.Expect(resource.Status.SerialNumber).To(Equal("123456789")) + g.Expect(resource.Labels).To(HaveKeyWithValue(v1alpha1.DeviceSerialLabel, "123456789")) g.Expect(resource.Status.FirmwareVersion).To(Equal("1.0.0")) g.Expect(resource.Status.LastRebootTime.Time).To(BeTemporally("==", lastRebootTime)) @@ -182,6 +183,37 @@ var _ = Describe("Device Controller", func() { }).Should(Succeed()) }) + It("Should keep an existing mismatched serial label", func() { + By("Creating the custom resource for the Kind Device with a pre-set serial label") + device := &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: metav1.NamespaceDefault, + Labels: map[string]string{ + v1alpha1.DeviceSerialLabel: "manual-serial", + }, + }, + Spec: v1alpha1.DeviceSpec{ + Endpoint: v1alpha1.Endpoint{ + Address: "192.168.10.2:9339", + SecretRef: &v1alpha1.SecretReference{ + Name: name, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, device)).To(Succeed()) + + By("Verifying the observed serial number is recorded without overwriting the existing label") + Eventually(func(g Gomega) { + resource := &v1alpha1.Device{} + g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed()) + g.Expect(resource.Status.Phase).To(Equal(v1alpha1.DevicePhaseRunning)) + g.Expect(resource.Status.SerialNumber).To(Equal("123456789")) + g.Expect(resource.Labels).To(HaveKeyWithValue(v1alpha1.DeviceSerialLabel, "manual-serial")) + }).Should(Succeed()) + }) + It("Should transition from ProvisioningCompleted to Running", func() { By("Creating a Device") device := &v1alpha1.Device{ diff --git a/internal/tftp/server.go b/internal/tftp/server.go new file mode 100644 index 00000000..ab8e1be1 --- /dev/null +++ b/internal/tftp/server.go @@ -0,0 +1,197 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package tftpserver + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "strings" + + tftp "github.com/pin/tftp/v3" + "k8s.io/klog/v2" + "sigs.k8s.io/controller-runtime/pkg/client" + + corev1 "github.com/ironcore-dev/network-operator/api/core/v1alpha1" + "github.com/ironcore-dev/network-operator/internal/clientutil" +) + +// Server represents the inline TFTP instance +type Server struct { + addr string + verify bool + reader client.Reader + logger klog.Logger +} + +// deviceEndpointIPField is the cache field index key used to look up Device objects by +// the IP part of spec.endpoint.address. +const deviceEndpointIPField = "device.endpoint.ip" + +type manager interface { + GetClient() client.Client + GetFieldIndexer() client.FieldIndexer +} + +func New(ctx context.Context, addr string, verify bool, mgr manager, logger klog.Logger) (*Server, error) { + if err := mgr.GetFieldIndexer().IndexField(ctx, &corev1.Device{}, deviceEndpointIPField, func(obj client.Object) []string { + device := obj.(*corev1.Device) + ip := endpointIP(device.Spec.Endpoint.Address) + if ip == "" { + return nil + } + return []string{ip} + }); err != nil { + return nil, err + } + + return &Server{addr: addr, verify: verify, reader: mgr.GetClient(), logger: logger}, nil +} + +// Start runs the TFTP server until ctx is cancelled. +func (s *Server) Start(ctx context.Context) error { + readHandler := func(filename string, rf io.ReaderFrom) error { + srcIP := "" + if xfer, ok := rf.(tftp.OutgoingTransfer); ok { + ra := xfer.RemoteAddr() + srcIP = ra.IP.String() + } + if srcIP == "" { + s.logger.Info("rejecting TFTP request: missing client IP") + return errors.New("missing client ip") + } + + reqName := strings.Trim(strings.ToLower(strings.TrimSpace(filename)), "/") + serial := parseSerial(reqName) + + var device *corev1.Device + if s.verify { + var err error + if serial != "" { + device, err = s.lookupBySerial(ctx, serial) + if err != nil { + return err + } + if device == nil { + s.logger.Info("rejecting TFTP request: unknown serial", "serial", serial, "clientIP", srcIP) + return errors.New("unknown serial") + } + } else { + device, err = s.lookupByIP(ctx, srcIP) + if err != nil { + return err + } + if device == nil { + s.logger.Info("rejecting TFTP request: no device found for client IP", "clientIP", srcIP) + return errors.New("unknown ip") + } + } + deviceIP := endpointIP(device.Spec.Endpoint.Address) + if deviceIP != "" && deviceIP != srcIP { + s.logger.Info("rejecting TFTP request: client IP does not match device endpoint", "serial", device.Status.SerialNumber, "deviceIP", deviceIP, "clientIP", srcIP) + return errors.New("ip mismatch") + } + + if serial != "" { + deviceSerial := strings.ToLower(strings.TrimSpace(device.Status.SerialNumber)) + if serial != deviceSerial { + s.logger.Info("rejecting TFTP request: parsed serial does not match device serial", "parsedSerial", serial, "deviceSerial", deviceSerial, "clientIP", srcIP) + return errors.New("serial mismatch") + } + } + } else { + d, err := s.lookupByIP(ctx, srcIP) + if err != nil { + return err + } + device = d + } + + if device == nil { + return errors.New("no device found") + } + + bootScript := resolveBootScript(ctx, s.reader, device.Namespace, device.Spec.Provisioning) + if len(bootScript) == 0 { + return errors.New("empty bootscript") + } + + if xfer, ok := rf.(tftp.OutgoingTransfer); ok { + xfer.SetSize(int64(len(bootScript))) + } + + n, err := rf.ReadFrom(bytes.NewReader(bootScript)) + if err != nil { + s.logger.Error(err, "failed to send TFTP payload", "clientIP", srcIP) + return err + } + s.logger.Info("TFTP payload delivered", "bytes", n, "clientIP", srcIP, "serial", device.Status.SerialNumber, "requestedFilename", filename) + return nil + } + + writeHandler := func(filename string, wt io.WriterTo) error { + return errors.New("write forbidden") + } + + srv := tftp.NewServer(readHandler, writeHandler) + + go func() { + <-ctx.Done() + }() + s.logger.Info("starting inline TFTP server", "address", s.addr, "verifyClient", s.verify) + return srv.ListenAndServe(s.addr) +} + +func (s *Server) lookupBySerial(ctx context.Context, serial string) (*corev1.Device, error) { + list := &corev1.DeviceList{} + if err := s.reader.List(ctx, list, client.MatchingLabels{corev1.DeviceSerialLabel: serial}); err != nil { + s.logger.Error(err, "failed to look up device by serial", "serial", serial) + return nil, fmt.Errorf("failed to look up device by serial %q: %w", serial, err) + } + if len(list.Items) == 0 { + return nil, nil + } + return &list.Items[0], nil +} + +func (s *Server) lookupByIP(ctx context.Context, ip string) (*corev1.Device, error) { + list := &corev1.DeviceList{} + if err := s.reader.List(ctx, list, client.MatchingFields{deviceEndpointIPField: ip}); err != nil { + s.logger.Error(err, "failed to look up device by client IP", "clientIP", ip) + return nil, fmt.Errorf("failed to look up device by client IP %q: %w", ip, err) + } + if len(list.Items) == 0 { + return nil, nil + } + return &list.Items[0], nil +} + +func endpointIP(address string) string { + ip, _, _ := strings.Cut(address, ":") + return ip +} + +func resolveBootScript(ctx context.Context, r client.Reader, ns string, p *corev1.Provisioning) []byte { + if p == nil { + return nil + } + c := clientutil.NewClient(r, ns) + data, err := c.Template(ctx, &p.BootScript) + if err != nil { + return nil + } + return data +} + +func parseSerial(fn string) string { + if n, ok := strings.CutPrefix(fn, "serial-"); ok { + fn = n + } + if i := strings.IndexByte(fn, '.'); i >= 0 { + fn = fn[:i] + } + return strings.TrimSpace(fn) +} diff --git a/internal/tftp/server_test.go b/internal/tftp/server_test.go new file mode 100644 index 00000000..33f7b00a --- /dev/null +++ b/internal/tftp/server_test.go @@ -0,0 +1,161 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package tftpserver + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + corev1 "github.com/ironcore-dev/network-operator/api/core/v1alpha1" +) + +func TestParseSerial(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {name: "strip serial prefix and extension", in: "serial-test-123.boot", want: "test-123"}, + {name: "no prefix with extension", in: "test-123.boot", want: "test-123"}, + {name: "keep first segment before dot", in: "serial-test-123.boot.cfg", want: "test-123"}, + {name: "trim spaces", in: " serial-test-123.boot ", want: "serial-test-123"}, + {name: "empty", in: "", want: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, parseSerial(tt.in)) + }) + } +} + +func TestEndpointIP(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {name: "ip and port", in: "10.0.0.8:22", want: "10.0.0.8"}, + {name: "ip only", in: "10.0.0.8", want: "10.0.0.8"}, + {name: "empty", in: "", want: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, endpointIP(tt.in)) + }) + } +} + +func TestLookupBySerial(t *testing.T) { + sch := runtime.NewScheme() + require.NoError(t, clientgoscheme.AddToScheme(sch)) + require.NoError(t, corev1.AddToScheme(sch)) + + dev := &corev1.Device{ + ObjectMeta: metav1.ObjectMeta{ + Name: "device-1", + Namespace: "default", + Labels: map[string]string{ + corev1.DeviceSerialLabel: "SER-001", + }, + }, + Spec: corev1.DeviceSpec{Endpoint: corev1.Endpoint{Address: "10.0.0.10:22"}}, + } + + cl := fake.NewClientBuilder().WithScheme(sch).WithObjects(dev).Build() + srv := &Server{reader: cl} + + t.Run("found", func(t *testing.T) { + got, err := srv.lookupBySerial(context.Background(), "SER-001") + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, "device-1", got.Name) + }) + + t.Run("not found", func(t *testing.T) { + got, err := srv.lookupBySerial(context.Background(), "SER-404") + require.NoError(t, err) + assert.Nil(t, got) + }) +} + +func TestLookupByIP(t *testing.T) { + sch := runtime.NewScheme() + require.NoError(t, clientgoscheme.AddToScheme(sch)) + require.NoError(t, corev1.AddToScheme(sch)) + + dev := &corev1.Device{ + ObjectMeta: metav1.ObjectMeta{ + Name: "device-1", + Namespace: "default", + }, + Spec: corev1.DeviceSpec{Endpoint: corev1.Endpoint{Address: "10.0.0.10:22"}}, + } + + cl := fake.NewClientBuilder(). + WithScheme(sch). + WithObjects(dev). + WithIndex(&corev1.Device{}, deviceEndpointIPField, func(obj client.Object) []string { + d, ok := obj.(*corev1.Device) + if !ok { + return nil + } + return []string{endpointIP(d.Spec.Endpoint.Address)} + }). + Build() + + srv := &Server{reader: cl} + + t.Run("found", func(t *testing.T) { + got, err := srv.lookupByIP(context.Background(), "10.0.0.10") + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, "device-1", got.Name) + }) + + t.Run("not found", func(t *testing.T) { + got, err := srv.lookupByIP(context.Background(), "10.0.0.99") + require.NoError(t, err) + assert.Nil(t, got) + }) +} + +func TestResolveBootScript(t *testing.T) { + sch := runtime.NewScheme() + require.NoError(t, clientgoscheme.AddToScheme(sch)) + require.NoError(t, corev1.AddToScheme(sch)) + + cl := fake.NewClientBuilder().WithScheme(sch).Build() + ctx := context.Background() + + t.Run("nil provisioning returns nil", func(t *testing.T) { + got := resolveBootScript(ctx, cl, "default", nil) + assert.Nil(t, got) + }) + + t.Run("inline bootscript", func(t *testing.T) { + inline := "#!/bin/sh\necho hello" + p := &corev1.Provisioning{BootScript: corev1.TemplateSource{Inline: &inline}} + + got := resolveBootScript(ctx, cl, "default", p) + require.NotNil(t, got) + assert.Equal(t, inline, string(got)) + }) + + t.Run("missing template source returns nil", func(t *testing.T) { + p := &corev1.Provisioning{BootScript: corev1.TemplateSource{}} + + got := resolveBootScript(ctx, cl, "default", p) + assert.Nil(t, got) + }) +}