diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 7add4f407..be1869229 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -34,7 +34,34 @@ builds: goamd64: - v3 env: - - CGO_ENABLED=0 + - CGO_ENABLED=1 + - id: netboot + main: ./cmd/netboot-server + binary: netboot-server + # export GIT_STATE=$(if git diff-index --quiet HEAD --; then echo 'clean'; else echo 'dirty'; fi) + # export BUILD_HOST=$(hostname) + # export GO_VERSION=$(go version | awk '{print $3}') + # export BUILD_USER=$(whoami) + ldflags: + - "-X 'main.GitCommit={{.Commit}}' \ + -X 'main.BuildTime={{.Timestamp}}' \ + -X 'main.Version={{.Version}}' \ + -X 'main.GitBranch={{.Branch}}' \ + -X 'main.GitTag={{.Tag}}' \ + -X 'main.GitState={{ .Env.GIT_STATE }}' \ + -X 'main.BuildHost={{ .Env.BUILD_HOST }}' \ + -X 'main.GoVersion={{ .Env.GO_VERSION }}' \ + -X 'main.BuildUser={{ .Env.BUILD_USER }}'" + goos: + - linux + - darwin + goarch: + - amd64 + - arm64 + goamd64: + - v3 + env: + - CGO_ENABLED=1 dockers: - image_templates: @@ -96,7 +123,7 @@ docker_manifests: - *arm64v8_linux_image archives: - - format: tar.gz + - format : "tar.gz" # this name template makes the OS and Arch compatible with the results of uname. name_template: >- {{ .ProjectName }}_ @@ -115,6 +142,7 @@ checksum: name_template: 'checksums.txt' snapshot: version_template: "{{ incpatch .Version }}-next" + changelog: sort: asc filters: diff --git a/README.md b/README.md index dbf1b1810..0cd47a2cd 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ export BUILD_USER=$(whoami) 2. Run in snapshot mode to build locally without releasing: ```bash - goreleaser release --snapshot --clean + goreleaser release --snapshot --clean --single-target ``` 3. Check the `dist/` directory for compiled binaries, which will include the injected metadata. diff --git a/cmd/cloud-init-server/handlers.go b/cmd/cloud-init-server/handlers.go index 1e2aa5d00..bcb5de6a2 100644 --- a/cmd/cloud-init-server/handlers.go +++ b/cmd/cloud-init-server/handlers.go @@ -7,8 +7,8 @@ import ( // Import to run swag.Register() to generated docs _ "github.com/OpenCHAMI/cloud-init/docs" - "github.com/OpenCHAMI/cloud-init/internal/smdclient" "github.com/OpenCHAMI/cloud-init/pkg/cistore" + "github.com/OpenCHAMI/cloud-init/pkg/smdclient" "github.com/OpenCHAMI/cloud-init/pkg/wgtunnel" "github.com/go-chi/chi/v5" "github.com/rs/zerolog/log" diff --git a/cmd/cloud-init-server/main.go b/cmd/cloud-init-server/main.go index 18da12ba4..6f48935b2 100644 --- a/cmd/cloud-init-server/main.go +++ b/cmd/cloud-init-server/main.go @@ -15,8 +15,9 @@ import ( "time" "github.com/OpenCHAMI/cloud-init/internal/memstore" - "github.com/OpenCHAMI/cloud-init/internal/smdclient" + "github.com/OpenCHAMI/cloud-init/internal/quackstore" "github.com/OpenCHAMI/cloud-init/pkg/cistore" + "github.com/OpenCHAMI/cloud-init/pkg/smdclient" "github.com/OpenCHAMI/cloud-init/pkg/wgtunnel" "github.com/OpenCHAMI/jwtauth/v5" "github.com/go-chi/chi/v5" @@ -50,6 +51,8 @@ var ( wireguardOnly = false debug = true wireGuardMiddleware func(http.Handler) http.Handler + storageBackend = "mem" // Default to memstore + dbPath = "cloud-init.db" // Default database path for quackstore ) func main() { @@ -69,6 +72,8 @@ func main() { flag.StringVar(&wireguardServer, "wireguard-server", wireguardServer, "Wireguard server IP address and network (e.g. 100.97.0.1/16)") flag.BoolVar(&wireguardOnly, "wireguard-only", wireguardOnly, "Only allow access to the cloud-init functions from the WireGuard subnet") flag.BoolVar(&debug, "debug", debug, "Enable debug logging") + flag.StringVar(&storageBackend, "storage-backend", storageBackend, "Storage backend to use (mem or quack)") + flag.StringVar(&dbPath, "db-path", dbPath, "Path to the database file for quackstore backend") flag.Parse() if debug { @@ -115,15 +120,32 @@ func main() { } } - // Set up our DataStore - store = memstore.NewMemStore() - store.SetClusterDefaults(cistore.ClusterDefaults{ + // Set up our DataStore based on the selected backend + var err error + switch storageBackend { + case "mem": + store = memstore.NewMemStore() + case "quack": + store, err = quackstore.NewQuackStore(dbPath) + if err != nil { + log.Fatal().Err(err).Msg("Failed to initialize QuackStore") + } + defer store.(*quackstore.QuackStore).Close() + default: + log.Fatal().Msgf("Unsupported storage backend: %s", storageBackend) + } + + // Set cluster defaults + err = store.SetClusterDefaults(cistore.ClusterDefaults{ ClusterName: clusterName, Region: region, AvailabilityZone: availabilityZone, CloudProvider: cloudProvider, BaseUrl: baseUrl, }) + if err != nil { + log.Fatal().Err(err).Msg("Failed to set cluster defaults") + } // Initialize the cloud-init handler with the datastore and SMD client ciHandler := NewCiHandler(store, sm, clusterName) diff --git a/cmd/cloud-init-server/metadata_handlers.go b/cmd/cloud-init-server/metadata_handlers.go index d1bd48881..940aaee20 100644 --- a/cmd/cloud-init-server/metadata_handlers.go +++ b/cmd/cloud-init-server/metadata_handlers.go @@ -4,8 +4,8 @@ import ( "net/http" "strings" - "github.com/OpenCHAMI/cloud-init/internal/smdclient" "github.com/OpenCHAMI/cloud-init/pkg/cistore" + "github.com/OpenCHAMI/cloud-init/pkg/smdclient" "github.com/go-chi/chi/v5" "github.com/rs/zerolog/log" yaml "gopkg.in/yaml.v2" diff --git a/cmd/cloud-init-server/userdata_handlers.go b/cmd/cloud-init-server/userdata_handlers.go index e14729807..6e76dfb34 100644 --- a/cmd/cloud-init-server/userdata_handlers.go +++ b/cmd/cloud-init-server/userdata_handlers.go @@ -3,8 +3,8 @@ package main import ( "net/http" - "github.com/OpenCHAMI/cloud-init/internal/smdclient" "github.com/OpenCHAMI/cloud-init/pkg/cistore" + "github.com/OpenCHAMI/cloud-init/pkg/smdclient" "github.com/go-chi/chi/v5" "github.com/rs/zerolog/log" ) diff --git a/cmd/cloud-init-server/vendordata_handlers.go b/cmd/cloud-init-server/vendordata_handlers.go index 673e26577..265eb7bca 100644 --- a/cmd/cloud-init-server/vendordata_handlers.go +++ b/cmd/cloud-init-server/vendordata_handlers.go @@ -4,8 +4,8 @@ import ( "fmt" "net/http" - "github.com/OpenCHAMI/cloud-init/internal/smdclient" "github.com/OpenCHAMI/cloud-init/pkg/cistore" + "github.com/OpenCHAMI/cloud-init/pkg/smdclient" "github.com/go-chi/chi/v5" "github.com/rs/zerolog/log" ) diff --git a/cmd/netboot-server/main.go b/cmd/netboot-server/main.go new file mode 100644 index 000000000..c6a6a33c8 --- /dev/null +++ b/cmd/netboot-server/main.go @@ -0,0 +1,184 @@ +package main + +import ( + "errors" + "fmt" + "net/http" + "os" + "time" + + "github.com/OpenCHAMI/cloud-init/pkg/bss" + "github.com/OpenCHAMI/cloud-init/pkg/smdclient" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + openchami_logger "github.com/openchami/chi-middleware/log" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/rs/zerolog/pkgerrors" + + "github.com/spf13/cobra" +) + +var ( + clusterName string + jwksUrl string + smdEndpoint string + tokenEndpoint string + accessToken string + certPath string + insecure bool + fakeSMDEnabled bool + storageBackend string + dbPath string +) + +func main() { + // Create root command + rootCmd := &cobra.Command{ + Use: "netboot-server", + Short: "OpenCHAMI netboot server", + Long: `OpenCHAMI netboot server provides boot parameter management and boot script generation. +It supports both in-memory and persistent storage backends, and can integrate with SMD for inventory details.`, + } + + // Create serve command + serveCmd := &cobra.Command{ + Use: "serve", + Short: "Start the netboot server", + Long: `Start the netboot server with the specified configuration.`, + PreRunE: func(cmd *cobra.Command, args []string) error { + if smdEndpoint == "" && !fakeSMDEnabled { + return errors.New("either --smd-endpoint or --fake-smd must be set for inventory details") + } + if clusterName == "" { + return errors.New("cluster name must be set via --cluster-name or CLUSTER_NAME") + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + return startServer() + }, + } + + // Add flags to serve command + serveCmd.Flags().StringVar(&clusterName, "cluster-name", os.Getenv("CLUSTER_NAME"), "Cluster name for SMD (default from CLUSTER_NAME env)") + serveCmd.Flags().StringVar(&jwksUrl, "jwks-url", os.Getenv("JWKS_URL"), "JWKS URL for JWT validation (default from JWKS_URL env)") + serveCmd.Flags().StringVar(&smdEndpoint, "smd-endpoint", os.Getenv("SMD_ENDPOINT"), "SMD endpoint (default from SMD_ENDPOINT env)") + serveCmd.Flags().StringVar(&tokenEndpoint, "token-endpoint", os.Getenv("TOKEN_ENDPOINT"), "Token endpoint (default from TOKEN_ENDPOINT env)") + serveCmd.Flags().StringVar(&accessToken, "access-token", os.Getenv("ACCESS_TOKEN"), "Access token (default from ACCESS_TOKEN env)") + serveCmd.Flags().StringVar(&certPath, "cert-path", os.Getenv("CERT_PATH"), "Path to certificate for secure connections (default from CERT_PATH env)") + serveCmd.Flags().BoolVar(&insecure, "insecure", os.Getenv("INSECURE") == "true", "Disable TLS verification (default from INSECURE env)") + serveCmd.Flags().BoolVar(&fakeSMDEnabled, "fake-smd", os.Getenv("CLOUD_INIT_SMD_SIMULATOR") == "true", "Enable FakeSMDClient simulation (default from CLOUD_INIT_SMD_SIMULATOR env)") + serveCmd.Flags().StringVar(&storageBackend, "storage-backend", "mem", "Storage backend to use (mem or quack)") + serveCmd.Flags().StringVar(&dbPath, "db-path", "netboot.db", "Path to the database file for quackstore backend") + + // Create version command + versionCmd := &cobra.Command{ + Use: "version", + Short: "Print version information", + Run: func(cmd *cobra.Command, args []string) { + PrintVersionInfo() + }, + } + + // Add commands to root + rootCmd.AddCommand(serveCmd) + rootCmd.AddCommand(versionCmd) + + // Execute root command + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} + +// startServer encapsulates the existing main logic +func startServer() error { + // Setup logger + zerolog.TimeFieldFormat = zerolog.TimeFormatUnix + zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack + log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}) + + // Create SMD client + var sm smdclient.SMDClientInterface + if fakeSMDEnabled { + fmt.Printf("\n\n**********\n\n\tCLOUD_INIT_SMD_SIMULATOR is set to true.\n\tUsing the FakeSMDClient\n\n**********\n\n\n") + fakeSm := smdclient.NewFakeSMDClient(clusterName, 500) + fakeSm.Summary() + sm = fakeSm + } else { + var err error + sm, err = smdclient.NewSMDClient(clusterName, smdEndpoint, tokenEndpoint, accessToken, certPath, insecure) + if err != nil { + log.Fatal().Err(err) + } + } + + // Initialize BSS storage based on the selected backend + var bssStorage bss.Store + var err error + switch storageBackend { + case "mem": + bssStorage = bss.NewMemoryStore() + case "quack": + bssStorage, err = bss.NewQuackStore(dbPath) + if err != nil { + log.Fatal().Err(err).Msg("Failed to initialize QuackStore") + } + defer bssStorage.(*bss.QuackStore).Close() + default: + log.Fatal().Msgf("Unsupported storage backend: %s", storageBackend) + } + + // Set up router + router := chi.NewRouter() + router.Use( + middleware.RequestID, + middleware.RealIP, + middleware.Logger, + middleware.Recoverer, + middleware.StripSlashes, + middleware.Timeout(60*time.Second), + openchami_logger.OpenCHAMILogger(log.Logger), + ) + + initBssRouter(router, sm, bssStorage) + + // Start the HTTP server + log.Info(). + Str("cluster", clusterName). + Str("smdEndpoint", smdEndpoint). + Str("fakeSMDEnabled", fmt.Sprintf("%t", fakeSMDEnabled)). + Str("tokenEndpoint", tokenEndpoint). + Str("jwksUrl", jwksUrl). + Str("accessToken", accessToken). + Str("certPath", certPath). + Bool("insecure", insecure). + Str("storageBackend", storageBackend). + Str("dbPath", dbPath). + Str("version", Version). + Str("commit", GitCommit). + Msgf("Starting netboot server on port 8080") + + if err := http.ListenAndServe(":8080", router); err != nil { + log.Fatal().Msgf("Failed to start server: %v", err) + } + return nil +} + +// initBssRouter sets up bootparam routes +func initBssRouter(router chi.Router, sm smdclient.SMDClientInterface, bssStorage bss.Store) { + router.Get("/version", VersionHandler) + router.Route("/boot/v1/", func(r chi.Router) { + r.Post("/bootparams", bss.CreateBootParamsHandler(bssStorage)) + r.Get("/bootparams/{id}", bss.GetBootParamsHandler(bssStorage)) + r.Put("/bootparams/{id}", bss.UpdateBootParamsHandler(bssStorage)) + r.Get("/bootparams/{id}", bss.GetBootParamsHandler(bssStorage)) + r.Get("/bootscript", bss.GenerateBootScriptHandler(bssStorage, sm)) + }) + router.Route("/boot/v2/", func(r chi.Router) { + r.Get("/bootparams/{id}", bss.GetBootParamsHandler(bssStorage)) + r.Put("/bootparams/{id}", bss.UpdateBootParamsHandler(bssStorage)) + r.Get("/bootparams/{id}", bss.GetBootParamsHandler(bssStorage)) + r.Get("/bootscript", bss.GenerateBootScriptHandler(bssStorage, sm)) + }) +} diff --git a/cmd/netboot-server/version.go b/cmd/netboot-server/version.go new file mode 100644 index 000000000..d8502f37a --- /dev/null +++ b/cmd/netboot-server/version.go @@ -0,0 +1,108 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "runtime" + "time" +) + +// GitCommit stores the latest Git commit hash. +// Set via -ldflags "-X main.GitCommit=$(git rev-parse HEAD)" +var GitCommit string + +// BuildTime stores the build timestamp in UTC. +// Set via -ldflags "-X main.BuildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)" +var BuildTime string + +// Version indicates the version of the binary, such as a release number or semantic version. +// Set via -ldflags "-X main.Version=v1.0.0" +var Version string + +// GitBranch holds the name of the Git branch from which the build was created. +// Set via -ldflags "-X main.GitBranch=$(git rev-parse --abbrev-ref HEAD)" +var GitBranch string + +// GitTag represents the most recent Git tag at build time, if any. +// Set via -ldflags "-X main.GitTag=$(git describe --tags --abbrev=0)" +var GitTag string + +// GitState indicates whether the working directory was "clean" or "dirty" (i.e., with uncommitted changes). +// Set via -ldflags "-X main.GitState=$(if git diff-index --quiet HEAD --; then echo 'clean'; else echo 'dirty'; fi)" +var GitState string + +// BuildHost stores the hostname of the machine where the binary was built. +// Set via -ldflags "-X main.BuildHost=$(hostname)" +var BuildHost string + +// GoVersion captures the Go version used to build the binary. +// Typically, this can be obtained automatically with runtime.Version(), but you can set it manually. +// Set via -ldflags "-X main.GoVersion=$(go version | awk '{print $3}')" +var GoVersion string + +// BuildUser is the username of the person or system that initiated the build process. +// Set via -ldflags "-X main.BuildUser=$(whoami)" +var BuildUser string + +var ( + startTime time.Time + runtimeHost string + processName string +) + +// PrintVersionInfo outputs all versioning information for troubleshooting or version checks. +func PrintVersionInfo() { + fmt.Printf("Version: %s\n", Version) + fmt.Printf("Git Commit: %s\n", GitCommit) + fmt.Printf("Build Time: %s\n", BuildTime) + fmt.Printf("Git Branch: %s\n", GitBranch) + fmt.Printf("Git Tag: %s\n", GitTag) + fmt.Printf("Git State: %s\n", GitState) + fmt.Printf("Build Host: %s\n", BuildHost) + fmt.Printf("Go Version: %s\n", GoVersion) + fmt.Printf("Build User: %s\n", BuildUser) +} + +func init() { + startTime = time.Now() + hostname, err := os.Hostname() + if err == nil { + runtimeHost = hostname + } else { + runtimeHost = "unknown" + } + processName = filepath.Base(os.Args[0]) +} + +func VersionInfo() string { + return fmt.Sprintf("Version: %s, Git Commit: %s, Build Time: %s, Git Branch: %s, Git Tag: %s, Git State: %s, Build Host: %s, Go Version: %s, Build User: %s", + Version, GitCommit, BuildTime, GitBranch, GitTag, GitState, BuildHost, GoVersion, BuildUser) +} + +func VersionHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + info := VersionInfo() + uptime := time.Since(startTime).String() + memstats := &runtime.MemStats{} + runtime.ReadMemStats(memstats) + + response := struct { + ProcessName string `json:"process_name"` + BuildInfo string `json:"build_info"` + Uptime string `json:"uptime"` + RuntimeHost string `json:"runtime_host"` + BytesAllocated uint64 `json:"bytes_allocated"` + }{ + ProcessName: processName, + BuildInfo: info, + Uptime: uptime, + RuntimeHost: runtimeHost, + BytesAllocated: memstats.HeapAlloc, + } + + json.NewEncoder(w).Encode(response) +} diff --git a/docs/docs.go b/docs/docs.go index 14ed03d8c..334224652 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -19,6 +19,214 @@ const docTemplate = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { + "/bootparams": { + "post": { + "description": "Creates a new set of boot parameters with a unique ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "boot-params" + ], + "summary": "Create new boot parameters", + "parameters": [ + { + "description": "Boot parameters to create", + "name": "bootParams", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/bss.BootParams" + } + } + ], + "responses": { + "201": { + "description": "Created boot parameters. Location header will be set to the URL of the new boot parameters.", + "schema": { + "$ref": "#/definitions/bss.BootParams" + } + }, + "400": { + "description": "Invalid request body", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "string" + } + } + } + } + }, + "/bootparams/{id}": { + "get": { + "description": "Retrieves boot parameters by ID", + "produces": [ + "application/json" + ], + "tags": [ + "boot-params" + ], + "summary": "Get boot parameters", + "parameters": [ + { + "type": "string", + "description": "Boot parameters ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Boot parameters", + "schema": { + "$ref": "#/definitions/bss.BootParams" + } + }, + "400": { + "description": "Invalid ID", + "schema": { + "type": "string" + } + }, + "404": { + "description": "Boot parameters not found", + "schema": { + "type": "string" + } + } + } + }, + "put": { + "description": "Updates an existing set of boot parameters by ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "boot-params" + ], + "summary": "Update existing boot parameters", + "parameters": [ + { + "type": "string", + "description": "Boot parameters ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Updated boot parameters", + "name": "bootParams", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/bss.BootParams" + } + } + ], + "responses": { + "200": { + "description": "Updated boot parameters", + "schema": { + "$ref": "#/definitions/bss.BootParams" + } + }, + "400": { + "description": "Invalid request body or ID", + "schema": { + "type": "string" + } + }, + "404": { + "description": "Boot parameters not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "string" + } + } + } + } + }, + "/bootscript/{id}": { + "get": { + "description": "Generates a boot script based on component ID and group membership", + "produces": [ + "text/plain" + ], + "tags": [ + "boot-scripts" + ], + "summary": "Generate boot script", + "parameters": [ + { + "type": "string", + "description": "Component ID (optional, defaults to requesting IP's component)", + "name": "id", + "in": "path" + }, + { + "type": "integer", + "description": "Retry count", + "name": "retry", + "in": "query" + }, + { + "type": "string", + "description": "Architecture", + "name": "arch", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Generated boot script", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Invalid parameters", + "schema": { + "type": "string" + } + }, + "404": { + "description": "No templates found", + "schema": { + "type": "string" + } + }, + "422": { + "description": "Unprocessable entity", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "string" + } + } + } + } + }, "/cloud-init/admin/cluster-defaults": { "get": { "description": "Get default meta-data values for cluster.", @@ -636,6 +844,81 @@ const docTemplate = `{ } }, "definitions": { + "bss.BootParams": { + "type": "object", + "properties": { + "cloud-init": { + "description": "Cloud-init server configuration", + "allOf": [ + { + "$ref": "#/definitions/bss.CloudInitServer" + } + ] + }, + "cmdline": { + "description": "Command line for the kernel", + "type": "string" + }, + "initrd": { + "description": "Initrd image URL/path", + "type": "string" + }, + "kernel": { + "description": "Kernel image URL/path", + "type": "string" + }, + "params": { + "description": "Kernel boot parameters", + "type": "string" + }, + "rootfs": { + "description": "Root filesystem configuration", + "allOf": [ + { + "$ref": "#/definitions/bss.RootFS" + } + ] + }, + "version": { + "description": "Version of the boot parameters", + "type": "integer" + } + } + }, + "bss.CloudInitServer": { + "type": "object", + "properties": { + "url": { + "description": "URL of the cloud-init server", + "type": "string" + }, + "version": { + "description": "Version of the cloud-init server", + "type": "string" + } + } + }, + "bss.RootFS": { + "type": "object", + "properties": { + "options": { + "description": "Mount options", + "type": "string" + }, + "path": { + "description": "Path to root filesystem", + "type": "string" + }, + "server": { + "description": "Server for network filesystems", + "type": "string" + }, + "type": { + "description": "Type of root filesystem (nfs, local, etc.)", + "type": "string" + } + } + }, "cistore.CloudConfigFile": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index 4f30b1668..c516cd374 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -11,6 +11,214 @@ "version": "1.0.0" }, "paths": { + "/bootparams": { + "post": { + "description": "Creates a new set of boot parameters with a unique ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "boot-params" + ], + "summary": "Create new boot parameters", + "parameters": [ + { + "description": "Boot parameters to create", + "name": "bootParams", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/bss.BootParams" + } + } + ], + "responses": { + "201": { + "description": "Created boot parameters. Location header will be set to the URL of the new boot parameters.", + "schema": { + "$ref": "#/definitions/bss.BootParams" + } + }, + "400": { + "description": "Invalid request body", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "string" + } + } + } + } + }, + "/bootparams/{id}": { + "get": { + "description": "Retrieves boot parameters by ID", + "produces": [ + "application/json" + ], + "tags": [ + "boot-params" + ], + "summary": "Get boot parameters", + "parameters": [ + { + "type": "string", + "description": "Boot parameters ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Boot parameters", + "schema": { + "$ref": "#/definitions/bss.BootParams" + } + }, + "400": { + "description": "Invalid ID", + "schema": { + "type": "string" + } + }, + "404": { + "description": "Boot parameters not found", + "schema": { + "type": "string" + } + } + } + }, + "put": { + "description": "Updates an existing set of boot parameters by ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "boot-params" + ], + "summary": "Update existing boot parameters", + "parameters": [ + { + "type": "string", + "description": "Boot parameters ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Updated boot parameters", + "name": "bootParams", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/bss.BootParams" + } + } + ], + "responses": { + "200": { + "description": "Updated boot parameters", + "schema": { + "$ref": "#/definitions/bss.BootParams" + } + }, + "400": { + "description": "Invalid request body or ID", + "schema": { + "type": "string" + } + }, + "404": { + "description": "Boot parameters not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "string" + } + } + } + } + }, + "/bootscript/{id}": { + "get": { + "description": "Generates a boot script based on component ID and group membership", + "produces": [ + "text/plain" + ], + "tags": [ + "boot-scripts" + ], + "summary": "Generate boot script", + "parameters": [ + { + "type": "string", + "description": "Component ID (optional, defaults to requesting IP's component)", + "name": "id", + "in": "path" + }, + { + "type": "integer", + "description": "Retry count", + "name": "retry", + "in": "query" + }, + { + "type": "string", + "description": "Architecture", + "name": "arch", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Generated boot script", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Invalid parameters", + "schema": { + "type": "string" + } + }, + "404": { + "description": "No templates found", + "schema": { + "type": "string" + } + }, + "422": { + "description": "Unprocessable entity", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "string" + } + } + } + } + }, "/cloud-init/admin/cluster-defaults": { "get": { "description": "Get default meta-data values for cluster.", @@ -628,6 +836,81 @@ } }, "definitions": { + "bss.BootParams": { + "type": "object", + "properties": { + "cloud-init": { + "description": "Cloud-init server configuration", + "allOf": [ + { + "$ref": "#/definitions/bss.CloudInitServer" + } + ] + }, + "cmdline": { + "description": "Command line for the kernel", + "type": "string" + }, + "initrd": { + "description": "Initrd image URL/path", + "type": "string" + }, + "kernel": { + "description": "Kernel image URL/path", + "type": "string" + }, + "params": { + "description": "Kernel boot parameters", + "type": "string" + }, + "rootfs": { + "description": "Root filesystem configuration", + "allOf": [ + { + "$ref": "#/definitions/bss.RootFS" + } + ] + }, + "version": { + "description": "Version of the boot parameters", + "type": "integer" + } + } + }, + "bss.CloudInitServer": { + "type": "object", + "properties": { + "url": { + "description": "URL of the cloud-init server", + "type": "string" + }, + "version": { + "description": "Version of the cloud-init server", + "type": "string" + } + } + }, + "bss.RootFS": { + "type": "object", + "properties": { + "options": { + "description": "Mount options", + "type": "string" + }, + "path": { + "description": "Path to root filesystem", + "type": "string" + }, + "server": { + "description": "Server for network filesystems", + "type": "string" + }, + "type": { + "description": "Type of root filesystem (nfs, local, etc.)", + "type": "string" + } + } + }, "cistore.CloudConfigFile": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 169f729ae..adffcf4e5 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1,4 +1,54 @@ definitions: + bss.BootParams: + properties: + cloud-init: + allOf: + - $ref: '#/definitions/bss.CloudInitServer' + description: Cloud-init server configuration + cmdline: + description: Command line for the kernel + type: string + initrd: + description: Initrd image URL/path + type: string + kernel: + description: Kernel image URL/path + type: string + params: + description: Kernel boot parameters + type: string + rootfs: + allOf: + - $ref: '#/definitions/bss.RootFS' + description: Root filesystem configuration + version: + description: Version of the boot parameters + type: integer + type: object + bss.CloudInitServer: + properties: + url: + description: URL of the cloud-init server + type: string + version: + description: Version of the cloud-init server + type: string + type: object + bss.RootFS: + properties: + options: + description: Mount options + type: string + path: + description: Path to root filesystem + type: string + server: + description: Server for network filesystems + type: string + type: + description: Type of root filesystem (nfs, local, etc.) + type: string + type: object cistore.CloudConfigFile: properties: content: @@ -202,6 +252,144 @@ info: title: OpenCHAMI Cloud-Init Server API version: 1.0.0 paths: + /bootparams: + post: + consumes: + - application/json + description: Creates a new set of boot parameters with a unique ID + parameters: + - description: Boot parameters to create + in: body + name: bootParams + required: true + schema: + $ref: '#/definitions/bss.BootParams' + produces: + - application/json + responses: + "201": + description: Created boot parameters. Location header will be set to the + URL of the new boot parameters. + schema: + $ref: '#/definitions/bss.BootParams' + "400": + description: Invalid request body + schema: + type: string + "500": + description: Internal server error + schema: + type: string + summary: Create new boot parameters + tags: + - boot-params + /bootparams/{id}: + get: + description: Retrieves boot parameters by ID + parameters: + - description: Boot parameters ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: Boot parameters + schema: + $ref: '#/definitions/bss.BootParams' + "400": + description: Invalid ID + schema: + type: string + "404": + description: Boot parameters not found + schema: + type: string + summary: Get boot parameters + tags: + - boot-params + put: + consumes: + - application/json + description: Updates an existing set of boot parameters by ID + parameters: + - description: Boot parameters ID + in: path + name: id + required: true + type: string + - description: Updated boot parameters + in: body + name: bootParams + required: true + schema: + $ref: '#/definitions/bss.BootParams' + produces: + - application/json + responses: + "200": + description: Updated boot parameters + schema: + $ref: '#/definitions/bss.BootParams' + "400": + description: Invalid request body or ID + schema: + type: string + "404": + description: Boot parameters not found + schema: + type: string + "500": + description: Internal server error + schema: + type: string + summary: Update existing boot parameters + tags: + - boot-params + /bootscript/{id}: + get: + description: Generates a boot script based on component ID and group membership + parameters: + - description: Component ID (optional, defaults to requesting IP's component) + in: path + name: id + type: string + - description: Retry count + in: query + name: retry + type: integer + - description: Architecture + in: query + name: arch + type: string + produces: + - text/plain + responses: + "200": + description: Generated boot script + schema: + type: string + "400": + description: Invalid parameters + schema: + type: string + "404": + description: No templates found + schema: + type: string + "422": + description: Unprocessable entity + schema: + type: string + "500": + description: Internal server error + schema: + type: string + summary: Generate boot script + tags: + - boot-scripts /cloud-init/{group}.yaml: get: description: |- diff --git a/go.mod b/go.mod index 8d74c6b12..7910516a7 100644 --- a/go.mod +++ b/go.mod @@ -4,9 +4,10 @@ go 1.24.0 require ( github.com/OpenCHAMI/jwtauth/v5 v5.0.0-20240321222802-e6cb468a2a18 - github.com/OpenCHAMI/quack v0.0.0 + github.com/OpenCHAMI/quack v0.0.1 github.com/OpenCHAMI/smd/v2 v2.18.0 github.com/go-chi/chi/v5 v5.2.1 + github.com/google/uuid v1.6.0 github.com/lestrrat-go/jwx/v2 v2.1.5 github.com/openchami/chi-middleware/auth v0.0.0-20240812224658-b16b83c70700 github.com/openchami/chi-middleware/log v0.0.0-20240812224658-b16b83c70700 @@ -32,7 +33,7 @@ require ( github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/google/flatbuffers v25.2.10+incompatible // indirect - github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect @@ -49,6 +50,7 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/segmentio/asm v1.2.0 // indirect + github.com/spf13/pflag v1.0.6 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect golang.org/x/mod v0.24.0 // indirect @@ -76,6 +78,7 @@ require ( github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/ryanuber/go-glob v1.0.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect + github.com/spf13/cobra v1.9.1 golang.org/x/crypto v0.37.0 // indirect golang.org/x/net v0.39.0 // indirect golang.org/x/sys v0.32.0 // indirect @@ -83,5 +86,3 @@ require ( golang.org/x/time v0.11.0 // indirect gopkg.in/yaml.v2 v2.4.0 ) - -replace github.com/OpenCHAMI/quack => ../quack diff --git a/go.sum b/go.sum index 47d915190..bb1509a1a 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,8 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/OpenCHAMI/jwtauth/v5 v5.0.0-20240321222802-e6cb468a2a18 h1:oBPtXp9RVm9lk5zTmDLf+Vh21yDHpulBxUqGJQjwQCk= github.com/OpenCHAMI/jwtauth/v5 v5.0.0-20240321222802-e6cb468a2a18/go.mod h1:ggNHWgLfW/WRXcE8ZZC4S7UwHif16HVmyowOCWdNSN8= +github.com/OpenCHAMI/quack v0.0.1 h1:qOXyXskLiqMOZqaDSEchUJPPe1qDuC2wUG3kNyyHeDI= +github.com/OpenCHAMI/quack v0.0.1/go.mod h1:5oWhAgheSMM+h713DIJEQlcT+ooOyYwj3z/1WSWWvyM= github.com/OpenCHAMI/smd/v2 v2.18.0 h1:RZ7lq+zkjZnrnc3McLJANnvoU/T7P8wcCFEukWp0FlQ= github.com/OpenCHAMI/smd/v2 v2.18.0/go.mod h1:RtFmMtTYmMUc74gw4FnfZKDpFMTQJeemAVXB0O+Qaa0= github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= @@ -23,6 +25,7 @@ github.com/apache/thrift v0.21.0/go.mod h1:W1H8aR/QRtYNvrPeFXBtobyRkd0/YVhTc6i07 github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -87,6 +90,8 @@ github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= github.com/hashicorp/vault/api v1.16.0 h1:nbEYGJiAPGzT9U4oWgaaB0g+Rj8E59QuHKyA5LhwQN4= github.com/hashicorp/vault/api v1.16.0/go.mod h1:KhuUhzOD8lDSk29AtzNjgAu2kxRA9jL9NAbkFlqvkBA= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/klauspost/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4= @@ -146,12 +151,17 @@ github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUz github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= diff --git a/internal/memstore/ciMemStore_test.go b/internal/memstore/ciMemStore_test.go new file mode 100644 index 000000000..fde785ef8 --- /dev/null +++ b/internal/memstore/ciMemStore_test.go @@ -0,0 +1,20 @@ +package memstore + +import ( + "testing" + + storetesting "github.com/OpenCHAMI/cloud-init/pkg/cistore/testing" +) + +func TestMemStore(t *testing.T) { + // Create a new MemStore instance + store := NewMemStore() + + // Create a cleanup function that will be called in all cases + cleanup := func() { + // No cleanup needed for MemStore as it's in-memory + } + + // Run the standard test suite + storetesting.RunStoreTests(t, store, cleanup) +} diff --git a/internal/quackstore/quackstore.go b/internal/quackstore/quackstore.go index 0006fc779..07483dc59 100644 --- a/internal/quackstore/quackstore.go +++ b/internal/quackstore/quackstore.go @@ -1,6 +1,7 @@ package quackstore import ( + "crypto/rand" "database/sql" "encoding/json" "fmt" @@ -348,8 +349,10 @@ func (s *QuackStore) Close() error { return s.db.Close() } -// generateInstanceId generates a unique instance ID +// generateInstanceID generates a unique instance ID in the format "i-XXXXXX", +// where "XXXXXX" is a random 6-digit hexadecimal string. func generateInstanceId() string { - // TODO: Implement proper instance ID generation - return "i-1234567890abcdef" + randBytes := make([]byte, 3) + rand.Read(randBytes) + return fmt.Sprintf("i-%x", randBytes) } diff --git a/pkg/bss/bootparams.go b/pkg/bss/bootparams.go new file mode 100644 index 000000000..3f56cd1d9 --- /dev/null +++ b/pkg/bss/bootparams.go @@ -0,0 +1,113 @@ +package bss + +import ( + "encoding/json" + "errors" + "strings" +) + +// BootMethod defines the supported boot methods +type BootMethod string + +// RootFS defines the root filesystem configuration +type RootFS struct { + Type string `json:"type,omitempty"` // Type of root filesystem (nfs, local, etc.) + Server string `json:"server,omitempty"` // Server for network filesystems + Path string `json:"path,omitempty"` // Path to root filesystem + Options string `json:"options,omitempty"` // Mount options +} + +// CloudInitServer defines the cloud-init server configuration +type CloudInitServer struct { + URL string `json:"url,omitempty"` // URL of the cloud-init server + Version string `json:"version,omitempty"` // Version of the cloud-init server +} + +// BootParams (V2) defines the boot parameters for one or more nodes +type BootParams struct { + Version int `json:"version,omitempty"` // Version of the boot parameters + Params string `json:"params,omitempty"` // Kernel boot parameters + Kernel string `json:"kernel,omitempty"` // Kernel image URL/path + Initrd string `json:"initrd,omitempty"` // Initrd image URL/path + RootFS *RootFS `json:"rootfs,omitempty"` // Root filesystem configuration + CloudInit *CloudInitServer `json:"cloud-init,omitempty"` // Cloud-init server configuration + CmdLine string `json:"cmdline,omitempty"` // Command line for the kernel +} + +// BootParamsV1 defines the boot parameters for one or more nodes +// This is the old version that matches the BSS API specification +type BootParamsV1 struct { + Hosts []string `json:"hosts,omitempty"` // List of host xnames + MACs []string `json:"macs,omitempty"` // List of MAC addresses + NIDs []int `json:"nids,omitempty"` // List of Node IDs + Group string `json:"group,omitempty"` // Group name + Params string `json:"params,omitempty"` // Kernel boot parameters + Kernel string `json:"kernel,omitempty"` // Kernel image URL/path + Initrd string `json:"initrd,omitempty"` // Initrd image URL/path + CloudInit *CloudInitServer `json:"cloud-init,omitempty"` // Cloud-init server configuration + ReferralToken string `json:"referral_token,omitempty"` // Referral token +} + +// ParseFromJSON unmarshals a JSON byte array into a BootParams struct +func (b *BootParams) ParseFromJSON(body []byte) error { + if err := json.Unmarshal(body, b); err != nil { + return err + } + + // Validate that kernel and initrd are provided + if b.Initrd == "" || b.Kernel == "" { + return errors.New("kernel and initrd must both be specified") + } + + return nil +} + +// ParseFromJSON unmarshals a JSON byte array into a BootParamsV1 struct +func (b *BootParamsV1) ParseFromJSON(body []byte) error { + if err := json.Unmarshal(body, b); err != nil { + return err + } + + // Validate that at least one of hosts, MACs, or NIDs is provided + if len(b.Hosts) == 0 && len(b.MACs) == 0 && len(b.NIDs) == 0 { + return errors.New("at least one of hosts, MACs, or NIDs must be specified") + } + + // Validate that kernel and initrd are provided + if b.Initrd == "" || b.Kernel == "" { + return errors.New("kernel and initrd must both be specified") + } + + return nil +} + +// ValidateBootParams validates the boot parameters +func (b *BootParams) ValidateBootParams() error { + if b.Kernel == "" || b.Initrd == "" { + return errors.New("kernel and initrd must both be specified") + } + return nil +} + +// MergeBootParams merges multiple boot parameters into a single boot parameter +// It will take the first kernel and initrd that are not empty +// It will also take the first rootfs and cloud-init that are not nil +func MergeBootParams(params []*BootParams) (*BootParams, error) { + merged := &BootParams{} + for _, param := range params { + merged.Params += param.Params + " " + // If the kernel is not set, use the first one that also has an initrd + if merged.Kernel == "" && param.Kernel != "" && param.Initrd != "" { + merged.Kernel = param.Kernel + merged.Initrd = param.Initrd + } + if merged.RootFS == nil && param.RootFS != nil { + merged.RootFS = param.RootFS + } + if merged.CloudInit == nil && param.CloudInit != nil { + merged.CloudInit = param.CloudInit + } + } + merged.Params = strings.TrimSpace(merged.Params) + return merged, nil +} diff --git a/pkg/bss/bootparams_test.go b/pkg/bss/bootparams_test.go new file mode 100644 index 000000000..fd6fde78a --- /dev/null +++ b/pkg/bss/bootparams_test.go @@ -0,0 +1,285 @@ +package bss + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBootParams_ValidateBootParams(t *testing.T) { + tests := []struct { + name string + params *BootParams + wantErr bool + }{ + { + name: "valid boot params", + params: &BootParams{ + Kernel: "http://example.com/kernel", + Initrd: "http://example.com/initrd", + }, + wantErr: false, + }, + { + name: "missing kernel", + params: &BootParams{ + Initrd: "http://example.com/initrd", + }, + wantErr: true, + }, + { + name: "missing initrd", + params: &BootParams{ + Kernel: "http://example.com/kernel", + }, + wantErr: true, + }, + { + name: "empty boot params", + params: &BootParams{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.params.ValidateBootParams() + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestMergeBootParams(t *testing.T) { + tests := []struct { + name string + params []*BootParams + expected *BootParams + wantErr bool + }{ + { + name: "merge basic params", + params: []*BootParams{ + { + Kernel: "http://example.com/kernel1", + Initrd: "http://example.com/initrd1", + Params: "console=ttyS0", + }, + { + Kernel: "http://example.com/kernel2", + Initrd: "http://example.com/initrd2", + Params: "root=/dev/sda1", + }, + }, + expected: &BootParams{ + Kernel: "http://example.com/kernel1", + Initrd: "http://example.com/initrd1", + Params: "console=ttyS0 root=/dev/sda1", + }, + wantErr: false, + }, + { + name: "merge with rootfs", + params: []*BootParams{ + { + Kernel: "http://example.com/kernel", + Initrd: "http://example.com/initrd", + RootFS: &RootFS{ + Type: "nfs", + Server: "10.0.0.1", + Path: "/nfsroot", + Options: "vers=4,ro", + }, + }, + { + Kernel: "http://example.com/kernel2", + Initrd: "http://example.com/initrd2", + Params: "console=ttyS0", + }, + }, + expected: &BootParams{ + Kernel: "http://example.com/kernel", + Initrd: "http://example.com/initrd", + Params: "console=ttyS0", + RootFS: &RootFS{ + Type: "nfs", + Server: "10.0.0.1", + Path: "/nfsroot", + Options: "vers=4,ro", + }, + }, + wantErr: false, + }, + { + name: "merge with cloud-init", + params: []*BootParams{ + { + Kernel: "http://example.com/kernel", + Initrd: "http://example.com/initrd", + CloudInit: &CloudInitServer{ + URL: "http://cloud-init.example.com", + Version: "v1", + }, + }, + { + Kernel: "http://example.com/kernel2", + Initrd: "http://example.com/initrd2", + Params: "console=ttyS0", + }, + }, + expected: &BootParams{ + Kernel: "http://example.com/kernel", + Initrd: "http://example.com/initrd", + Params: "console=ttyS0", + CloudInit: &CloudInitServer{ + URL: "http://cloud-init.example.com", + Version: "v1", + }, + }, + wantErr: false, + }, + { + name: "merge with all components", + params: []*BootParams{ + { + Kernel: "http://example.com/kernel", + Initrd: "http://example.com/initrd", + Params: "console=ttyS0", + RootFS: &RootFS{ + Type: "nfs", + Server: "10.0.0.1", + Path: "/nfsroot", + }, + CloudInit: &CloudInitServer{ + URL: "http://cloud-init.example.com", + Version: "v1", + }, + }, + { + Kernel: "http://example.com/kernel2", + Initrd: "http://example.com/initrd2", + Params: "root=/dev/sda1", + }, + }, + expected: &BootParams{ + Kernel: "http://example.com/kernel", + Initrd: "http://example.com/initrd", + Params: "console=ttyS0 root=/dev/sda1", + RootFS: &RootFS{ + Type: "nfs", + Server: "10.0.0.1", + Path: "/nfsroot", + }, + CloudInit: &CloudInitServer{ + URL: "http://cloud-init.example.com", + Version: "v1", + }, + }, + wantErr: false, + }, + { + name: "merge empty params", + params: []*BootParams{ + {}, + {}, + }, + expected: &BootParams{}, + wantErr: false, + }, + { + name: "merge nil params", + params: nil, + expected: &BootParams{}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + merged, err := MergeBootParams(tt.params) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, tt.expected.Kernel, merged.Kernel) + assert.Equal(t, tt.expected.Initrd, merged.Initrd) + assert.Equal(t, tt.expected.Params, merged.Params) + + if tt.expected.RootFS != nil { + assert.NotNil(t, merged.RootFS) + assert.Equal(t, tt.expected.RootFS.Type, merged.RootFS.Type) + assert.Equal(t, tt.expected.RootFS.Server, merged.RootFS.Server) + assert.Equal(t, tt.expected.RootFS.Path, merged.RootFS.Path) + assert.Equal(t, tt.expected.RootFS.Options, merged.RootFS.Options) + } else { + assert.Nil(t, merged.RootFS) + } + + if tt.expected.CloudInit != nil { + assert.NotNil(t, merged.CloudInit) + assert.Equal(t, tt.expected.CloudInit.URL, merged.CloudInit.URL) + assert.Equal(t, tt.expected.CloudInit.Version, merged.CloudInit.Version) + } else { + assert.Nil(t, merged.CloudInit) + } + }) + } +} + +func TestBootParams_ParseFromJSON(t *testing.T) { + tests := []struct { + name string + json string + wantErr bool + }{ + { + name: "valid json", + json: `{ + "kernel": "http://example.com/kernel", + "initrd": "http://example.com/initrd", + "params": "console=ttyS0" + }`, + wantErr: false, + }, + { + name: "missing kernel", + json: `{ + "initrd": "http://example.com/initrd" + }`, + wantErr: true, + }, + { + name: "missing initrd", + json: `{ + "kernel": "http://example.com/kernel" + }`, + wantErr: true, + }, + { + name: "empty json", + json: `{}`, + wantErr: true, + }, + { + name: "invalid json", + json: `{invalid json}`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + params := &BootParams{} + err := params.ParseFromJSON([]byte(tt.json)) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/pkg/bss/bootscript.go b/pkg/bss/bootscript.go new file mode 100644 index 000000000..b677b0564 --- /dev/null +++ b/pkg/bss/bootscript.go @@ -0,0 +1,88 @@ +package bss + +import ( + "fmt" + "strings" + + "github.com/OpenCHAMI/cloud-init/pkg/cmdline" +) + +func GenerateIPXEScript(params *BootParams, retry int, arch string) (string, error) { + if params.Kernel == "" { + return "", fmt.Errorf("kernel must be specified") + } + if params.Initrd == "" { + return "", fmt.Errorf("initrd must be specified") + } + + var script strings.Builder + script.WriteString("#!ipxe\n") + + // Build kernel command line + kernelCmd := fmt.Sprintf("kernel --name kernel %s", params.Kernel) + + cmd := cmdline.New() + for _, param := range strings.Split(params.Params, " ") { + cmd.AddFlag(param) + } + + // Add root filesystem parameters if specified + if params.RootFS != nil { + switch params.RootFS.Type { + case "nfs": + // NFS root requires network configuration + kernelCmd += " rd.neednet=1" + if params.RootFS.Server != "" && params.RootFS.Path != "" { + cmd.AddParam("root", fmt.Sprintf("nfs://%s:%s", params.RootFS.Server, params.RootFS.Path)) + if params.RootFS.Options != "" { + cmd.AddParam("rootflags", params.RootFS.Options) + } + } + case "local": + // Local root filesystem + if params.RootFS.Path != "" { + cmd.AddParam("root", params.RootFS.Path) + if params.RootFS.Options != "" { + cmd.AddParam("rootflags", params.RootFS.Options) + } + } + } + } + + // Add cloud-init server configuration if specified + if params.CloudInit != nil && params.CloudInit.URL != "" { + if params.CloudInit.Version != "" { + cmd.AddParam("ds", fmt.Sprintf("nocloud-net;s=%s;v=%s", params.CloudInit.URL, params.CloudInit.Version)) + } else { + cmd.AddParam("ds", fmt.Sprintf("nocloud-net;s=%s", params.CloudInit.URL)) + } + } + + script.WriteString(kernelCmd + " " + cmd.String() + " || goto boot_retry\n") + + // Add initrd if specified + if params.Initrd != "" { + script.WriteString(fmt.Sprintf("initrd --name initrd %s || goto boot_retry\n", params.Initrd)) + } + + // Add boot command + script.WriteString("boot || goto boot_retry\n") + + // Add retry section + script.WriteString(":boot_retry\n") + script.WriteString("sleep 30\n") + + // Add retry chain command + retryParams := make([]string, 0) + if retry > 0 { + retryParams = append(retryParams, fmt.Sprintf("retry=%d", retry)) + } + if arch != "" { + retryParams = append(retryParams, fmt.Sprintf("arch=%s", arch)) + } + + script.WriteString(fmt.Sprintf("chain https://api-gw-service-nmn.local/apis/bss/boot/v1/bootscript?%s\n", + strings.Join(retryParams, "&"))) + + return script.String(), nil +} diff --git a/pkg/bss/bootscript_test.go b/pkg/bss/bootscript_test.go new file mode 100644 index 000000000..1372faad9 --- /dev/null +++ b/pkg/bss/bootscript_test.go @@ -0,0 +1,237 @@ +package bss + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGenerateBootScript_Basic(t *testing.T) { + params := &BootParams{ + Kernel: "http://example.com/kernel", + Initrd: "http://example.com/initrd", + Params: "console=ttyS0,115200 root=/dev/sda1", + } + + script, err := GenerateIPXEScript(params, 0, "") + assert.NoError(t, err) + assert.Contains(t, script, "#!ipxe") + assert.Contains(t, script, "kernel --name kernel http://example.com/kernel root=/dev/sda1 console=ttyS0,115200 ") + assert.Contains(t, script, "initrd --name initrd http://example.com/initrd") + assert.Contains(t, script, "boot || goto boot_retry") + assert.Contains(t, script, ":boot_retry") + assert.Contains(t, script, "sleep 30") + assert.Contains(t, script, "chain https://api-gw-service-nmn.local/apis/bss/boot/v1/bootscript") +} + +func TestGenerateBootScript_WithRetry(t *testing.T) { + params := &BootParams{ + Kernel: "http://example.com/kernel", + Initrd: "http://example.com/initrd", + } + + script, err := GenerateIPXEScript(params, 3, "") + assert.NoError(t, err) + assert.Contains(t, script, "retry=3") +} + +func TestGenerateBootScript_WithArch(t *testing.T) { + params := &BootParams{ + Kernel: "http://example.com/kernel", + Initrd: "http://example.com/initrd", + } + + script, err := GenerateIPXEScript(params, 0, "x86_64") + assert.NoError(t, err) + assert.Contains(t, script, "arch=x86_64") +} + +func TestGenerateBootScript_WithOptions(t *testing.T) { + params := &BootParams{ + Kernel: "http://example.com/kernel", + Initrd: "http://example.com/initrd", + Params: "console=ttyS0,115200 root=/dev/sda1", + } + + script, err := GenerateIPXEScript(params, 2, "x86_64") + assert.NoError(t, err) + + // Check all components are present + assert.Contains(t, script, "kernel --name kernel http://example.com/kernel root=/dev/sda1 console=ttyS0,115200") + assert.Contains(t, script, "initrd --name initrd http://example.com/initrd") + assert.Contains(t, script, "boot || goto boot_retry") + assert.Contains(t, script, "retry=2") + assert.Contains(t, script, "arch=x86_64") +} + +func TestGenerateBootScript_ErrorCases(t *testing.T) { + // Test missing kernel + params := &BootParams{ + Params: "console=ttyS0,115200", + } + + _, err := GenerateIPXEScript(params, 0, "") + assert.Error(t, err) + assert.Contains(t, err.Error(), "kernel must be specified") + + // Test empty params + params = &BootParams{} + _, err = GenerateIPXEScript(params, 0, "") + assert.Error(t, err) + assert.Contains(t, err.Error(), "kernel must be specified") + +} + +func TestGenerateBootScript_RetryChainURL(t *testing.T) { + params := &BootParams{ + Kernel: "http://example.com/kernel", + Initrd: "http://example.com/initrd", + } + + script, err := GenerateIPXEScript(params, 1, "x86_64") + assert.NoError(t, err) + + // Verify the retry chain URL is properly formatted + expectedURL := "chain https://api-gw-service-nmn.local/apis/bss/boot/v1/bootscript?retry=1&arch=x86_64" + assert.Contains(t, script, expectedURL) +} + +func TestGenerateBootScript_Ordering(t *testing.T) { + params := &BootParams{ + Kernel: "http://example.com/kernel", + Initrd: "http://example.com/initrd", + Params: "console=ttyS0,115200", + } + + script, err := GenerateIPXEScript(params, 0, "") + assert.NoError(t, err) + + // Split the script into lines to properly check command ordering + lines := strings.Split(script, "\n") + + // Find the line numbers for each command + var kernelLine, initrdLine, bootLine, retryLine int + for i, line := range lines { + switch { + case strings.HasPrefix(line, "kernel"): + kernelLine = i + case strings.HasPrefix(line, "initrd"): + initrdLine = i + case strings.HasPrefix(line, "boot"): + bootLine = i + case strings.HasPrefix(line, ":boot_retry"): + retryLine = i + } + } + + // Verify the order of commands + assert.True(t, kernelLine < initrdLine, "kernel command should come before initrd") + assert.True(t, initrdLine < bootLine, "initrd command should come before boot") + assert.True(t, bootLine < retryLine, "boot command should come before retry section") +} + +func TestGenerateBootScript_WithNFSRoot(t *testing.T) { + params := &BootParams{ + Kernel: "http://example.com/kernel", + Initrd: "http://example.com/initrd", + RootFS: &RootFS{ + Type: "nfs", + Server: "10.0.0.1", + Path: "/nfsroot", + Options: "vers=4,ro", + }, + } + + script, err := GenerateIPXEScript(params, 0, "") + assert.NoError(t, err) + + // Verify NFS root parameters + assert.Contains(t, script, "rd.neednet=1") + assert.Contains(t, script, "root=nfs://10.0.0.1:/nfsroot") + assert.Contains(t, script, "rootflags=vers=4,ro") + + // Verify remote initrd loading + assert.Contains(t, script, "initrd --name initrd http://example.com/initrd") +} + +func TestGenerateBootScript_WithLocalRoot(t *testing.T) { + params := &BootParams{ + Kernel: "http://example.com/kernel", + Initrd: "http://example.com/initrd", + RootFS: &RootFS{ + Type: "local", + Path: "/dev/sda1", + Options: "ro,noatime", + }, + } + + script, err := GenerateIPXEScript(params, 0, "") + assert.NoError(t, err) + + // Verify local root parameters + assert.Contains(t, script, "root=/dev/sda1") + assert.Contains(t, script, "rootflags=ro,noatime") + + // Verify remote initrd loading + assert.Contains(t, script, "initrd --name initrd http://example.com/initrd") +} + +func TestGenerateBootScript_WithRootFSAndParams(t *testing.T) { + params := &BootParams{ + Kernel: "http://example.com/kernel", + Initrd: "http://example.com/initrd", + RootFS: &RootFS{ + Type: "nfs", + Server: "10.0.0.1", + Path: "/nfsroot", + }, + Params: "console=ttyS0,115200", + } + + script, err := GenerateIPXEScript(params, 0, "") + assert.NoError(t, err) + + // Verify both rootfs and additional parameters are included + assert.Contains(t, script, "root=nfs://10.0.0.1:/nfsroot") + assert.Contains(t, script, "console=ttyS0,115200") +} + +func TestGenerateBootScript_WithCloudInit(t *testing.T) { + params := &BootParams{ + Kernel: "http://example.com/kernel", + Initrd: "http://example.com/initrd", + CloudInit: &CloudInitServer{ + URL: "http://cloud-init.example.com", + Version: "v1", + }, + } + + script, err := GenerateIPXEScript(params, 0, "") + assert.NoError(t, err) + + // Verify cloud-init parameters + assert.Contains(t, script, "ds=nocloud-net;s=http://cloud-init.example.com;v=v1") +} + +func TestGenerateBootScript_WithCloudInitAndRootFS(t *testing.T) { + params := &BootParams{ + Kernel: "http://example.com/kernel", + Initrd: "http://example.com/initrd", + RootFS: &RootFS{ + Type: "nfs", + Server: "10.0.0.1", + Path: "/nfsroot", + }, + CloudInit: &CloudInitServer{ + URL: "http://cloud-init.example.com", + }, + } + + script, err := GenerateIPXEScript(params, 0, "") + assert.NoError(t, err) + + // Verify both rootfs and cloud-init parameters + assert.Contains(t, script, "root=nfs://10.0.0.1:/nfsroot") + assert.Contains(t, script, "ds=nocloud-net;s=http://cloud-init.example.com") +} diff --git a/pkg/bss/handlers.go b/pkg/bss/handlers.go new file mode 100644 index 000000000..22a471fec --- /dev/null +++ b/pkg/bss/handlers.go @@ -0,0 +1,279 @@ +package bss + +import ( + "crypto/rand" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strconv" + "strings" + + "github.com/OpenCHAMI/cloud-init/pkg/smdclient" + "github.com/go-chi/chi/v5" +) + +// CreateBootParamsHandler creates an HTTP handler for creating boot parameters +// @Summary Create new boot parameters +// @Description Creates a new set of boot parameters with a unique ID +// @Tags boot-params +// @Accept json +// @Produce json +// @Param bootParams body BootParams true "Boot parameters to create" +// @Success 201 {object} BootParams "Created boot parameters. Location header will be set to the URL of the new boot parameters." +// @Failure 400 {string} string "Invalid request body" +// @Failure 500 {string} string "Internal server error" +// @Router /bootparams [post] +func CreateBootParamsHandler(store Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "Failed to read request body", http.StatusBadRequest) + return + } + + var params BootParams + if err := json.Unmarshal(body, ¶ms); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if err := params.ParseFromJSON(body); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // Generate a unique ID for the boot parameters + id := generateBootParamsID() + + // Create new boot parameters + if err := store.Set(id, ¶ms); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // Get the stored params to include version in response + storedParams, err := store.Get(id) + if err != nil { + http.Error(w, "Failed to retrieve stored parameters", http.StatusInternalServerError) + return + } + + // Set Location header with the new resource URL + w.Header().Set("Location", fmt.Sprintf("/bootparams/%s", id)) + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(storedParams) + } +} + +// UpdateBootParamsHandler creates an HTTP handler for updating boot parameters +// @Summary Update existing boot parameters +// @Description Updates an existing set of boot parameters by ID +// @Tags boot-params +// @Accept json +// @Produce json +// @Param id path string true "Boot parameters ID" +// @Param bootParams body BootParams true "Updated boot parameters" +// @Success 200 {object} BootParams "Updated boot parameters" +// @Failure 400 {string} string "Invalid request body or ID" +// @Failure 404 {string} string "Boot parameters not found" +// @Failure 500 {string} string "Internal server error" +// @Router /bootparams/{id} [put] +func UpdateBootParamsHandler(store Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Get ID from URL parameters + id := chi.URLParam(r, "id") + if id == "" { + http.Error(w, "ID is required in path", http.StatusBadRequest) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "Failed to read request body", http.StatusBadRequest) + return + } + + var params BootParams + if err := json.Unmarshal(body, ¶ms); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if err := params.ParseFromJSON(body); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // Update existing boot parameters + if err := store.Update(id, ¶ms); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // Get the updated params to include version in response + updatedParams, err := store.Get(id) + if err != nil { + http.Error(w, "Failed to retrieve stored parameters", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(updatedParams) + } +} + +// GetBootParamsHandler creates an HTTP handler for retrieving boot parameters +// @Summary Get boot parameters +// @Description Retrieves boot parameters by ID +// @Tags boot-params +// @Produce json +// @Param id path string true "Boot parameters ID" +// @Success 200 {object} BootParams "Boot parameters" +// @Failure 400 {string} string "Invalid ID" +// @Failure 404 {string} string "Boot parameters not found" +// @Router /bootparams/{id} [get] +func GetBootParamsHandler(store Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Get ID from URL parameters + id := chi.URLParam(r, "id") + if id == "" { + http.Error(w, "ID is required in path", http.StatusBadRequest) + return + } + + params, err := store.Get(id) + if err != nil { + http.Error(w, "Boot parameters not found", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(params) + } +} + +// GenerateBootScriptHandler creates an HTTP handler for generating boot scripts +// @Summary Generate boot script +// @Description Generates a boot script based on component ID and group membership +// @Tags boot-scripts +// @Produce text/plain +// @Param id path string false "Component ID (optional, defaults to requesting IP's component)" +// @Param retry query integer false "Retry count" +// @Param arch query string false "Architecture" +// @Success 200 {string} string "Generated boot script" +// @Failure 400 {string} string "Invalid parameters" +// @Failure 404 {string} string "No templates found" +// @Failure 422 {string} string "Unprocessable entity" +// @Failure 500 {string} string "Internal server error" +// @Router /bootscript/{id} [get] +func GenerateBootScriptHandler(store Store, smd smdclient.SMDClientInterface) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var urlId string = chi.URLParam(r, "id") + var id = urlId + var err error + // If this request includes an id, it can be interrpreted as an impersonation request + if urlId == "" { + ip := getActualRequestIP(r) + // Get the component information from the SMD client + id, err = smd.IDfromIP(ip) + if err != nil { + w.WriteHeader(http.StatusUnprocessableEntity) + return + } else { + log.Printf("xname %s with ip %s found\n", id, ip) + } + } + groups, err := smd.GroupMembership(id) + if err != nil { + http.Error(w, "Failed to get group membership for "+id, http.StatusInternalServerError) + return + } + + var templates []*BootParams + for _, group := range groups { + template, err := store.GetTemplateForGroup(group) + if err != nil { + http.Error(w, "Failed to get template for group "+group, http.StatusInternalServerError) + return + } + templates = append(templates, template) + } + + if len(templates) == 0 { + http.Error(w, "No templates found for any groups", http.StatusNotFound) + return + } + + merged, err := MergeBootParams(templates) + if err != nil { + http.Error(w, "Failed to merge boot parameters", http.StatusInternalServerError) + return + } + + retry := 0 + if retryStr := r.URL.Query().Get("retry"); retryStr != "" { + if _, err := strconv.Atoi(retryStr); err != nil { + http.Error(w, "Invalid retry parameter", http.StatusBadRequest) + return + } + } + + arch := r.URL.Query().Get("arch") + + script, err := GenerateIPXEScript(merged, retry, arch) + if err != nil { + http.Error(w, "Failed to generate boot script", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/plain") + w.Write([]byte(script)) + } +} + +// generateBootParamsID generates a Boot Parameters ID in the format "bp-XXXXXX", +// where "XXXXXX" is a random 6-digit hexadecimal string. +func generateBootParamsID() string { + randBytes := make([]byte, 3) + rand.Read(randBytes) + return fmt.Sprintf("bp-%x", randBytes) +} + +func getActualRequestIP(r *http.Request) string { + var ip string + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + // If X-Forwarded-For exists, use the first IP in the list + ip = strings.Split(xff, ",")[0] + } else { + // Otherwise, use the remote address + portIndex := strings.LastIndex(r.RemoteAddr, ":") + if portIndex > 0 { + ip = r.RemoteAddr[:portIndex] + } else { + ip = r.RemoteAddr + } + } + return strings.TrimSpace(ip) +} diff --git a/pkg/bss/handlers_test.go b/pkg/bss/handlers_test.go new file mode 100644 index 000000000..c44d419ea --- /dev/null +++ b/pkg/bss/handlers_test.go @@ -0,0 +1,206 @@ +package bss + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/OpenCHAMI/cloud-init/pkg/smdclient" +) + +func TestCreateBootParamsHandler(t *testing.T) { + store := NewMemoryStore() + handler := CreateBootParamsHandler(store) + + // Test successful creation + params := BootParams{ + Kernel: "vmlinuz", + Initrd: "initrd.img", + Params: "console=ttyS0", + } + body, _ := json.Marshal(params) + req := httptest.NewRequest("POST", "/bootparams", bytes.NewBuffer(body)) + w := httptest.NewRecorder() + handler(w, req) + + if w.Code != http.StatusCreated { + t.Errorf("Expected status %d, got %d", http.StatusCreated, w.Code) + } + + // Verify Location header + location := w.Header().Get("Location") + if location == "" { + t.Error("Expected Location header to be set") + } + + // Test invalid JSON + req = httptest.NewRequest("POST", "/bootparams", bytes.NewBuffer([]byte("invalid json"))) + w = httptest.NewRecorder() + handler(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status %d, got %d", http.StatusBadRequest, w.Code) + } +} + +func TestGetBootParamsHandler(t *testing.T) { + store := NewMemoryStore() + handler := GetBootParamsHandler(store) + + // Create test data + params := BootParams{ + Kernel: "vmlinuz", + Initrd: "initrd.img", + Params: "console=ttyS0", + } + id := "test-id" + store.Set(id, ¶ms) + + // Test successful retrieval + req := httptest.NewRequest("GET", "/bootparams/"+id, nil) + w := httptest.NewRecorder() + handler(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) + } + + // Test missing ID + req = httptest.NewRequest("GET", "/bootparams/", nil) + w = httptest.NewRecorder() + handler(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status %d, got %d", http.StatusBadRequest, w.Code) + } + + // Test non-existent ID + req = httptest.NewRequest("GET", "/bootparams/nonexistent", nil) + w = httptest.NewRecorder() + handler(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("Expected status %d, got %d", http.StatusNotFound, w.Code) + } +} + +func TestUpdateBootParamsHandler(t *testing.T) { + store := NewMemoryStore() + handler := UpdateBootParamsHandler(store) + + // Create test data + params := BootParams{ + Kernel: "vmlinuz", + Initrd: "initrd.img", + Params: "console=ttyS0", + } + id := "test-id" + store.Set(id, ¶ms) + + // Test successful update + updatedParams := BootParams{ + Kernel: "vmlinuz-new", + Initrd: "initrd.img-new", + Params: "console=ttyS0,debug", + } + body, _ := json.Marshal(updatedParams) + req := httptest.NewRequest("PUT", "/bootparams/"+id, bytes.NewBuffer(body)) + w := httptest.NewRecorder() + handler(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) + } + + // Test missing ID + req = httptest.NewRequest("PUT", "/bootparams/", bytes.NewBuffer(body)) + w = httptest.NewRecorder() + handler(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status %d, got %d", http.StatusBadRequest, w.Code) + } + + // Test non-existent ID + req = httptest.NewRequest("PUT", "/bootparams/nonexistent", bytes.NewBuffer(body)) + w = httptest.NewRecorder() + handler(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status %d, got %d", http.StatusBadRequest, w.Code) + } + + // Test invalid JSON + req = httptest.NewRequest("PUT", "/bootparams/"+id, bytes.NewBuffer([]byte("invalid json"))) + w = httptest.NewRecorder() + handler(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status %d, got %d", http.StatusBadRequest, w.Code) + } +} + +func TestGenerateBootScriptHandler(t *testing.T) { + store := NewMemoryStore() + client, err := smdclient.NewSMDClient("http://localhost:8080", "test", "test", "test", "test", false) + if err != nil { + t.Fatalf("Failed to create SMD client: %v", err) + } + handler := GenerateBootScriptHandler(store, client) + + // Create test data + params := BootParams{ + Kernel: "vmlinuz", + Initrd: "initrd.img", + Params: "console=ttyS0", + } + id := "test-id" + store.Set(id, ¶ms) + + // Test successful script generation + req := httptest.NewRequest("GET", "/bootscript/"+id, nil) + w := httptest.NewRecorder() + handler(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) + } + + // Test missing ID + req = httptest.NewRequest("GET", "/bootscript/", nil) + w = httptest.NewRecorder() + handler(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status %d, got %d", http.StatusBadRequest, w.Code) + } + + // Test non-existent ID + req = httptest.NewRequest("GET", "/bootscript/nonexistent", nil) + w = httptest.NewRecorder() + handler(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("Expected status %d, got %d", http.StatusNotFound, w.Code) + } + + // Test with retry parameter + req = httptest.NewRequest("GET", "/bootscript/"+id+"?retry=3", nil) + w = httptest.NewRecorder() + handler(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) + } + + // Test with invalid retry parameter + req = httptest.NewRequest("GET", "/bootscript/"+id+"?retry=invalid", nil) + w = httptest.NewRecorder() + handler(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status %d, got %d", http.StatusBadRequest, w.Code) + } +} diff --git a/pkg/bss/quackstore.go b/pkg/bss/quackstore.go new file mode 100644 index 000000000..c664edfea --- /dev/null +++ b/pkg/bss/quackstore.go @@ -0,0 +1,358 @@ +package bss + +import ( + "database/sql" + "encoding/json" + "fmt" + + "github.com/OpenCHAMI/quack/quack" +) + +// QuackStore implements the Store interface using Quack for persistence +type QuackStore struct { + db *sql.DB +} + +// NewQuackStore creates a new QuackStore instance +func NewQuackStore(dbPath string) (*QuackStore, error) { + storage, err := quack.NewDuckDBStorage(dbPath) + if err != nil { + return nil, fmt.Errorf("failed to open Quack database: %w", err) + } + + store := &QuackStore{ + db: storage.DB(), + } + + // Initialize database schema + if err := store.initializeSchema(); err != nil { + return nil, fmt.Errorf("failed to initialize schema: %w", err) + } + + return store, nil +} + +// initializeSchema creates the necessary tables if they don't exist +func (s *QuackStore) initializeSchema() error { + queries := []string{ + `CREATE TABLE IF NOT EXISTS boot_params ( + id TEXT PRIMARY KEY, + current_version INTEGER, + default_version INTEGER, + versions BLOB + )`, + `CREATE TABLE IF NOT EXISTS v1_boot_params ( + xname TEXT PRIMARY KEY, + data BLOB + )`, + `CREATE TABLE IF NOT EXISTS group_templates ( + group_name TEXT PRIMARY KEY, + param_id TEXT, + version INTEGER, + FOREIGN KEY(param_id) REFERENCES boot_params(id) + )`, + } + + for _, query := range queries { + if _, err := s.db.Exec(query); err != nil { + return fmt.Errorf("failed to execute query %q: %w", query, err) + } + } + return nil +} + +// Set stores boot parameters with the given ID +func (s *QuackStore) Set(id string, params *BootParams) error { + // Check if boot parameters exist + var exists bool + err := s.db.QueryRow("SELECT EXISTS(SELECT 1 FROM boot_params WHERE id = ?)", id).Scan(&exists) + if err != nil { + return fmt.Errorf("failed to check boot parameters existence: %w", err) + } + if exists { + return fmt.Errorf("boot parameters with ID '%s' already exist", id) + } + + // Initialize versioned boot parameters + versioned := &VersionedBootParams{ + CurrentVersion: 1, + DefaultVersion: 1, + Versions: []BootParams{*params}, + } + + // Set initial version + versioned.Versions[0].Version = 1 + + // Marshal the versioned boot parameters + data, err := json.Marshal(versioned) + if err != nil { + return fmt.Errorf("failed to marshal boot parameters: %w", err) + } + + // Store in database + _, err = s.db.Exec("INSERT INTO boot_params (id, current_version, default_version, versions) VALUES (?, ?, ?, ?)", + id, versioned.CurrentVersion, versioned.DefaultVersion, data) + if err != nil { + return fmt.Errorf("failed to insert boot parameters: %w", err) + } + + return nil +} + +// Get retrieves the latest version of boot parameters by ID +func (s *QuackStore) Get(id string) (*BootParams, error) { + var currentVersion int + var data []byte + err := s.db.QueryRow("SELECT current_version, versions FROM boot_params WHERE id = ?", id).Scan(¤tVersion, &data) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("boot parameters not found") + } + if err != nil { + return nil, fmt.Errorf("failed to query boot parameters: %w", err) + } + + var versioned VersionedBootParams + if err := json.Unmarshal(data, &versioned); err != nil { + return nil, fmt.Errorf("failed to unmarshal boot parameters: %w", err) + } + + if len(versioned.Versions) == 0 { + return nil, fmt.Errorf("no versions available") + } + + // Return a copy of the current version + current := versioned.Versions[currentVersion-1] + return ¤t, nil +} + +// GetVersion retrieves a specific version of boot parameters by ID +func (s *QuackStore) GetVersion(id string, version int) (*BootParams, error) { + var data []byte + err := s.db.QueryRow("SELECT versions FROM boot_params WHERE id = ?", id).Scan(&data) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("boot parameters not found") + } + if err != nil { + return nil, fmt.Errorf("failed to query boot parameters: %w", err) + } + + var versioned VersionedBootParams + if err := json.Unmarshal(data, &versioned); err != nil { + return nil, fmt.Errorf("failed to unmarshal boot parameters: %w", err) + } + + if version < 1 || version > len(versioned.Versions) { + return nil, fmt.Errorf("invalid version number") + } + + // Return a copy of the requested version + params := versioned.Versions[version-1] + return ¶ms, nil +} + +// GetDefault retrieves the default version of boot parameters by ID +func (s *QuackStore) GetDefault(id string) (*BootParams, error) { + var defaultVersion int + var data []byte + err := s.db.QueryRow("SELECT default_version, versions FROM boot_params WHERE id = ?", id).Scan(&defaultVersion, &data) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("boot parameters not found") + } + if err != nil { + return nil, fmt.Errorf("failed to query boot parameters: %w", err) + } + + var versioned VersionedBootParams + if err := json.Unmarshal(data, &versioned); err != nil { + return nil, fmt.Errorf("failed to unmarshal boot parameters: %w", err) + } + + if defaultVersion < 1 || defaultVersion > len(versioned.Versions) { + return nil, fmt.Errorf("invalid default version") + } + + // Return a copy of the default version + params := versioned.Versions[defaultVersion-1] + return ¶ms, nil +} + +// Update stores a new version of existing boot parameters +func (s *QuackStore) Update(id string, params *BootParams) error { + var currentVersion int + var data []byte + err := s.db.QueryRow("SELECT current_version, versions FROM boot_params WHERE id = ?", id).Scan(¤tVersion, &data) + if err == sql.ErrNoRows { + return fmt.Errorf("boot parameters not found") + } + if err != nil { + return fmt.Errorf("failed to query boot parameters: %w", err) + } + + var versioned VersionedBootParams + if err := json.Unmarshal(data, &versioned); err != nil { + return fmt.Errorf("failed to unmarshal boot parameters: %w", err) + } + + // Create new version + newVersion := *params + newVersion.Version = len(versioned.Versions) + 1 + versioned.Versions = append(versioned.Versions, newVersion) + versioned.CurrentVersion = newVersion.Version + + // Marshal the updated versioned boot parameters + updatedData, err := json.Marshal(versioned) + if err != nil { + return fmt.Errorf("failed to marshal boot parameters: %w", err) + } + + // Update in database + _, err = s.db.Exec("UPDATE boot_params SET current_version = ?, versions = ? WHERE id = ?", + versioned.CurrentVersion, updatedData, id) + if err != nil { + return fmt.Errorf("failed to update boot parameters: %w", err) + } + + return nil +} + +// SetDefault sets the default version for a boot parameter set +func (s *QuackStore) SetDefault(id string, version int) error { + var data []byte + err := s.db.QueryRow("SELECT versions FROM boot_params WHERE id = ?", id).Scan(&data) + if err == sql.ErrNoRows { + return fmt.Errorf("boot parameters not found") + } + if err != nil { + return fmt.Errorf("failed to query boot parameters: %w", err) + } + + var versioned VersionedBootParams + if err := json.Unmarshal(data, &versioned); err != nil { + return fmt.Errorf("failed to unmarshal boot parameters: %w", err) + } + + if version < 1 || version > len(versioned.Versions) { + return fmt.Errorf("invalid version number") + } + + // Update default version in database + _, err = s.db.Exec("UPDATE boot_params SET default_version = ? WHERE id = ?", version, id) + if err != nil { + return fmt.Errorf("failed to update default version: %w", err) + } + + return nil +} + +// Delete deletes all versions of boot parameters by ID +func (s *QuackStore) Delete(id string) error { + // Start a transaction to ensure atomicity + tx, err := s.db.Begin() + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() + + // Delete from boot_params table + _, err = tx.Exec("DELETE FROM boot_params WHERE id = ?", id) + if err != nil { + return fmt.Errorf("failed to delete boot parameters: %w", err) + } + + // Delete from group_templates table + _, err = tx.Exec("DELETE FROM group_templates WHERE param_id = ?", id) + if err != nil { + return fmt.Errorf("failed to delete group templates: %w", err) + } + + // Commit the transaction + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit transaction: %w", err) + } + + return nil +} + +// GetV1 retrieves the latest version of V1 boot parameters by xname +func (s *QuackStore) GetV1(xname string) (*BootParamsV1, error) { + var data []byte + err := s.db.QueryRow("SELECT data FROM v1_boot_params WHERE xname = ?", xname).Scan(&data) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("boot parameters not found") + } + if err != nil { + return nil, fmt.Errorf("failed to query boot parameters: %w", err) + } + + var params BootParamsV1 + if err := json.Unmarshal(data, ¶ms); err != nil { + return nil, fmt.Errorf("failed to unmarshal boot parameters: %w", err) + } + + return ¶ms, nil +} + +// SetV1 stores V1 boot parameters by xname +func (s *QuackStore) SetV1(xname string, params *BootParamsV1) error { + data, err := json.Marshal(params) + if err != nil { + return fmt.Errorf("failed to marshal boot parameters: %w", err) + } + + _, err = s.db.Exec("INSERT OR REPLACE INTO v1_boot_params (xname, data) VALUES (?, ?)", xname, data) + if err != nil { + return fmt.Errorf("failed to store boot parameters: %w", err) + } + + return nil +} + +// AssignTemplateToGroup assigns a versioned template to a group +func (s *QuackStore) AssignTemplateToGroup(paramId, group string, version int) error { + // Check if boot parameters exist + var exists bool + err := s.db.QueryRow("SELECT EXISTS(SELECT 1 FROM boot_params WHERE id = ?)", paramId).Scan(&exists) + if err != nil { + return fmt.Errorf("failed to check boot parameters existence: %w", err) + } + if !exists { + return fmt.Errorf("boot parameters not found") + } + + // If version is 0, get the default version + if version == 0 { + err = s.db.QueryRow("SELECT default_version FROM boot_params WHERE id = ?", paramId).Scan(&version) + if err != nil { + return fmt.Errorf("failed to get default version: %w", err) + } + } + + // Store the template assignment + _, err = s.db.Exec("INSERT OR REPLACE INTO group_templates (group_name, param_id, version) VALUES (?, ?, ?)", + group, paramId, version) + if err != nil { + return fmt.Errorf("failed to assign template to group: %w", err) + } + + return nil +} + +// GetTemplateForGroup retrieves the template for a group +func (s *QuackStore) GetTemplateForGroup(group string) (*BootParams, error) { + var paramId string + var version int + err := s.db.QueryRow("SELECT param_id, version FROM group_templates WHERE group_name = ?", group).Scan(¶mId, &version) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("template not found") + } + if err != nil { + return nil, fmt.Errorf("failed to query template: %w", err) + } + + return s.GetVersion(paramId, version) +} + +// Close closes the database connection +func (s *QuackStore) Close() error { + return s.db.Close() +} diff --git a/pkg/bss/quackstore_test.go b/pkg/bss/quackstore_test.go new file mode 100644 index 000000000..9000b8f50 --- /dev/null +++ b/pkg/bss/quackstore_test.go @@ -0,0 +1,183 @@ +package bss + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestQuackStore(t *testing.T) { + // Create a temporary database file + dbPath := "test_quackstore.db" + defer os.Remove(dbPath) + + // Create a new QuackStore instance + store, err := NewQuackStore(dbPath) + assert.NoError(t, err) + defer store.Close() + + // Run the standard store tests + testStoreImplementation(t, store) +} + +func TestQuackStoreV1Operations(t *testing.T) { + // Create a temporary database file + dbPath := "test_quackstore_v1.db" + defer os.Remove(dbPath) + + // Create a new QuackStore instance + store, err := NewQuackStore(dbPath) + assert.NoError(t, err) + defer store.Close() + + // Test V1 boot parameters + v1Params := &BootParamsV1{ + Kernel: "http://example.com/kernel", + Initrd: "http://example.com/initrd", + Params: "console=ttyS0", + } + + // Test setting V1 parameters + err = store.SetV1("x1000c0s0b0n0", v1Params) + assert.NoError(t, err) + + // Test getting V1 parameters + retrieved, err := store.GetV1("x1000c0s0b0n0") + assert.NoError(t, err) + assert.Equal(t, v1Params.Kernel, retrieved.Kernel) + assert.Equal(t, v1Params.Initrd, retrieved.Initrd) + assert.Equal(t, v1Params.Params, retrieved.Params) + + // Test getting non-existent V1 parameters + _, err = store.GetV1("nonexistent") + assert.Error(t, err) +} + +func TestQuackStoreGroupTemplateOperations(t *testing.T) { + // Create a temporary database file + dbPath := "test_quackstore_templates.db" + defer os.Remove(dbPath) + + // Create a new QuackStore instance + store, err := NewQuackStore(dbPath) + assert.NoError(t, err) + defer store.Close() + + // Create a boot parameter set + params := &BootParams{ + Kernel: "http://example.com/kernel", + Initrd: "http://example.com/initrd", + Params: "console=ttyS0", + } + + // Store the boot parameters + err = store.Set("test-template", params) + assert.NoError(t, err) + + // Test assigning template to group + err = store.AssignTemplateToGroup("test-template", "test-group", 1) + assert.NoError(t, err) + + // Test getting template for group + template, err := store.GetTemplateForGroup("test-group") + assert.NoError(t, err) + assert.Equal(t, params.Kernel, template.Kernel) + assert.Equal(t, params.Initrd, template.Initrd) + assert.Equal(t, params.Params, template.Params) + + // Test getting template for non-existent group + _, err = store.GetTemplateForGroup("nonexistent") + assert.Error(t, err) + + // Test assigning template to group with non-existent boot parameters + err = store.AssignTemplateToGroup("nonexistent", "test-group", 1) + assert.Error(t, err) + + // Test assigning template to group with default version + err = store.AssignTemplateToGroup("test-template", "test-group-default", 0) + assert.NoError(t, err) + + // Test getting template for group with default version + template, err = store.GetTemplateForGroup("test-group-default") + assert.NoError(t, err) + assert.Equal(t, params.Kernel, template.Kernel) + assert.Equal(t, params.Initrd, template.Initrd) + assert.Equal(t, params.Params, template.Params) +} + +func TestQuackStoreVersioning(t *testing.T) { + // Create a temporary database file + dbPath := "test_quackstore_versioning.db" + defer os.Remove(dbPath) + + // Create a new QuackStore instance + store, err := NewQuackStore(dbPath) + assert.NoError(t, err) + defer store.Close() + + // Create initial boot parameters + initialParams := &BootParams{ + Kernel: "http://example.com/kernel", + Initrd: "http://example.com/initrd", + Params: "console=ttyS0", + } + + // Store the initial boot parameters + err = store.Set("test-versioning", initialParams) + assert.NoError(t, err) + + // Update the boot parameters + updatedParams := &BootParams{ + Kernel: "http://example.com/new-kernel", + Initrd: "http://example.com/new-initrd", + Params: "console=ttyS0,debug", + } + + err = store.Update("test-versioning", updatedParams) + assert.NoError(t, err) + + // Test getting the latest version + latest, err := store.Get("test-versioning") + assert.NoError(t, err) + assert.Equal(t, updatedParams.Kernel, latest.Kernel) + assert.Equal(t, updatedParams.Initrd, latest.Initrd) + assert.Equal(t, updatedParams.Params, latest.Params) + assert.Equal(t, 2, latest.Version) + + // Test getting version 1 + v1, err := store.GetVersion("test-versioning", 1) + assert.NoError(t, err) + assert.Equal(t, initialParams.Kernel, v1.Kernel) + assert.Equal(t, initialParams.Initrd, v1.Initrd) + assert.Equal(t, initialParams.Params, v1.Params) + assert.Equal(t, 1, v1.Version) + + // Test getting version 2 + v2, err := store.GetVersion("test-versioning", 2) + assert.NoError(t, err) + assert.Equal(t, updatedParams.Kernel, v2.Kernel) + assert.Equal(t, updatedParams.Initrd, v2.Initrd) + assert.Equal(t, updatedParams.Params, v2.Params) + assert.Equal(t, 2, v2.Version) + + // Test setting default version + err = store.SetDefault("test-versioning", 1) + assert.NoError(t, err) + + // Test getting default version + defaultParams, err := store.GetDefault("test-versioning") + assert.NoError(t, err) + assert.Equal(t, initialParams.Kernel, defaultParams.Kernel) + assert.Equal(t, initialParams.Initrd, defaultParams.Initrd) + assert.Equal(t, initialParams.Params, defaultParams.Params) + assert.Equal(t, 1, defaultParams.Version) + + // Test setting invalid default version + err = store.SetDefault("test-versioning", 3) + assert.Error(t, err) + + // Test getting invalid version + _, err = store.GetVersion("test-versioning", 3) + assert.Error(t, err) +} diff --git a/pkg/bss/router.go b/pkg/bss/router.go new file mode 100644 index 000000000..ca5215c20 --- /dev/null +++ b/pkg/bss/router.go @@ -0,0 +1,32 @@ +package bss + +import ( + "github.com/OpenCHAMI/cloud-init/pkg/smdclient" + "github.com/go-chi/chi/v5" +) + +// NewBSSRouter creates a new router with all the BSS endpoints +// This is an example router which will work in some circumstances. +// It is not intended to be used in production. +func NewBSSRouter(store Store, smd *smdclient.SMDClient) chi.Router { + r := chi.NewRouter() + r.Route("/boot/v1", func(r chi.Router) { + + r.Put("/bootparameters", V1AddBootParamsHandler(store, smd)) + r.Post("/bootparameters", V1AddBootParamsHandler(store, smd)) + + // Boot script endpoint + r.Get("/bootscript", GenerateBootScriptHandler(store, smd)) + }) + r.Route("/boot/v2", func(r chi.Router) { + // Boot parameters endpoints + r.Post("/bootparams", CreateBootParamsHandler(store)) + r.Get("/bootparams/{id}", GetBootParamsHandler(store)) + r.Put("/bootparams/{id}", UpdateBootParamsHandler(store)) + r.Get("/bootparams/{id}", GetBootParamsHandler(store)) + // Boot script endpoint + r.Get("/bootscript", GenerateBootScriptHandler(store, smd)) + }) + + return r +} diff --git a/pkg/bss/store.go b/pkg/bss/store.go new file mode 100644 index 000000000..c9574a7d8 --- /dev/null +++ b/pkg/bss/store.go @@ -0,0 +1,237 @@ +package bss + +import ( + "errors" + "sync" +) + +// VersionedBootParams represents a versioned set of boot parameters +type VersionedBootParams struct { + CurrentVersion int `json:"current_version"` // Current active version + DefaultVersion int `json:"default_version"` // Default version to use + Versions []BootParams `json:"versions"` // History of all versions +} + +// Store defines the interface for storing and retrieving boot parameters +type Store interface { + // Set stores boot parameters with the given ID and creates a new version + Set(id string, params *BootParams) error + // Get retrieves the latest version of boot parameters by ID + Get(id string) (*BootParams, error) + // GetV1 retrieves the latest version of V1 boot parameters by xname + GetV1(xname string) (*BootParamsV1, error) + // SetV1 stores V1 boot parameters by xname + SetV1(xname string, params *BootParamsV1) error + // GetVersion retrieves a specific version of boot parameters by ID + GetVersion(id string, version int) (*BootParams, error) + // GetDefault retrieves the default version of boot parameters by ID + GetDefault(id string) (*BootParams, error) + // Update stores a new version of existing boot parameters + Update(id string, params *BootParams) error + // SetDefault sets the default version for a boot parameter set + SetDefault(id string, version int) error + // Delete deletes all versions of boot parameters by ID + Delete(id string) error + // AssignTemplateToGroup assigns a versioned template to a group. Passing 0 for version will assign the latest version. + AssignTemplateToGroup(paramId, group string, version int) error + // GetTemplateForGroup retrieves the template for a group + GetTemplateForGroup(group string) (*BootParams, error) +} + +// MemoryStore is an in-memory implementation of the Store interface +type MemoryStore struct { + mu sync.RWMutex + params map[string]*VersionedBootParams + v1 map[string]*BootParamsV1 // maps an xname to a boot params v1 struct + v2 map[string]struct { + BootParams *VersionedBootParams + Version int + } +} + +// NewMemoryStore creates a new MemoryStore +func NewMemoryStore() *MemoryStore { + return &MemoryStore{ + params: make(map[string]*VersionedBootParams), + v1: make(map[string]*BootParamsV1), + } +} + +// Set stores boot parameters with the given ID +func (s *MemoryStore) Set(id string, params *BootParams) error { + s.mu.Lock() + defer s.mu.Unlock() + + if _, exists := s.params[id]; exists { + return errors.New("boot parameters with this ID already exist") + } + + // Initialize versioned boot parameters + versioned := &VersionedBootParams{ + CurrentVersion: 1, + DefaultVersion: 1, + Versions: []BootParams{*params}, + } + + // Set initial version + versioned.Versions[0].Version = 1 + s.params[id] = versioned + return nil +} + +// Get retrieves the latest version of boot parameters by ID +func (s *MemoryStore) Get(id string) (*BootParams, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + versioned, exists := s.params[id] + if !exists { + return nil, errors.New("boot parameters not found") + } + + if len(versioned.Versions) == 0 { + return nil, errors.New("no versions available") + } + + // Return a copy of the current version + current := versioned.Versions[versioned.CurrentVersion-1] + return ¤t, nil +} + +// GetVersion retrieves a specific version of boot parameters by ID +func (s *MemoryStore) GetVersion(id string, version int) (*BootParams, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + versioned, exists := s.params[id] + if !exists { + return nil, errors.New("boot parameters not found") + } + + if version < 1 || version > len(versioned.Versions) { + return nil, errors.New("invalid version number") + } + + // Return a copy of the requested version + params := versioned.Versions[version-1] + return ¶ms, nil +} + +// GetDefault retrieves the default version of boot parameters by ID +func (s *MemoryStore) GetDefault(id string) (*BootParams, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + versioned, exists := s.params[id] + if !exists { + return nil, errors.New("boot parameters not found") + } + + if versioned.DefaultVersion < 1 || versioned.DefaultVersion > len(versioned.Versions) { + return nil, errors.New("invalid default version") + } + + // Return a copy of the default version + params := versioned.Versions[versioned.DefaultVersion-1] + return ¶ms, nil +} + +// Update updates existing boot parameters and creates a new version +func (s *MemoryStore) Update(id string, params *BootParams) error { + s.mu.Lock() + defer s.mu.Unlock() + + versioned, exists := s.params[id] + if !exists { + return errors.New("boot parameters not found") + } + + // Create new version + newVersion := *params + newVersion.Version = len(versioned.Versions) + 1 + versioned.Versions = append(versioned.Versions, newVersion) + versioned.CurrentVersion = newVersion.Version + + return nil +} + +// SetDefault sets the default version for a boot parameter set +func (s *MemoryStore) SetDefault(id string, version int) error { + s.mu.Lock() + defer s.mu.Unlock() + + versioned, exists := s.params[id] + if !exists { + return errors.New("boot parameters not found") + } + + if version < 1 || version > len(versioned.Versions) { + return errors.New("invalid version number") + } + + versioned.DefaultVersion = version + return nil +} + +// Delete deletes all versions of boot parameters by ID +func (s *MemoryStore) Delete(id string) error { + s.mu.Lock() + defer s.mu.Unlock() + + delete(s.params, id) + return nil +} + +// GetV1 retrieves the latest version of V1 boot parameters by xname +func (s *MemoryStore) GetV1(xname string) (*BootParamsV1, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + params, exists := s.v1[xname] + if !exists { + return nil, errors.New("boot parameters not found") + } + + return params, nil +} + +// SetV1 stores V1 boot parameters by xname +func (s *MemoryStore) SetV1(xname string, params *BootParamsV1) error { + s.mu.Lock() + defer s.mu.Unlock() + + s.v1[xname] = params + return nil +} + +// AssignTemplateToGroup assigns a versioned template to a group. Passing 0 for version will assign the latest version. +func (s *MemoryStore) AssignTemplateToGroup(paramId, group string, version int) error { + s.mu.Lock() + defer s.mu.Unlock() + + if version == 0 { + version = s.params[paramId].DefaultVersion + } + + s.v2[group] = struct { + BootParams *VersionedBootParams + Version int + }{ + BootParams: s.params[paramId], + Version: version, + } + return nil +} + +// GetTemplateForGroup retrieves the template for a group +func (s *MemoryStore) GetTemplateForGroup(group string) (*BootParams, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + template, exists := s.v2[group] + if !exists { + return nil, errors.New("template not found") + } + + return &template.BootParams.Versions[template.Version], nil +} diff --git a/pkg/bss/store_test.go b/pkg/bss/store_test.go new file mode 100644 index 000000000..d534a6ff6 --- /dev/null +++ b/pkg/bss/store_test.go @@ -0,0 +1,129 @@ +package bss + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// testStoreImplementation is a helper function that runs all store tests against a given Store implementation +func testStoreImplementation(t *testing.T, store Store) { + // Test initial store state + params, err := store.Get("test") + assert.Error(t, err) + assert.Nil(t, params) + + // Test storing new parameters + testParams := &BootParams{ + Kernel: "http://example.com/kernel", + Initrd: "http://example.com/initrd", + } + + err = store.Set("test", testParams) + assert.NoError(t, err) + + // Test retrieving stored parameters + params, err = store.Get("test") + assert.NoError(t, err) + assert.Equal(t, testParams.Kernel, params.Kernel) + assert.Equal(t, testParams.Initrd, params.Initrd) + assert.Equal(t, 1, params.Version) + + // Test getting version 1 explicitly + params, err = store.GetVersion("test", 1) + assert.NoError(t, err) + assert.Equal(t, testParams.Kernel, params.Kernel) + assert.Equal(t, testParams.Initrd, params.Initrd) + assert.Equal(t, 1, params.Version) + + // Test getting default version (should be 1 initially) + params, err = store.GetDefault("test") + assert.NoError(t, err) + assert.Equal(t, testParams.Kernel, params.Kernel) + assert.Equal(t, testParams.Initrd, params.Initrd) + assert.Equal(t, 1, params.Version) + + // Test storing with existing ID + err = store.Set("test", testParams) + assert.Error(t, err) + + // Test updating parameters + updatedParams := &BootParams{ + Kernel: "http://example.com/new-kernel", + Initrd: "http://example.com/new-initrd", + } + + err = store.Update("test", updatedParams) + assert.NoError(t, err) + + // Test retrieving updated parameters (should get version 2) + params, err = store.Get("test") + assert.NoError(t, err) + assert.Equal(t, updatedParams.Kernel, params.Kernel) + assert.Equal(t, updatedParams.Initrd, params.Initrd) + assert.Equal(t, 2, params.Version) + + // Test getting version 1 is still available + params, err = store.GetVersion("test", 1) + assert.NoError(t, err) + assert.Equal(t, testParams.Kernel, params.Kernel) + assert.Equal(t, testParams.Initrd, params.Initrd) + assert.Equal(t, 1, params.Version) + + // Test getting version 2 explicitly + params, err = store.GetVersion("test", 2) + assert.NoError(t, err) + assert.Equal(t, updatedParams.Kernel, params.Kernel) + assert.Equal(t, updatedParams.Initrd, params.Initrd) + assert.Equal(t, 2, params.Version) + + // Test setting default version + err = store.SetDefault("test", 1) + assert.NoError(t, err) + + // Test getting default version (should now be 1) + params, err = store.GetDefault("test") + assert.NoError(t, err) + assert.Equal(t, testParams.Kernel, params.Kernel) + assert.Equal(t, testParams.Initrd, params.Initrd) + assert.Equal(t, 1, params.Version) + + // Test setting invalid default version + err = store.SetDefault("test", 3) + assert.Error(t, err) + + // Test getting invalid version + params, err = store.GetVersion("test", 3) + assert.Error(t, err) + assert.Nil(t, params) + + // Test updating non-existent parameters + err = store.Update("nonexistent", updatedParams) + assert.Error(t, err) + + // Test getting non-existent version + params, err = store.GetVersion("nonexistent", 1) + assert.Error(t, err) + assert.Nil(t, params) + + // Test getting default version of non-existent parameters + params, err = store.GetDefault("nonexistent") + assert.Error(t, err) + assert.Nil(t, params) + + // Test setting default version of non-existent parameters + err = store.SetDefault("nonexistent", 1) + assert.Error(t, err) +} + +// TestMemoryStore runs the store tests against the MemoryStore implementation +func TestMemoryStore(t *testing.T) { + store := NewMemoryStore() + testStoreImplementation(t, store) +} + +// TestFileStore runs the store tests against the FileStore implementation +// func TestFileStore(t *testing.T) { +// store := NewFileStore() +// testStoreImplementation(t, store) +// } diff --git a/pkg/bss/v1handlers.go b/pkg/bss/v1handlers.go new file mode 100644 index 000000000..faafd9bf2 --- /dev/null +++ b/pkg/bss/v1handlers.go @@ -0,0 +1,67 @@ +package bss + +import ( + "encoding/json" + "net/http" + + "github.com/OpenCHAMI/cloud-init/pkg/smdclient" + "github.com/google/uuid" +) + +// V1AddBootParamsHandler adds a new boot params to the store +// If a list of nodes is provided, we need to store this the old way +// by normalizing the list of nodes to a list of ids and storing the same data for each node +// If a group is provided, we can store it the new way +func V1AddBootParamsHandler(store Store, smd *smdclient.SMDClient) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // convert request body to V1BootParams + var v1BootParams BootParamsV1 + err := json.NewDecoder(r.Body).Decode(&v1BootParams) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + //normalize the list of nodes to a list of ids (xnames) + var xnames []string + var badmacs []string + if v1BootParams.Hosts != nil { + xnames = append(xnames, v1BootParams.Hosts...) + } + if v1BootParams.MACs != nil { + for _, mac := range v1BootParams.MACs { + xname, err := smd.IDfromMAC(mac) + if err != nil { + badmacs = append(badmacs, mac) + } else { + xnames = append(xnames, xname) + } + } + } + if v1BootParams.NIDs != nil { + http.Error(w, "NIDs not supported", http.StatusBadRequest) + return + } + if len(xnames) != 0 { + for _, xname := range xnames { + // We need a referral token for each node + // Generating one for now, but we should use a better mechanism in the future + referralToken := uuid.New().String() + v1BootParams.ReferralToken = referralToken + store.SetV1(xname, &v1BootParams) + } + } + // return 201 created with a BSS-Referral-Token header + referralToken := uuid.New().String() + w.Header().Set("BSS-Referral-Token", referralToken) + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode( + struct { + Hosts []string `json:"hosts"` + BadMACs []string `json:"bad_macs"` + }{ + Hosts: xnames, + BadMACs: badmacs, + }, + ) + } +} diff --git a/pkg/cistore/models.go b/pkg/cistore/models.go index aabf70527..0738f8b28 100644 --- a/pkg/cistore/models.go +++ b/pkg/cistore/models.go @@ -1,6 +1,7 @@ package cistore import ( + "encoding/base64" "encoding/json" "errors" "fmt" @@ -73,7 +74,7 @@ type CloudConfigFile struct { Encoding string `json:"encoding,omitempty" yaml:"encoding,omitempty" enums:"base64,plain"` } -// Custom JSON unmarshaler for CloudConfigFile +// UnmarshalJSON implements json.Unmarshaler func (f *CloudConfigFile) UnmarshalJSON(data []byte) error { // Use an auxiliary struct so that: // @@ -91,11 +92,25 @@ func (f *CloudConfigFile) UnmarshalJSON(data []byte) error { Alias: (*Alias)(f), } + // Unmarshal into the helper struct if err := json.Unmarshal(data, &aux); err != nil { return err } - f.Content = []byte(aux.Content) + // Handle encoding + switch aux.Encoding { + case "base64": + decoded, err := base64.StdEncoding.DecodeString(aux.Content) + if err != nil { + return fmt.Errorf("failed to decode base64 content: %w", err) + } + f.Content = decoded + case "plain": + f.Content = []byte(aux.Content) + default: + return fmt.Errorf("unsupported encoding: %s", aux.Encoding) + } + return nil } @@ -164,7 +179,6 @@ func (f CloudConfigFile) MarshalJSON() ([]byte, error) { Alias: (Alias)(f), } - aux.Content = string(f.Content) return json.Marshal(aux) } diff --git a/pkg/cmdline/main.go b/pkg/cmdline/main.go new file mode 100644 index 000000000..4934f04a3 --- /dev/null +++ b/pkg/cmdline/main.go @@ -0,0 +1,121 @@ +package cmdline + +import ( + "errors" + "fmt" + "sort" + "strings" +) + +// CommandLine represents a kernel command line builder +// that enforces uniqueness, correct syntax, and key ordering. +type CommandLine struct { + params map[string]string + flags map[string]struct{} + order []string // track insertion order for special keys +} + +// New creates a new CommandLine instance. +func New() *CommandLine { + return &CommandLine{ + params: make(map[string]string), + flags: make(map[string]struct{}), + order: []string{}, + } +} + +// AddParam adds a key=value parameter. +func (c *CommandLine) AddParam(key, value string) error { + if err := validateKey(key); err != nil { + return err + } + if err := validateValue(value); err != nil { + return err + } + if _, exists := c.params[key]; exists { + return fmt.Errorf("parameter '%s' already exists", key) + } + c.params[key] = value + if key == "initrd" || key == "root" { + c.order = append([]string{key}, c.order...) + } else { + c.order = append(c.order, key) + } + return nil +} + +// AddFlag adds a flag (e.g., 'quiet', 'nomodeset') without value. +// If the flag contains an '=', it is treated as a key=value param. +func (c *CommandLine) AddFlag(flag string) error { + if strings.Contains(flag, "=") { + parts := strings.SplitN(flag, "=", 2) + if len(parts) != 2 { + return fmt.Errorf("invalid parameter format in flag: '%s'", flag) + } + return c.AddParam(parts[0], parts[1]) + } + + if err := validateKey(flag); err != nil { + return err + } + if _, exists := c.flags[flag]; exists { + return fmt.Errorf("flag '%s' already exists", flag) + } + c.flags[flag] = struct{}{} + return nil +} + +// String renders the full command line as a string. +func (c *CommandLine) String() string { + var entries []string + + // First, add initrd and root in order if they exist + if v, ok := c.params["initrd"]; ok { + entries = append(entries, fmt.Sprintf("initrd=%s", v)) + } + if v, ok := c.params["root"]; ok { + entries = append(entries, fmt.Sprintf("root=%s", v)) + } + + // Then add all flags in alphabetical order + flags := make([]string, 0, len(c.flags)) + for flag := range c.flags { + flags = append(flags, flag) + } + sort.Strings(flags) + entries = append(entries, flags...) + + // Finally add remaining parameters in alphabetical order + params := make([]string, 0, len(c.params)) + for k, v := range c.params { + // Skip initrd and root as they were already added + if k == "initrd" || k == "root" { + continue + } + params = append(params, fmt.Sprintf("%s=%s", k, v)) + } + sort.Strings(params) + entries = append(entries, params...) + + return strings.Join(entries, " ") +} + +func validateKey(key string) error { + if strings.TrimSpace(key) != key || strings.ContainsAny(key, " \"'\\") { + return fmt.Errorf("invalid key: '%s'", key) + } + if key == "" { + return errors.New("key cannot be empty") + } + return nil +} + +func validateValue(value string) error { + if strings.TrimSpace(value) != value || strings.ContainsAny(value, " \"'\\") { + return fmt.Errorf("invalid value: '%s'", value) + } + if value == "" { + return errors.New("value cannot be empty") + } + return nil +} diff --git a/pkg/cmdline/main_test.go b/pkg/cmdline/main_test.go new file mode 100644 index 000000000..484bf528e --- /dev/null +++ b/pkg/cmdline/main_test.go @@ -0,0 +1,271 @@ +package cmdline + +import ( + "strings" + "testing" +) + +func TestNew(t *testing.T) { + cl := New() + if cl == nil { + t.Fatal("New() returned nil") + } + if len(cl.params) != 0 { + t.Errorf("New() params map should be empty, got %d entries", len(cl.params)) + } + if len(cl.flags) != 0 { + t.Errorf("New() flags map should be empty, got %d entries", len(cl.flags)) + } + if len(cl.order) != 0 { + t.Errorf("New() order slice should be empty, got %d entries", len(cl.order)) + } +} + +func TestAddParam(t *testing.T) { + tests := []struct { + name string + key string + value string + wantErr bool + }{ + { + name: "valid param", + key: "console", + value: "ttyS0", + wantErr: false, + }, + { + name: "empty key", + key: "", + value: "value", + wantErr: true, + }, + { + name: "empty value", + key: "key", + value: "", + wantErr: true, + }, + { + name: "key with spaces", + key: "key with spaces", + value: "value", + wantErr: true, + }, + { + name: "value with spaces", + key: "key", + value: "value with spaces", + wantErr: true, + }, + { + name: "key with quotes", + key: `key"with"quotes`, + value: "value", + wantErr: true, + }, + { + name: "value with quotes", + key: "key", + value: `value"with"quotes`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cl := New() + err := cl.AddParam(tt.key, tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("AddParam() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr { + if val, ok := cl.params[tt.key]; !ok || val != tt.value { + t.Errorf("AddParam() failed to add param, got %v, want %v", val, tt.value) + } + } + }) + } +} + +func TestAddParamDuplicate(t *testing.T) { + cl := New() + key := "console" + value1 := "ttyS0" + value2 := "ttyS1" + + if err := cl.AddParam(key, value1); err != nil { + t.Fatalf("First AddParam() failed: %v", err) + } + + err := cl.AddParam(key, value2) + if err == nil { + t.Error("AddParam() with duplicate key should return error") + } + if val := cl.params[key]; val != value1 { + t.Errorf("AddParam() with duplicate key should not change value, got %v, want %v", val, value1) + } +} + +func TestAddFlag(t *testing.T) { + tests := []struct { + name string + flag string + wantErr bool + }{ + { + name: "valid flag", + flag: "quiet", + wantErr: false, + }, + { + name: "valid flag with hyphen", + flag: "no-fb", + wantErr: false, + }, + { + name: "empty flag", + flag: "", + wantErr: true, + }, + { + name: "flag with spaces", + flag: "flag with spaces", + wantErr: true, + }, + { + name: "flag with quotes", + flag: `flag"with"quotes`, + wantErr: true, + }, + { + name: "flag as key=value", + flag: "console=ttyS0", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cl := New() + err := cl.AddFlag(tt.flag) + if (err != nil) != tt.wantErr { + t.Errorf("AddFlag() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr { + if strings.Contains(tt.flag, "=") { + parts := strings.SplitN(tt.flag, "=", 2) + if val, ok := cl.params[parts[0]]; !ok || val != parts[1] { + t.Errorf("AddFlag() failed to add param, got %v, want %v", val, parts[1]) + } + } else { + if _, ok := cl.flags[tt.flag]; !ok { + t.Errorf("AddFlag() failed to add flag %v", tt.flag) + } + } + } + }) + } +} + +func TestAddFlagDuplicate(t *testing.T) { + cl := New() + flag := "quiet" + + if err := cl.AddFlag(flag); err != nil { + t.Fatalf("First AddFlag() failed: %v", err) + } + + err := cl.AddFlag(flag) + if err == nil { + t.Error("AddFlag() with duplicate flag should return error") + } +} + +func TestString(t *testing.T) { + tests := []struct { + name string + params map[string]string + flags []string + expected string + }{ + { + name: "only params", + params: map[string]string{ + "console": "ttyS0", + "root": "/dev/sda1", + }, + flags: []string{}, + expected: "root=/dev/sda1 console=ttyS0", + }, + { + name: "only flags", + params: map[string]string{}, + flags: []string{"splash", "quiet"}, + expected: "quiet splash", + }, + { + name: "mixed params and flags", + params: map[string]string{ + "console": "ttyS0", + "root": "/dev/sda1", + }, + flags: []string{"splash", "quiet"}, + expected: "root=/dev/sda1 quiet splash console=ttyS0", + }, + { + name: "initrd and root first", + params: map[string]string{ + "console": "ttyS0", + "root": "/dev/sda1", + "initrd": "/boot/initrd.img", + }, + flags: []string{"quiet"}, + expected: "initrd=/boot/initrd.img root=/dev/sda1 quiet console=ttyS0", + }, + { + name: "multiple params in alphabetical order", + params: map[string]string{ + "console": "ttyS0", + "root": "/dev/sda1", + "initrd": "/boot/initrd.img", + "panic": "30", + }, + flags: []string{"quiet", "splash"}, + expected: "initrd=/boot/initrd.img root=/dev/sda1 quiet splash console=ttyS0 panic=30", + }, + { + name: "empty", + params: map[string]string{}, + flags: []string{}, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cl := New() + + // Add params + for k, v := range tt.params { + if err := cl.AddParam(k, v); err != nil { + t.Fatalf("AddParam() failed for %s=%s: %v", k, v, err) + } + } + + // Add flags + for _, f := range tt.flags { + if err := cl.AddFlag(f); err != nil { + t.Fatalf("AddFlag() failed: %v", err) + } + } + + got := cl.String() + if got != tt.expected { + t.Errorf("String() = %v, want %v", got, tt.expected) + } + }) + } +} diff --git a/internal/smdclient/FakeSMDClient.go b/pkg/smdclient/FakeSMDClient.go similarity index 100% rename from internal/smdclient/FakeSMDClient.go rename to pkg/smdclient/FakeSMDClient.go diff --git a/internal/smdclient/FakeSMDClient_handlers.go b/pkg/smdclient/FakeSMDClient_handlers.go similarity index 100% rename from internal/smdclient/FakeSMDClient_handlers.go rename to pkg/smdclient/FakeSMDClient_handlers.go diff --git a/internal/smdclient/FakeSMDClient_test.go b/pkg/smdclient/FakeSMDClient_test.go similarity index 100% rename from internal/smdclient/FakeSMDClient_test.go rename to pkg/smdclient/FakeSMDClient_test.go diff --git a/internal/smdclient/SMDclient.go b/pkg/smdclient/SMDclient.go similarity index 100% rename from internal/smdclient/SMDclient.go rename to pkg/smdclient/SMDclient.go diff --git a/internal/smdclient/SMDclient_test.go b/pkg/smdclient/SMDclient_test.go similarity index 100% rename from internal/smdclient/SMDclient_test.go rename to pkg/smdclient/SMDclient_test.go diff --git a/internal/smdclient/oidc.go b/pkg/smdclient/oidc.go similarity index 100% rename from internal/smdclient/oidc.go rename to pkg/smdclient/oidc.go diff --git a/pkg/wgtunnel/handlers.go b/pkg/wgtunnel/handlers.go index 0144935fc..7440cfde3 100644 --- a/pkg/wgtunnel/handlers.go +++ b/pkg/wgtunnel/handlers.go @@ -7,7 +7,7 @@ import ( "net/http" "strings" - "github.com/OpenCHAMI/cloud-init/internal/smdclient" + "github.com/OpenCHAMI/cloud-init/pkg/smdclient" "github.com/rs/zerolog/log" ) diff --git a/scripts/test_server.sh b/scripts/test_server.sh new file mode 100755 index 000000000..5da9bda0e --- /dev/null +++ b/scripts/test_server.sh @@ -0,0 +1,97 @@ +#!/bin/bash + +# Exit on any error +set -e + +# Colors for output +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +# Function to print test results +print_result() { + if [ $1 -eq 0 ]; then + echo -e "${GREEN}✓ $2${NC}" + else + echo -e "${RED}✗ $2${NC}" + exit 1 + fi +} + +# Start the server in the background +echo "Starting netboot server..." +./netboot-server serve --cluster-name test-cluster --fake-smd --storage-backend mem & +SERVER_PID=$! + +# Wait for server to start +sleep 2 + +# Cleanup function to kill the server when the script exits +cleanup() { + echo "Stopping server..." + kill $SERVER_PID +} +trap cleanup EXIT + +# Test creating boot parameters +echo -e "\nTesting boot parameters API..." + +# Create boot parameters +RESPONSE=$(http POST http://localhost:8080/boot/v1/bootparams \ + kernel=/boot/vmlinuz \ + initrd=/boot/initrd.img \ + params="console=ttyS0 root=/dev/sda1" \ + --json \ + --check-status \ + --body) + +# Extract the ID from the Location header +ID=$(echo $RESPONSE | jq -r '.id') +if [ -z "$ID" ]; then + print_result 1 "Failed to create boot parameters" +else + print_result 0 "Created boot parameters with ID: $ID" +fi + +# Get boot parameters +RESPONSE=$(http GET http://localhost:8080/boot/v1/bootparams/$ID --check-status --body) +KERNEL=$(echo $RESPONSE | jq -r '.kernel') +INITRD=$(echo $RESPONSE | jq -r '.initrd') +PARAMS=$(echo $RESPONSE | jq -r '.params') + +if [ "$KERNEL" = "/boot/vmlinuz" ] && [ "$INITRD" = "/boot/initrd.img" ] && [ "$PARAMS" = "console=ttyS0 root=/dev/sda1" ]; then + print_result 0 "Retrieved boot parameters match created parameters" +else + print_result 1 "Retrieved boot parameters do not match created parameters" +fi + +# Update boot parameters +RESPONSE=$(http PUT http://localhost:8080/boot/v1/bootparams/$ID \ + kernel=/boot/vmlinuz-new \ + initrd=/boot/initrd.img \ + params="console=ttyS0 root=/dev/sda1" \ + --json \ + --check-status \ + --body) + +# Verify the update +RESPONSE=$(http GET http://localhost:8080/boot/v1/bootparams/$ID --check-status --body) +KERNEL=$(echo $RESPONSE | jq -r '.kernel') + +if [ "$KERNEL" = "/boot/vmlinuz-new" ]; then + print_result 0 "Updated boot parameters successfully" +else + print_result 1 "Failed to update boot parameters" +fi + +# Test boot script generation +echo -e "\nTesting boot script generation..." + +RESPONSE=$(http GET "http://localhost:8080/boot/v1/bootscript?node=$ID" --check-status --body) +if echo "$RESPONSE" | jq -e . >/dev/null 2>&1; then + print_result 0 "Generated boot script successfully" +else + print_result 1 "Failed to generate boot script" +fi + +echo -e "\nAll tests completed successfully!" \ No newline at end of file