Skip to content

Commit 933d6b5

Browse files
committed
Implement ZTP plugin
1 parent 0b91073 commit 933d6b5

File tree

6 files changed

+262
-0
lines changed

6 files changed

+262
-0
lines changed

example/ztp_config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
provisioningScriptAddress: "http://[2001:db8::1]/ztp/provisioning.sh"

internal/api/ztp_config.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors
2+
// SPDX-License-Identifier: MIT
3+
4+
package api
5+
6+
type ZTPConfig struct {
7+
ProvisioningScriptAddress string `yaml:"provisioningScriptAddress"`
8+
}

main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import (
3939
"github.com/ironcore-dev/fedhcp/plugins/onmetal"
4040
"github.com/ironcore-dev/fedhcp/plugins/oob"
4141
"github.com/ironcore-dev/fedhcp/plugins/pxeboot"
42+
"github.com/ironcore-dev/fedhcp/plugins/ztp"
4243
"k8s.io/apimachinery/pkg/util/sets"
4344
)
4445

@@ -66,6 +67,7 @@ var desiredPlugins = []*plugins.Plugin{
6667
&httpboot.Plugin,
6768
&metal.Plugin,
6869
&macfilter.Plugin,
70+
&ztp.Plugin,
6971
}
7072

7173
var (

plugins/ztp/plugin.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors
2+
// SPDX-License-Identifier: MIT
3+
4+
package ztp
5+
6+
import (
7+
"fmt"
8+
"net/url"
9+
"os"
10+
11+
"github.com/ironcore-dev/fedhcp/internal/api"
12+
"gopkg.in/yaml.v2"
13+
14+
"github.com/coredhcp/coredhcp/handler"
15+
"github.com/coredhcp/coredhcp/logger"
16+
"github.com/coredhcp/coredhcp/plugins"
17+
"github.com/insomniacslk/dhcp/dhcpv6"
18+
)
19+
20+
var log = logger.GetLogger("plugins/ztp")
21+
22+
// Plugin wraps plugin registration information
23+
var Plugin = plugins.Plugin{
24+
Name: "ztp",
25+
Setup6: setup6,
26+
}
27+
28+
var (
29+
provisioningScript string
30+
)
31+
32+
const (
33+
optionZTPCode = 239
34+
)
35+
36+
// args[0] = path to config file
37+
func parseArgs(args ...string) (string, error) {
38+
if len(args) != 1 {
39+
return "", fmt.Errorf("exactly one argument must be passed to the plugin, got %d", len(args))
40+
}
41+
return args[0], nil
42+
}
43+
44+
func loadConfig(args ...string) (*api.ZTPConfig, error) {
45+
path, err := parseArgs(args...)
46+
if err != nil {
47+
return nil, fmt.Errorf("invalid configuration: %v", err)
48+
}
49+
50+
log.Debugf("Reading config file %s", path)
51+
configData, err := os.ReadFile(path)
52+
if err != nil {
53+
return nil, fmt.Errorf("failed to read config file: %v", err)
54+
}
55+
56+
config := &api.ZTPConfig{}
57+
if err = yaml.Unmarshal(configData, config); err != nil {
58+
return nil, fmt.Errorf("failed to parse config file: %v", err)
59+
}
60+
61+
return config, nil
62+
}
63+
64+
func parseConfig(args ...string) (*url.URL, error) {
65+
ztpConfig, err := loadConfig(args...)
66+
if err != nil {
67+
return nil, err
68+
}
69+
scriptURL, err := url.Parse(ztpConfig.ProvisioningScriptAddress)
70+
if err != nil {
71+
return nil, fmt.Errorf("invalid ztp script scriptURL: %v", err)
72+
}
73+
74+
if (scriptURL.Scheme != "http" && scriptURL.Scheme != "https") || scriptURL.Host == "" || scriptURL.Path == "" {
75+
return nil, fmt.Errorf("malformed ZTP script parameter, should be a valid URL")
76+
}
77+
78+
return scriptURL, nil
79+
}
80+
81+
func setup6(args ...string) (handler.Handler6, error) {
82+
scriptURL, err := parseConfig(args...)
83+
if err != nil {
84+
return nil, err
85+
}
86+
87+
provisioningScript = scriptURL.String()
88+
89+
log.Printf("loaded ZTP plugin for DHCPv6.")
90+
return handler6, nil
91+
}
92+
93+
func handler6(req, resp dhcpv6.DHCPv6) (dhcpv6.DHCPv6, bool) {
94+
log.Debugf("Received DHCPv6 request: %s", req.Summary())
95+
96+
if provisioningScript == "" {
97+
// nothing to do
98+
return resp, false
99+
}
100+
101+
var opt dhcpv6.Option
102+
103+
// TODO: ZTP check?
104+
buf := []byte(provisioningScript)
105+
opt = &dhcpv6.OptionGeneric{
106+
OptionCode: optionZTPCode,
107+
OptionData: buf,
108+
}
109+
110+
if opt != nil {
111+
resp.AddOption(opt)
112+
log.Debugf("Added option %s", opt)
113+
}
114+
115+
log.Debugf("Sent DHCPv6 response: %s", resp.Summary())
116+
return resp, false
117+
}

plugins/ztp/plugin_test.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors
2+
// SPDX-License-Identifier: MIT
3+
4+
package ztp
5+
6+
import (
7+
"net"
8+
"os"
9+
10+
"github.com/insomniacslk/dhcp/dhcpv6"
11+
12+
. "github.com/onsi/ginkgo/v2"
13+
. "github.com/onsi/gomega"
14+
)
15+
16+
var _ = Describe("ZTP Plugin", func() {
17+
Describe("Configuration Loading", func() {
18+
It("should return an error if the configuration file is missing", func() {
19+
_, err := loadConfig("nonexistent.yaml")
20+
Expect(err).To(HaveOccurred())
21+
})
22+
23+
It("should return an error if the configuration file is invalid", func() {
24+
invalidConfigPath := "invalid_test_config.yaml"
25+
26+
file, err := os.CreateTemp(GinkgoT().TempDir(), invalidConfigPath)
27+
Expect(err).NotTo(HaveOccurred())
28+
defer func() {
29+
_ = file.Close()
30+
}()
31+
Expect(os.WriteFile(file.Name(), []byte("Invalid YAML"), 0644)).To(Succeed())
32+
33+
_, err = loadConfig(file.Name())
34+
Expect(err).To(HaveOccurred())
35+
})
36+
})
37+
38+
Describe("DHCPv6 Message Handling", func() {
39+
It("should return ZTP option 239", func() {
40+
req := createRequest("11:22:33:44:55:66")
41+
stub, err := dhcpv6.NewMessage()
42+
Expect(err).NotTo(HaveOccurred())
43+
Expect(stub).NotTo(BeNil())
44+
45+
stub.MessageType = dhcpv6.MessageTypeReply
46+
resp, stop := handler6(req, stub)
47+
Expect(stop).To(BeFalse())
48+
49+
opt := resp.GetOneOption(optionZTPCode).(*dhcpv6.OptionGeneric)
50+
Expect(opt).NotTo(BeNil())
51+
Expect(int(opt.OptionCode)).To(Equal(optionZTPCode))
52+
Expect(opt.OptionData).To(Equal([]byte(provisioningScript)))
53+
})
54+
})
55+
})
56+
57+
func createRequest(mac string) dhcpv6.DHCPv6 {
58+
hwAddr, err := net.ParseMAC(mac)
59+
Expect(err).NotTo(HaveOccurred())
60+
Expect(hwAddr).NotTo(BeNil())
61+
62+
req, err := dhcpv6.NewMessage()
63+
Expect(err).NotTo(HaveOccurred())
64+
Expect(req).NotTo(BeNil())
65+
66+
req.MessageType = dhcpv6.MessageTypeRequest
67+
req.AddOption(dhcpv6.OptRequestedOption(optionZTPCode))
68+
return req
69+
}

plugins/ztp/suite_test.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors
2+
// SPDX-License-Identifier: MIT
3+
4+
package ztp
5+
6+
import (
7+
"os"
8+
"testing"
9+
"time"
10+
11+
"github.com/ironcore-dev/fedhcp/internal/api"
12+
"gopkg.in/yaml.v3"
13+
14+
. "github.com/onsi/ginkgo/v2"
15+
. "github.com/onsi/gomega"
16+
"sigs.k8s.io/controller-runtime/pkg/envtest"
17+
logf "sigs.k8s.io/controller-runtime/pkg/log"
18+
"sigs.k8s.io/controller-runtime/pkg/log/zap"
19+
//+kubebuilder:scaffold:imports
20+
)
21+
22+
const (
23+
pollingInterval = 50 * time.Millisecond
24+
eventuallyTimeout = 3 * time.Second
25+
consistentlyDuration = 1 * time.Second
26+
testConfigPath = "config.yaml"
27+
testZtpProvisioningScriptPath = "https://2001:db8::1/ztp/provisioning.sh"
28+
)
29+
30+
var (
31+
testEnv *envtest.Environment
32+
)
33+
34+
func TestZTP(t *testing.T) {
35+
SetDefaultConsistentlyPollingInterval(pollingInterval)
36+
SetDefaultEventuallyPollingInterval(pollingInterval)
37+
SetDefaultEventuallyTimeout(eventuallyTimeout)
38+
SetDefaultConsistentlyDuration(consistentlyDuration)
39+
RegisterFailHandler(Fail)
40+
41+
RunSpecs(t, "ZTP Plugin Suite")
42+
}
43+
44+
var _ = BeforeSuite(func() {
45+
logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true)))
46+
log.Print("BeforeSuite: Runs once before all tests")
47+
48+
configFile := testConfigPath
49+
config := &api.ZTPConfig{
50+
ProvisioningScriptAddress: testZtpProvisioningScriptPath,
51+
}
52+
configData, err := yaml.Marshal(config)
53+
Expect(err).NotTo(HaveOccurred())
54+
55+
file, err := os.CreateTemp(GinkgoT().TempDir(), configFile)
56+
Expect(err).NotTo(HaveOccurred())
57+
defer func() {
58+
_ = file.Close()
59+
}()
60+
Expect(os.WriteFile(file.Name(), configData, 0644)).To(Succeed())
61+
62+
_, err = setup6(file.Name())
63+
Expect(err).NotTo(HaveOccurred())
64+
Expect(provisioningScript).NotTo(BeEmpty())
65+
})

0 commit comments

Comments
 (0)