From 484facd7f5960c74cd8cd23951d51e66cd22e299 Mon Sep 17 00:00:00 2001 From: Alex Lovell-Troy Date: Wed, 19 Mar 2025 12:14:40 +0100 Subject: [PATCH 01/22] feat(quackstore): implement QuackStore for persistent storage with tests - Added QuackStore implementation for managing groups, instances, and cluster defaults using Quack for persistence. - Introduced unit tests for QuackStore to ensure functionality and data integrity. - Updated go.mod to include new dependencies and replaced the quack module path. - Enhanced CloudConfigFile JSON marshaling and unmarshaling for better encoding handling. --- go.mod | 13 + go.sum | 25 ++ internal/quackstore/quackstore.go | 355 ++++++++++++++++++++++++ internal/quackstore/quackstore_test.go | 103 +++++++ pkg/cistore/models.go | 25 +- pkg/cistore/testing/store_test_suite.go | 275 ++++++++++++++++++ 6 files changed, 795 insertions(+), 1 deletion(-) create mode 100644 internal/quackstore/quackstore.go create mode 100644 internal/quackstore/quackstore_test.go create mode 100644 pkg/cistore/testing/store_test_suite.go diff --git a/go.mod b/go.mod index 49e9683ea..5eb36abed 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.23 require ( github.com/OpenCHAMI/jwtauth/v5 v5.0.0-20240321222802-e6cb468a2a18 + github.com/OpenCHAMI/quack v0.0.0 github.com/OpenCHAMI/smd/v2 v2.17.7 github.com/go-chi/chi/v5 v5.1.0 github.com/lestrrat-go/jwx/v2 v2.1.3 @@ -18,6 +19,7 @@ require ( github.com/KyleBanks/depth v1.2.1 // indirect github.com/PuerkitoBio/purell v1.1.1 // indirect github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect + github.com/apache/arrow/go/v14 v14.0.2 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect @@ -27,19 +29,28 @@ require ( github.com/go-openapi/spec v0.20.4 // indirect github.com/go-openapi/swag v0.19.15 // indirect github.com/goccy/go-json v0.10.4 // indirect + github.com/google/flatbuffers v23.5.26+incompatible // indirect github.com/josharian/intern v1.0.0 // indirect + github.com/klauspost/compress v1.16.7 // indirect + github.com/klauspost/cpuid/v2 v2.2.5 // indirect github.com/lestrrat-go/blackmagic v1.0.2 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect github.com/lestrrat-go/httprc v1.0.6 // indirect github.com/lestrrat-go/iter v1.0.2 // indirect github.com/lestrrat-go/option v1.0.1 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/marcboeker/go-duckdb v1.7.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/pierrec/lz4/v4 v4.1.18 // indirect 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/zeebo/xxh3 v1.0.2 // indirect + golang.org/x/mod v0.17.0 // indirect + golang.org/x/sync v0.10.0 // indirect golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect + golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) @@ -69,3 +80,5 @@ require ( golang.org/x/time v0.8.0 // indirect gopkg.in/yaml.v2 v2.4.0 ) + +replace github.com/OpenCHAMI/quack => ../quack diff --git a/go.sum b/go.sum index e30aa2aaa..9140e2e30 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,8 @@ github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tN github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/apache/arrow/go/v14 v14.0.2 h1:N8OkaJEOfI3mEZt07BIkvo4sC6XDbL+48MBPWO5IONw= +github.com/apache/arrow/go/v14 v14.0.2/go.mod h1:u3fgh3EdgN/YQ8cVQRguVW3R+seMybFg8QBQ5LU+eBY= 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= @@ -48,8 +50,12 @@ github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncV github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM= github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/flatbuffers v23.5.26+incompatible h1:M9dgRyhJemaM4Sw8+66GHBu8ioaQmyPLg1b8VwK5WJg= +github.com/google/flatbuffers v23.5.26+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -78,6 +84,10 @@ github.com/hashicorp/vault/api v1.15.0 h1:O24FYQCWwhwKnF7CuSqP30S51rTV7vz1iACXE/ github.com/hashicorp/vault/api v1.15.0/go.mod h1:+5YTO09JGn0u+b6ySD/LLVf8WkJCPLAL2Vkmrn2+CM8= 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/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I= +github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg= +github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= @@ -100,6 +110,8 @@ github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/marcboeker/go-duckdb v1.7.0 h1:c9DrS13ta+gqVgg9DiEW8I+PZBE85nBMLL/YMooYoUY= +github.com/marcboeker/go-duckdb v1.7.0/go.mod h1:WtWeqqhZoTke/Nbd7V9lnBx7I2/A/q0SAq/urGzPCMs= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= @@ -116,6 +128,8 @@ github.com/openchami/chi-middleware/auth v0.0.0-20240812224658-b16b83c70700 h1:X github.com/openchami/chi-middleware/auth v0.0.0-20240812224658-b16b83c70700/go.mod h1:kswb9kU5cZAFRAvf1dAUJRWbQyjDEb0qkxW4ncDdEXg= github.com/openchami/chi-middleware/log v0.0.0-20240812224658-b16b83c70700 h1:Gzt5f6RK39CHvY3SJudzBb/RK4tVh/S3CpJ0eQlbNdg= github.com/openchami/chi-middleware/log v0.0.0-20240812224658-b16b83c70700/go.mod h1:UuXvr2loD4MtvZeKr57W0WpBs+gm0KM1kdtcXrE8M6s= +github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ= +github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -139,8 +153,14 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A= github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= @@ -153,6 +173,7 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= @@ -167,6 +188,10 @@ golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= +gonum.org/v1/gonum v0.12.0 h1:xKuo6hzt+gMav00meVPUlXwSdoEJP46BR+wdxQEFK2o= +gonum.org/v1/gonum v0.12.0/go.mod h1:73TDxJfAAHeA8Mk9mf8NlIppyhQNo5GLTcYeqgo2lvY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= diff --git a/internal/quackstore/quackstore.go b/internal/quackstore/quackstore.go new file mode 100644 index 000000000..0006fc779 --- /dev/null +++ b/internal/quackstore/quackstore.go @@ -0,0 +1,355 @@ +package quackstore + +import ( + "database/sql" + "encoding/json" + "fmt" + + "github.com/OpenCHAMI/cloud-init/pkg/cistore" + "github.com/OpenCHAMI/quack/quack" +) + +// QuackStore implements the cistore.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 groups ( + name TEXT PRIMARY KEY, + data BLOB + )`, + `CREATE TABLE IF NOT EXISTS instances ( + node_name TEXT PRIMARY KEY, + data BLOB + )`, + `CREATE TABLE IF NOT EXISTS cluster_defaults ( + id INTEGER PRIMARY KEY, + data BLOB + )`, + } + + 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 +} + +// GetGroups returns all groups +func (s *QuackStore) GetGroups() map[string]cistore.GroupData { + groups := make(map[string]cistore.GroupData) + + rows, err := s.db.Query("SELECT name, data FROM groups") + if err != nil { + fmt.Printf("Error querying groups: %v\n", err) + return groups + } + defer rows.Close() + + for rows.Next() { + var name string + var data []byte + if err := rows.Scan(&name, &data); err != nil { + fmt.Printf("Error scanning row: %v\n", err) + continue + } + + fmt.Printf("Raw data from database for group %s: %s\n", name, string(data)) + + var group cistore.GroupData + if err := json.Unmarshal(data, &group); err != nil { + fmt.Printf("Error unmarshaling group data: %v\n", err) + continue + } + group.Name = name + groups[name] = group + } + + return groups +} + +// AddGroupData adds a new group +func (s *QuackStore) AddGroupData(groupName string, groupData cistore.GroupData) error { + // Check if group exists + var exists bool + err := s.db.QueryRow("SELECT EXISTS(SELECT 1 FROM groups WHERE name = ?)", groupName).Scan(&exists) + if err != nil { + return fmt.Errorf("failed to check group existence: %w", err) + } + if exists { + return fmt.Errorf("group '%s' not added as it already exists", groupName) + } + + // Ensure name is set correctly + groupData.Name = groupName + + data, err := json.Marshal(groupData) + if err != nil { + return fmt.Errorf("failed to marshal group data: %w", err) + } + + fmt.Printf("Storing data for group %s: %s\n", groupName, string(data)) + + _, err = s.db.Exec("INSERT INTO groups (name, data) VALUES (?, ?)", groupName, data) + if err != nil { + return fmt.Errorf("failed to insert group: %w", err) + } + + return nil +} + +// GetGroupData returns a specific group +func (s *QuackStore) GetGroupData(groupName string) (cistore.GroupData, error) { + var data []byte + err := s.db.QueryRow("SELECT data FROM groups WHERE name = ?", groupName).Scan(&data) + if err == sql.ErrNoRows { + return cistore.GroupData{}, fmt.Errorf("group (%s) not found", groupName) + } + if err != nil { + return cistore.GroupData{}, fmt.Errorf("failed to query group: %w", err) + } + + fmt.Printf("Raw data from database for group %s: %s\n", groupName, string(data)) + + var group cistore.GroupData + if err := json.Unmarshal(data, &group); err != nil { + return cistore.GroupData{}, fmt.Errorf("failed to unmarshal group data: %w", err) + } + + // Ensure name is set correctly + group.Name = groupName + return group, nil +} + +// UpdateGroupData updates an existing group +func (s *QuackStore) UpdateGroupData(groupName string, groupData cistore.GroupData, create bool) error { + // Ensure name is set correctly + groupData.Name = groupName + + data, err := json.Marshal(groupData) + if err != nil { + return fmt.Errorf("failed to marshal group data: %w", err) + } + + fmt.Printf("Storing data for group %s: %s\n", groupName, string(data)) + + if create { + _, err = s.db.Exec("INSERT OR REPLACE INTO groups (name, data) VALUES (?, ?)", groupName, data) + if err != nil { + return fmt.Errorf("failed to upsert group: %w", err) + } + return nil + } + + result, err := s.db.Exec("UPDATE groups SET data = ? WHERE name = ?", data, groupName) + if err != nil { + return fmt.Errorf("failed to update group: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to get rows affected: %w", err) + } + + if rows == 0 { + return fmt.Errorf("group (%s) not found", groupName) + } + + return nil +} + +// RemoveGroupData removes a group +func (s *QuackStore) RemoveGroupData(groupName string) error { + result, err := s.db.Exec("DELETE FROM groups WHERE name = ?", groupName) + if err != nil { + return fmt.Errorf("failed to delete group: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to get rows affected: %w", err) + } + + if rows == 0 { + return fmt.Errorf("group (%s) not found", groupName) + } + + return nil +} + +// GetInstanceInfo returns instance information for a node +func (s *QuackStore) GetInstanceInfo(nodeName string) (cistore.OpenCHAMIInstanceInfo, error) { + var data []byte + err := s.db.QueryRow("SELECT data FROM instances WHERE node_name = ?", nodeName).Scan(&data) + if err == sql.ErrNoRows { + // If not found, return a new instance with generated ID + return cistore.OpenCHAMIInstanceInfo{ + InstanceID: generateInstanceId(), + }, nil + } + if err != nil { + return cistore.OpenCHAMIInstanceInfo{}, fmt.Errorf("failed to query instance: %w", err) + } + + var info cistore.OpenCHAMIInstanceInfo + if err := json.Unmarshal(data, &info); err != nil { + return cistore.OpenCHAMIInstanceInfo{}, fmt.Errorf("failed to unmarshal instance info: %w", err) + } + + return info, nil +} + +// SetInstanceInfo sets instance information for a node +func (s *QuackStore) SetInstanceInfo(nodeName string, instanceInfo cistore.OpenCHAMIInstanceInfo) error { + // Get existing instance info to preserve instance ID if it exists + var existingData []byte + err := s.db.QueryRow("SELECT data FROM instances WHERE node_name = ?", nodeName).Scan(&existingData) + if err == nil { + var existingInfo cistore.OpenCHAMIInstanceInfo + if err := json.Unmarshal(existingData, &existingInfo); err == nil { + // Preserve existing instance ID + instanceInfo.InstanceID = existingInfo.InstanceID + } + } else if err != sql.ErrNoRows { + return fmt.Errorf("failed to query existing instance: %w", err) + } else if instanceInfo.InstanceID == "" { + // Generate new instance ID if none exists + instanceInfo.InstanceID = generateInstanceId() + } + + data, err := json.Marshal(instanceInfo) + if err != nil { + return fmt.Errorf("failed to marshal instance info: %w", err) + } + + _, err = s.db.Exec("INSERT OR REPLACE INTO instances (node_name, data) VALUES (?, ?)", nodeName, data) + if err != nil { + return fmt.Errorf("failed to save instance info: %w", err) + } + + return nil +} + +// DeleteInstanceInfo deletes instance information for a node +func (s *QuackStore) DeleteInstanceInfo(nodeName string) error { + result, err := s.db.Exec("DELETE FROM instances WHERE node_name = ?", nodeName) + if err != nil { + return fmt.Errorf("failed to delete instance: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to get rows affected: %w", err) + } + + if rows == 0 { + return fmt.Errorf("instance not found: %s", nodeName) + } + + return nil +} + +// GetClusterDefaults returns cluster defaults +func (s *QuackStore) GetClusterDefaults() (cistore.ClusterDefaults, error) { + var data []byte + err := s.db.QueryRow("SELECT data FROM cluster_defaults WHERE id = 1").Scan(&data) + if err == sql.ErrNoRows { + return cistore.ClusterDefaults{}, nil + } + if err != nil { + return cistore.ClusterDefaults{}, fmt.Errorf("failed to query cluster defaults: %w", err) + } + + var defaults cistore.ClusterDefaults + if err := json.Unmarshal(data, &defaults); err != nil { + return cistore.ClusterDefaults{}, fmt.Errorf("failed to unmarshal cluster defaults: %w", err) + } + + return defaults, nil +} + +// SetClusterDefaults sets cluster defaults +func (s *QuackStore) SetClusterDefaults(clusterDefaults cistore.ClusterDefaults) error { + // Get existing defaults to merge with + var existingData []byte + err := s.db.QueryRow("SELECT data FROM cluster_defaults WHERE id = 1").Scan(&existingData) + if err == nil { + var existingDefaults cistore.ClusterDefaults + if err := json.Unmarshal(existingData, &existingDefaults); err == nil { + // Merge with existing defaults + if clusterDefaults.ClusterName != "" { + existingDefaults.ClusterName = clusterDefaults.ClusterName + } + if clusterDefaults.ShortName != "" { + existingDefaults.ShortName = clusterDefaults.ShortName + } + if clusterDefaults.NidLength != 0 { + existingDefaults.NidLength = clusterDefaults.NidLength + } + if clusterDefaults.BaseUrl != "" { + existingDefaults.BaseUrl = clusterDefaults.BaseUrl + } + if clusterDefaults.AvailabilityZone != "" { + existingDefaults.AvailabilityZone = clusterDefaults.AvailabilityZone + } + if clusterDefaults.Region != "" { + existingDefaults.Region = clusterDefaults.Region + } + if clusterDefaults.CloudProvider != "" { + existingDefaults.CloudProvider = clusterDefaults.CloudProvider + } + if len(clusterDefaults.PublicKeys) > 0 { + existingDefaults.PublicKeys = clusterDefaults.PublicKeys + } + clusterDefaults = existingDefaults + } + } else if err != sql.ErrNoRows { + return fmt.Errorf("failed to query existing cluster defaults: %w", err) + } + + data, err := json.Marshal(clusterDefaults) + if err != nil { + return fmt.Errorf("failed to marshal cluster defaults: %w", err) + } + + _, err = s.db.Exec("INSERT OR REPLACE INTO cluster_defaults (id, data) VALUES (1, ?)", data) + if err != nil { + return fmt.Errorf("failed to save cluster defaults: %w", err) + } + + return nil +} + +// Close closes the Quack database connection +func (s *QuackStore) Close() error { + return s.db.Close() +} + +// generateInstanceId generates a unique instance ID +func generateInstanceId() string { + // TODO: Implement proper instance ID generation + return "i-1234567890abcdef" +} diff --git a/internal/quackstore/quackstore_test.go b/internal/quackstore/quackstore_test.go new file mode 100644 index 000000000..23cc7ef82 --- /dev/null +++ b/internal/quackstore/quackstore_test.go @@ -0,0 +1,103 @@ +package quackstore + +import ( + "os" + "path/filepath" + "testing" + "time" + + storetesting "github.com/OpenCHAMI/cloud-init/pkg/cistore/testing" + "github.com/stretchr/testify/assert" +) + +func TestQuackStore(t *testing.T) { + // Create a temporary directory for the test database + tmpDir, err := os.MkdirTemp("", "quackstore-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Create the database file path + dbPath := filepath.Join(tmpDir, "test.db") + + // Test database creation and schema initialization + t.Run("Database Initialization", func(t *testing.T) { + store, err := NewQuackStore(dbPath) + assert.NoError(t, err) + defer store.Close() + + // Verify database file was created + _, err = os.Stat(dbPath) + assert.NoError(t, err) + + // Verify tables were created by trying to insert test data + _, err = store.db.Exec("INSERT INTO groups (name, data) VALUES (?, ?)", "init-test", "{}") + assert.NoError(t, err) + + _, err = store.db.Exec("INSERT INTO instances (node_name, data) VALUES (?, ?)", "init-test", "{}") + assert.NoError(t, err) + + _, err = store.db.Exec("INSERT INTO cluster_defaults (id, data) VALUES (?, ?)", 1, "{}") + assert.NoError(t, err) + }) + + // Test database cleanup + t.Run("Database Cleanup", func(t *testing.T) { + // Remove the existing database file to start fresh + os.Remove(dbPath) + + store, err := NewQuackStore(dbPath) + assert.NoError(t, err) + defer store.Close() + + // Insert some test data + _, err = store.db.Exec("INSERT INTO groups (name, data) VALUES (?, ?)", "cleanup-test", "{}") + assert.NoError(t, err) + + // Close the database + err = store.Close() + assert.NoError(t, err) + + // Reopen the database and verify data persists + store, err = NewQuackStore(dbPath) + assert.NoError(t, err) + defer store.Close() + + var count int + err = store.db.QueryRow("SELECT COUNT(*) FROM groups").Scan(&count) + assert.NoError(t, err) + assert.Equal(t, 1, count) + }) + + // Remove the database file before running the standard test suite + os.Remove(dbPath) + + // Create a new QuackStore instance for the standard test suite + store, err := NewQuackStore(dbPath) + if err != nil { + t.Fatalf("Failed to create QuackStore: %v", err) + } + defer store.Close() + + // Create a cleanup function that will be called in all cases + cleanup := func() { + store.Close() + os.RemoveAll(tmpDir) + } + defer cleanup() + + // Run the standard test suite with a timeout + done := make(chan bool) + go func() { + storetesting.RunStoreTests(t, store, cleanup) + done <- true + }() + + select { + case <-done: + // Tests completed successfully + case <-time.After(30 * time.Second): + t.Fatal("Tests timed out after 30 seconds") + } +} diff --git a/pkg/cistore/models.go b/pkg/cistore/models.go index 7352fb6d8..2a39c5d3c 100644 --- a/pkg/cistore/models.go +++ b/pkg/cistore/models.go @@ -1,8 +1,10 @@ package cistore import ( + "encoding/base64" "encoding/json" "errors" + "fmt" base "github.com/Cray-HPE/hms-base" ) @@ -70,7 +72,28 @@ type CloudConfigFile struct { Encoding string `json:"encoding,omitempty" enums:"base64,plain"` } -// Custom unmarshaler for CloudConfigFile +// MarshalJSON implements json.Marshaler +func (f CloudConfigFile) MarshalJSON() ([]byte, error) { + type Alias CloudConfigFile + var content string + switch f.Encoding { + case "base64": + content = base64.StdEncoding.EncodeToString(f.Content) + case "plain", "": + content = string(f.Content) + default: + return nil, fmt.Errorf("unsupported encoding: %s", f.Encoding) + } + return json.Marshal(&struct { + Content string `json:"content"` + *Alias + }{ + Content: content, + Alias: (*Alias)(&f), + }) +} + +// UnmarshalJSON implements json.Unmarshaler func (f *CloudConfigFile) UnmarshalJSON(data []byte) error { // Use an auxiliary struct so that: // diff --git a/pkg/cistore/testing/store_test_suite.go b/pkg/cistore/testing/store_test_suite.go new file mode 100644 index 000000000..2fad4e4b9 --- /dev/null +++ b/pkg/cistore/testing/store_test_suite.go @@ -0,0 +1,275 @@ +package testing + +import ( + "testing" + + "github.com/OpenCHAMI/cloud-init/pkg/cistore" + "github.com/stretchr/testify/assert" +) + +// RunStoreTests runs a suite of tests against any implementation of cistore.Store +func RunStoreTests(t *testing.T, store cistore.Store, cleanup func()) { + // Ensure cleanup is called in all cases + defer func() { + if cleanup != nil { + cleanup() + } + }() + + // Run each test in a separate sub-test to ensure isolation + t.Run("Group Operations", func(t *testing.T) { + // Clear existing data + groups := store.GetGroups() + for name := range groups { + store.RemoveGroupData(name) + } + testGroupOperations(t, store) + }) + + t.Run("Instance Operations", func(t *testing.T) { + // Clear existing data + groups := store.GetGroups() + for name := range groups { + store.RemoveGroupData(name) + } + testInstanceOperations(t, store) + }) + + t.Run("Cluster Defaults Operations", func(t *testing.T) { + // Clear existing data + groups := store.GetGroups() + for name := range groups { + store.RemoveGroupData(name) + } + testClusterDefaultsOperations(t, store) + }) +} + +func testGroupOperations(t *testing.T, store cistore.Store) { + // Test data + testGroup := cistore.GroupData{ + Name: "test-group", + Description: "Test group description", + Data: map[string]interface{}{ + "key1": map[string]interface{}{ + "value": "value1", + }, + "key2": map[string]interface{}{ + "value": "value2", + }, + }, + File: cistore.CloudConfigFile{ + Content: []byte("test content"), + Name: "test.yaml", + Encoding: "plain", + }, + } + + // Test AddGroupData + t.Run("Add Group", func(t *testing.T) { + err := store.AddGroupData(testGroup.Name, testGroup) + assert.NoError(t, err) + + // Try to add the same group again (should fail) + err = store.AddGroupData(testGroup.Name, testGroup) + assert.Error(t, err) + assert.Contains(t, err.Error(), "already exists") + }) + + // Test GetGroupData + t.Run("Get Group", func(t *testing.T) { + group, err := store.GetGroupData(testGroup.Name) + assert.NoError(t, err) + assert.Equal(t, testGroup.Name, group.Name) + assert.Equal(t, testGroup.Description, group.Description) + assert.Equal(t, testGroup.Data, group.Data) + assert.Equal(t, testGroup.File.Content, group.File.Content) + assert.Equal(t, testGroup.File.Name, group.File.Name) + assert.Equal(t, testGroup.File.Encoding, group.File.Encoding) + + // Test non-existent group + _, err = store.GetGroupData("non-existent") + assert.Error(t, err) + assert.Contains(t, err.Error(), "not found") + }) + + // Test UpdateGroupData + t.Run("Update Group", func(t *testing.T) { + updatedGroup := testGroup + updatedGroup.Description = "Updated description" + updatedGroup.Data["key1"] = map[string]interface{}{ + "value": "updated-value", + } + updatedGroup.File.Content = []byte("updated content") + + // Test update without create + err := store.UpdateGroupData(testGroup.Name, updatedGroup, false) + assert.NoError(t, err) + + // Verify update + group, err := store.GetGroupData(testGroup.Name) + assert.NoError(t, err) + assert.Equal(t, updatedGroup.Description, group.Description) + assert.Equal(t, updatedGroup.Data["key1"], group.Data["key1"]) + assert.Equal(t, updatedGroup.File.Content, group.File.Content) + + // Test update with create + newGroup := cistore.GroupData{ + Name: "new-group", + Description: "New group", + Data: map[string]interface{}{ + "key1": map[string]interface{}{ + "value": "new-value", + }, + }, + File: cistore.CloudConfigFile{ + Content: []byte("new content"), + Name: "new.yaml", + Encoding: "plain", + }, + } + err = store.UpdateGroupData(newGroup.Name, newGroup, true) + assert.NoError(t, err) + + // Verify new group + group, err = store.GetGroupData(newGroup.Name) + assert.NoError(t, err) + assert.Equal(t, newGroup.Name, group.Name) + assert.Equal(t, newGroup.Description, group.Description) + assert.Equal(t, newGroup.Data, group.Data) + assert.Equal(t, newGroup.File.Content, group.File.Content) + assert.Equal(t, newGroup.File.Name, group.File.Name) + assert.Equal(t, newGroup.File.Encoding, group.File.Encoding) + }) + + // Test RemoveGroupData + t.Run("Remove Group", func(t *testing.T) { + err := store.RemoveGroupData(testGroup.Name) + assert.NoError(t, err) + + // Verify removal + _, err = store.GetGroupData(testGroup.Name) + assert.Error(t, err) + assert.Contains(t, err.Error(), "not found") + }) + + // Test GetGroups + t.Run("Get All Groups", func(t *testing.T) { + groups := store.GetGroups() + assert.NotNil(t, groups) + assert.Contains(t, groups, "new-group") + }) +} + +func testInstanceOperations(t *testing.T, store cistore.Store) { + // Test data + testInstance := cistore.OpenCHAMIInstanceInfo{ + ID: "test-node", + InstanceID: "i-test123", + LocalHostname: "test-host", + Hostname: "test-host.example.com", + ClusterName: "test-cluster", + Region: "test-region", + AvailabilityZone: "test-zone", + CloudProvider: "test-provider", + InstanceType: "test-type", + CloudInitBaseURL: "http://test.example.com", + PublicKeys: []string{"ssh-rsa test-key"}, + } + + // Test SetInstanceInfo + t.Run("Set Instance Info", func(t *testing.T) { + err := store.SetInstanceInfo(testInstance.ID, testInstance) + assert.NoError(t, err) + }) + + // Test GetInstanceInfo + t.Run("Get Instance Info", func(t *testing.T) { + info, err := store.GetInstanceInfo(testInstance.ID) + assert.NoError(t, err) + assert.Equal(t, testInstance.ID, info.ID) + assert.Equal(t, testInstance.InstanceID, info.InstanceID) + assert.Equal(t, testInstance.LocalHostname, info.LocalHostname) + assert.Equal(t, testInstance.Hostname, info.Hostname) + assert.Equal(t, testInstance.ClusterName, info.ClusterName) + assert.Equal(t, testInstance.Region, info.Region) + assert.Equal(t, testInstance.AvailabilityZone, info.AvailabilityZone) + assert.Equal(t, testInstance.CloudProvider, info.CloudProvider) + assert.Equal(t, testInstance.InstanceType, info.InstanceType) + assert.Equal(t, testInstance.CloudInitBaseURL, info.CloudInitBaseURL) + assert.Equal(t, testInstance.PublicKeys, info.PublicKeys) + + // Test non-existent instance + info, err = store.GetInstanceInfo("non-existent") + assert.NoError(t, err) + assert.NotEmpty(t, info.InstanceID) + }) + + // Test DeleteInstanceInfo + t.Run("Delete Instance Info", func(t *testing.T) { + err := store.DeleteInstanceInfo(testInstance.ID) + assert.NoError(t, err) + + // Verify deletion + info, err := store.GetInstanceInfo(testInstance.ID) + assert.NoError(t, err) + assert.Empty(t, info.ID) + assert.NotEmpty(t, info.InstanceID) + }) +} + +func testClusterDefaultsOperations(t *testing.T, store cistore.Store) { + // Test data + testDefaults := cistore.ClusterDefaults{ + ClusterName: "test-cluster", + ShortName: "test", + NidLength: 3, + BaseUrl: "http://test.example.com", + AvailabilityZone: "test-zone", + Region: "test-region", + CloudProvider: "test-provider", + PublicKeys: []string{"ssh-rsa test-key"}, + } + + // Test SetClusterDefaults + t.Run("Set Cluster Defaults", func(t *testing.T) { + err := store.SetClusterDefaults(testDefaults) + assert.NoError(t, err) + }) + + // Test GetClusterDefaults + t.Run("Get Cluster Defaults", func(t *testing.T) { + defaults, err := store.GetClusterDefaults() + assert.NoError(t, err) + assert.Equal(t, testDefaults.ClusterName, defaults.ClusterName) + assert.Equal(t, testDefaults.ShortName, defaults.ShortName) + assert.Equal(t, testDefaults.NidLength, defaults.NidLength) + assert.Equal(t, testDefaults.BaseUrl, defaults.BaseUrl) + assert.Equal(t, testDefaults.AvailabilityZone, defaults.AvailabilityZone) + assert.Equal(t, testDefaults.Region, defaults.Region) + assert.Equal(t, testDefaults.CloudProvider, defaults.CloudProvider) + assert.Equal(t, testDefaults.PublicKeys, defaults.PublicKeys) + }) + + // Test partial update + t.Run("Update Cluster Defaults", func(t *testing.T) { + partialDefaults := cistore.ClusterDefaults{ + ClusterName: "updated-cluster", + ShortName: "updated", + } + + err := store.SetClusterDefaults(partialDefaults) + assert.NoError(t, err) + + defaults, err := store.GetClusterDefaults() + assert.NoError(t, err) + assert.Equal(t, partialDefaults.ClusterName, defaults.ClusterName) + assert.Equal(t, partialDefaults.ShortName, defaults.ShortName) + assert.Equal(t, testDefaults.NidLength, defaults.NidLength) + assert.Equal(t, testDefaults.BaseUrl, defaults.BaseUrl) + assert.Equal(t, testDefaults.AvailabilityZone, defaults.AvailabilityZone) + assert.Equal(t, testDefaults.Region, defaults.Region) + assert.Equal(t, testDefaults.CloudProvider, defaults.CloudProvider) + assert.Equal(t, testDefaults.PublicKeys, defaults.PublicKeys) + }) +} From ac5e973f5d4ae95ca20e63898710680323e405bc Mon Sep 17 00:00:00 2001 From: Alex Lovell-Troy Date: Fri, 18 Apr 2025 17:28:22 -0400 Subject: [PATCH 02/22] feat(bss): implement BootParams and boot script generation with HTTP handlers - Added BootParams struct to define boot parameters including kernel, initrd, root filesystem, and cloud-init server configurations. - Implemented GenerateBootScript function to create iPXE boot scripts based on BootParams. - Created BootParamsHandler for handling HTTP requests to create, update, retrieve, and generate boot scripts. - Introduced MemoryStore for in-memory storage of boot parameters with versioning. - Added comprehensive unit tests for BootParams, boot script generation, and HTTP handlers to ensure functionality and reliability. --- pkg/bss/bootparams.go | 44 +++++++ pkg/bss/bootscript.go | 83 +++++++++++++ pkg/bss/bootscript_test.go | 235 +++++++++++++++++++++++++++++++++++++ pkg/bss/handlers.go | 178 ++++++++++++++++++++++++++++ pkg/bss/handlers_test.go | 102 ++++++++++++++++ pkg/bss/store.go | 73 ++++++++++++ pkg/bss/store_test.go | 56 +++++++++ pkg/cistore/models.go | 44 +------ 8 files changed, 772 insertions(+), 43 deletions(-) create mode 100644 pkg/bss/bootparams.go create mode 100644 pkg/bss/bootscript.go create mode 100644 pkg/bss/bootscript_test.go create mode 100644 pkg/bss/handlers.go create mode 100644 pkg/bss/handlers_test.go create mode 100644 pkg/bss/store.go create mode 100644 pkg/bss/store_test.go diff --git a/pkg/bss/bootparams.go b/pkg/bss/bootparams.go new file mode 100644 index 000000000..5af248c41 --- /dev/null +++ b/pkg/bss/bootparams.go @@ -0,0 +1,44 @@ +package bss + +import ( + "encoding/json" + "errors" +) + +// 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 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 +} + +// 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 +} diff --git a/pkg/bss/bootscript.go b/pkg/bss/bootscript.go new file mode 100644 index 000000000..efb57b607 --- /dev/null +++ b/pkg/bss/bootscript.go @@ -0,0 +1,83 @@ +package bss + +import ( + "fmt" + "strings" +) + +// GenerateBootScript creates an iPXE boot script from the given boot parameters +func GenerateBootScript(params *BootParams, retry int, arch string) (string, error) { + if params.Kernel == "" { + return "", fmt.Errorf("kernel must be specified to generate boot script") + } + + var script strings.Builder + script.WriteString("#!ipxe\n") + + // Build kernel command line + kernelCmd := fmt.Sprintf("kernel --name kernel %s", params.Kernel) + + // 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 != "" { + kernelCmd += fmt.Sprintf(" root=nfs://%s:%s", params.RootFS.Server, params.RootFS.Path) + if params.RootFS.Options != "" { + kernelCmd += fmt.Sprintf(" rootflags=%s", params.RootFS.Options) + } + } + case "local": + // Local root filesystem + if params.RootFS.Path != "" { + kernelCmd += fmt.Sprintf(" root=%s", params.RootFS.Path) + if params.RootFS.Options != "" { + kernelCmd += fmt.Sprintf(" rootflags=%s", params.RootFS.Options) + } + } + } + } + + // Add cloud-init server configuration if specified + if params.CloudInit != nil && params.CloudInit.URL != "" { + kernelCmd += fmt.Sprintf(" ds=nocloud-net;s=%s", params.CloudInit.URL) + if params.CloudInit.Version != "" { + kernelCmd += fmt.Sprintf(";v=%s", params.CloudInit.Version) + } + } + + // Add any additional parameters + if params.Params != "" { + kernelCmd += " " + params.Params + } + + script.WriteString(kernelCmd + " || 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..f79aa4d43 --- /dev/null +++ b/pkg/bss/bootscript_test.go @@ -0,0 +1,235 @@ +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 := GenerateBootScript(params, 0, "") + assert.NoError(t, err) + assert.Contains(t, script, "#!ipxe") + assert.Contains(t, script, "kernel --name kernel http://example.com/kernel console=ttyS0,115200 root=/dev/sda1") + 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", + } + + script, err := GenerateBootScript(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", + } + + script, err := GenerateBootScript(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 := GenerateBootScript(params, 2, "x86_64") + assert.NoError(t, err) + + // Check all components are present + assert.Contains(t, script, "kernel --name kernel http://example.com/kernel console=ttyS0,115200 root=/dev/sda1") + 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 := GenerateBootScript(params, 0, "") + assert.Error(t, err) + assert.Contains(t, err.Error(), "kernel must be specified") + + // Test empty params + params = &BootParams{} + _, err = GenerateBootScript(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", + } + + script, err := GenerateBootScript(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 := GenerateBootScript(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 := GenerateBootScript(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 := GenerateBootScript(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 ip=dhcp", + } + + script, err := GenerateBootScript(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 := GenerateBootScript(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", + Params: "console=ttyS0,115200,ip=dhcp", + RootFS: &RootFS{ + Type: "nfs", + Server: "10.0.0.1", + Path: "/nfsroot", + }, + CloudInit: &CloudInitServer{ + URL: "http://cloud-init.example.com", + }, + } + + script, err := GenerateBootScript(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") + assert.Contains(t, script, "console=ttyS0,115200,ip=dhcp") +} diff --git a/pkg/bss/handlers.go b/pkg/bss/handlers.go new file mode 100644 index 000000000..d99c5b189 --- /dev/null +++ b/pkg/bss/handlers.go @@ -0,0 +1,178 @@ +package bss + +import ( + "encoding/json" + "fmt" + "io" + "net/http" +) + +// BootParamsHandler handles HTTP requests for boot parameters +type BootParamsHandler struct { + store Store +} + +// NewBootParamsHandler creates a new BootParamsHandler +func NewBootParamsHandler(store Store) *BootParamsHandler { + return &BootParamsHandler{ + store: store, + } +} + +// CreateBootParams handles POST requests to create new boot parameters +func (h *BootParamsHandler) CreateBootParams(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 + } + + // Get ID from query parameter + id := r.URL.Query().Get("id") + if id == "" { + http.Error(w, "ID parameter is required", http.StatusBadRequest) + return + } + + // Create new boot parameters + if err := h.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 := h.store.Get(id) + if err != nil { + http.Error(w, "Failed to retrieve stored parameters", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(storedParams) +} + +// UpdateBootParams handles PUT requests to update existing boot parameters +func (h *BootParamsHandler) UpdateBootParams(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + 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 + } + + // Get ID from query parameter + id := r.URL.Query().Get("id") + if id == "" { + http.Error(w, "ID parameter is required", http.StatusBadRequest) + return + } + + // Update existing boot parameters + if err := h.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 := h.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) +} + +// GetBootParams handles GET requests to retrieve boot parameters +func (h *BootParamsHandler) GetBootParams(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + id := r.URL.Query().Get("id") + if id == "" { + http.Error(w, "ID parameter is required", http.StatusBadRequest) + return + } + + params, err := h.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 handles GET requests to generate boot scripts +func (h *BootParamsHandler) GenerateBootScriptHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + id := r.URL.Query().Get("id") + if id == "" { + http.Error(w, "ID parameter is required", http.StatusBadRequest) + return + } + + params, err := h.store.Get(id) + if err != nil { + http.Error(w, "Boot parameters not found", http.StatusNotFound) + return + } + + retry := 0 + if retryStr := r.URL.Query().Get("retry"); retryStr != "" { + if _, err := fmt.Sscanf(retryStr, "%d", &retry); err != nil { + http.Error(w, "Invalid retry parameter", http.StatusBadRequest) + return + } + } + + arch := r.URL.Query().Get("arch") + + script, err := GenerateBootScript(params, 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)) +} diff --git a/pkg/bss/handlers_test.go b/pkg/bss/handlers_test.go new file mode 100644 index 000000000..fb9a679e1 --- /dev/null +++ b/pkg/bss/handlers_test.go @@ -0,0 +1,102 @@ +package bss + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBootParamsHandler(t *testing.T) { + store := NewMemoryStore() + handler := NewBootParamsHandler(store) + + // Test data + testParams := &BootParams{ + Kernel: "http://example.com/kernel", + Initrd: "http://example.com/initrd", + } + + // Test creating new parameters + body, _ := json.Marshal(testParams) + req := httptest.NewRequest("POST", "/bootparams?id=test", bytes.NewReader(body)) + w := httptest.NewRecorder() + handler.CreateBootParams(w, req) + + assert.Equal(t, http.StatusCreated, w.Code) + var response BootParams + json.Unmarshal(w.Body.Bytes(), &response) + assert.Equal(t, testParams.Kernel, response.Kernel) + assert.Equal(t, testParams.Initrd, response.Initrd) + assert.Equal(t, 1, response.Version) + + // Test retrieving parameters + req = httptest.NewRequest("GET", "/bootparams?id=test", nil) + w = httptest.NewRecorder() + handler.GetBootParams(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + json.Unmarshal(w.Body.Bytes(), &response) + assert.Equal(t, testParams.Kernel, response.Kernel) + assert.Equal(t, testParams.Initrd, response.Initrd) + assert.Equal(t, 1, response.Version) + + // Test updating parameters + updatedParams := &BootParams{ + Kernel: "http://example.com/new-kernel", + Initrd: "http://example.com/new-initrd", + } + body, _ = json.Marshal(updatedParams) + req = httptest.NewRequest("PUT", "/bootparams?id=test", bytes.NewReader(body)) + w = httptest.NewRecorder() + handler.UpdateBootParams(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + json.Unmarshal(w.Body.Bytes(), &response) + assert.Equal(t, updatedParams.Kernel, response.Kernel) + assert.Equal(t, updatedParams.Initrd, response.Initrd) + assert.Equal(t, 2, response.Version) + + // Test generating boot script + req = httptest.NewRequest("GET", "/bootscript?id=test", nil) + w = httptest.NewRecorder() + handler.GenerateBootScriptHandler(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), updatedParams.Kernel) + assert.Contains(t, w.Body.String(), updatedParams.Initrd) + + // Test error cases + // Missing ID + req = httptest.NewRequest("POST", "/bootparams", bytes.NewReader(body)) + w = httptest.NewRecorder() + handler.CreateBootParams(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code) + + // Invalid JSON + req = httptest.NewRequest("POST", "/bootparams?id=test", bytes.NewReader([]byte("invalid"))) + w = httptest.NewRecorder() + handler.CreateBootParams(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code) + + // Non-existent parameters + req = httptest.NewRequest("GET", "/bootparams?id=nonexistent", nil) + w = httptest.NewRecorder() + handler.GetBootParams(w, req) + assert.Equal(t, http.StatusNotFound, w.Code) + + // Update non-existent parameters + req = httptest.NewRequest("PUT", "/bootparams?id=nonexistent", bytes.NewReader(body)) + w = httptest.NewRecorder() + handler.UpdateBootParams(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code) + + // Create with existing ID + req = httptest.NewRequest("POST", "/bootparams?id=test", bytes.NewReader(body)) + w = httptest.NewRecorder() + handler.CreateBootParams(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code) +} diff --git a/pkg/bss/store.go b/pkg/bss/store.go new file mode 100644 index 000000000..93d481c10 --- /dev/null +++ b/pkg/bss/store.go @@ -0,0 +1,73 @@ +package bss + +import ( + "errors" + "sync" +) + +// Store defines the interface for storing and retrieving boot parameters +type Store interface { + // Set stores boot parameters with the given ID + Set(id string, params *BootParams) error + // Get retrieves boot parameters by ID + Get(id string) (*BootParams, error) + // Update updates existing boot parameters and increments version + Update(id string, params *BootParams) error +} + +// MemoryStore is an in-memory implementation of the Store interface +type MemoryStore struct { + mu sync.RWMutex + params map[string]*BootParams +} + +// NewMemoryStore creates a new MemoryStore +func NewMemoryStore() *MemoryStore { + return &MemoryStore{ + params: make(map[string]*BootParams), + } +} + +// 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") + } + + // Set initial version + params.Version = 1 + s.params[id] = params + return nil +} + +// Get retrieves boot parameters by ID +func (s *MemoryStore) Get(id string) (*BootParams, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + params, exists := s.params[id] + if !exists { + return nil, errors.New("boot parameters not found") + } + + return params, nil +} + +// Update updates existing boot parameters and increments version +func (s *MemoryStore) Update(id string, params *BootParams) error { + s.mu.Lock() + defer s.mu.Unlock() + + existing, exists := s.params[id] + if !exists { + return errors.New("boot parameters not found") + } + + // Preserve the ID and increment version + params.Version = existing.Version + 1 + s.params[id] = params + return nil +} diff --git a/pkg/bss/store_test.go b/pkg/bss/store_test.go new file mode 100644 index 000000000..5d048fe4a --- /dev/null +++ b/pkg/bss/store_test.go @@ -0,0 +1,56 @@ +package bss + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMemoryStore(t *testing.T) { + store := NewMemoryStore() + + // 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 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 + 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 updating non-existent parameters + err = store.Update("nonexistent", updatedParams) + assert.Error(t, err) +} diff --git a/pkg/cistore/models.go b/pkg/cistore/models.go index 2a39c5d3c..b9d5e7809 100644 --- a/pkg/cistore/models.go +++ b/pkg/cistore/models.go @@ -1,10 +1,8 @@ package cistore import ( - "encoding/base64" "encoding/json" "errors" - "fmt" base "github.com/Cray-HPE/hms-base" ) @@ -75,34 +73,17 @@ type CloudConfigFile struct { // MarshalJSON implements json.Marshaler func (f CloudConfigFile) MarshalJSON() ([]byte, error) { type Alias CloudConfigFile - var content string - switch f.Encoding { - case "base64": - content = base64.StdEncoding.EncodeToString(f.Content) - case "plain", "": - content = string(f.Content) - default: - return nil, fmt.Errorf("unsupported encoding: %s", f.Encoding) - } return json.Marshal(&struct { Content string `json:"content"` *Alias }{ - Content: content, + Content: string(f.Content), Alias: (*Alias)(&f), }) } // UnmarshalJSON implements json.Unmarshaler func (f *CloudConfigFile) UnmarshalJSON(data []byte) error { - // Use an auxiliary struct so that: - // - // 1. json.Unmarshal doesn't recurse forever and overflow the stack. - // 2. json.Unmarshal doesn't try to base64-decode "content" in the data - // before assigning the bytes to f.Content. Content is unmarshalled - // as a string instead of bytes in order to prevent this. After - // unmarshalling, the string is converted back to bytes and assigned - // to f.Content. type Alias CloudConfigFile aux := &struct { Content string `json:"content"` @@ -118,26 +99,3 @@ func (f *CloudConfigFile) UnmarshalJSON(data []byte) error { f.Content = []byte(aux.Content) return nil } - -// Custom marshaler for CloudConfigFile -func (f CloudConfigFile) MarshalJSON() ([]byte, error) { - // Use temporary struct to marshal so json.Marshal doesn't recurse - // indefinitely. Also to convert Content from bytes to string so - // json.Marshal doesn't try to base64 encode the bytes. - // Use an auxiliary struct so that: - // - // 1. json.Marshal doesn't recurse forever and overflow the stack. - // 2. json.Marshal doesn't try to base64-encode f.Content. f.Content is - // converted from bytes to a string and then assigned to aux.Content - // to prevent this. Then, aux gets marshalled instead of f. - type Alias CloudConfigFile - aux := &struct { - Content string `json:"content"` - Alias - }{ - Alias: (Alias)(f), - } - - aux.Content = string(f.Content) - return json.Marshal(aux) -} From 74a89671ad781728d5606e6e98383bf8ec3db089 Mon Sep 17 00:00:00 2001 From: Alex Lovell-Troy Date: Wed, 23 Apr 2025 14:49:00 -0400 Subject: [PATCH 03/22] feat(bss): Add the capability for cloud-init to act as BSS - Updated go.mod to include the new dependency for UUID generation. - Refactored import paths to use the new smdclient package structure. - Introduced BootParamsV1 struct for backward compatibility with the old API specification. - Enhanced boot parameters validation and merging logic. - Implemented new HTTP handlers for creating and managing boot parameters. - Added comprehensive unit tests for the new features and refactored existing tests for better coverage. --- cmd/cloud-init-server/handlers.go | 2 +- cmd/cloud-init-server/main.go | 2 +- cmd/cloud-init-server/metadata_handlers.go | 2 +- cmd/cloud-init-server/userdata_handlers.go | 2 +- cmd/cloud-init-server/vendordata_handlers.go | 2 +- go.mod | 1 + pkg/bss/bootparams.go | 70 +++- pkg/bss/bootparams_test.go | 285 +++++++++++++++ pkg/bss/bootscript.go | 35 +- pkg/bss/bootscript_test.go | 38 +- pkg/bss/handlers.go | 325 ++++++++++-------- pkg/bss/handlers_test.go | 232 +++++++++---- pkg/bss/router.go | 27 ++ pkg/bss/store.go | 194 ++++++++++- pkg/bss/store_test.go | 81 ++++- pkg/bss/v1handlers.go | 67 ++++ pkg/cmdline/main.go | 121 +++++++ pkg/cmdline/main_test.go | 271 +++++++++++++++ {internal => pkg}/smdclient/FakeSMDClient.go | 0 .../smdclient/FakeSMDClient_handlers.go | 0 .../smdclient/FakeSMDClient_test.go | 0 {internal => pkg}/smdclient/SMDclient.go | 0 {internal => pkg}/smdclient/SMDclient_test.go | 0 {internal => pkg}/smdclient/oidc.go | 0 pkg/wgtunnel/handlers.go | 2 +- 25 files changed, 1502 insertions(+), 257 deletions(-) create mode 100644 pkg/bss/bootparams_test.go create mode 100644 pkg/bss/router.go create mode 100644 pkg/bss/v1handlers.go create mode 100644 pkg/cmdline/main.go create mode 100644 pkg/cmdline/main_test.go rename {internal => pkg}/smdclient/FakeSMDClient.go (100%) rename {internal => pkg}/smdclient/FakeSMDClient_handlers.go (100%) rename {internal => pkg}/smdclient/FakeSMDClient_test.go (100%) rename {internal => pkg}/smdclient/SMDclient.go (100%) rename {internal => pkg}/smdclient/SMDclient_test.go (100%) rename {internal => pkg}/smdclient/oidc.go (100%) 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..82fd1c66d 100644 --- a/cmd/cloud-init-server/main.go +++ b/cmd/cloud-init-server/main.go @@ -15,8 +15,8 @@ import ( "time" "github.com/OpenCHAMI/cloud-init/internal/memstore" - "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/OpenCHAMI/jwtauth/v5" "github.com/go-chi/chi/v5" 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/go.mod b/go.mod index 5eb36abed..f6f9b6c03 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/OpenCHAMI/quack v0.0.0 github.com/OpenCHAMI/smd/v2 v2.17.7 github.com/go-chi/chi/v5 v5.1.0 + github.com/google/uuid v1.6.0 github.com/lestrrat-go/jwx/v2 v2.1.3 github.com/openchami/chi-middleware/auth v0.0.0-20240812224658-b16b83c70700 github.com/openchami/chi-middleware/log v0.0.0-20240812224658-b16b83c70700 diff --git a/pkg/bss/bootparams.go b/pkg/bss/bootparams.go index 5af248c41..0068273b0 100644 --- a/pkg/bss/bootparams.go +++ b/pkg/bss/bootparams.go @@ -3,8 +3,12 @@ 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.) @@ -19,7 +23,7 @@ type CloudInitServer struct { Version string `json:"version,omitempty"` // Version of the cloud-init server } -// BootParams defines the boot parameters for one or more nodes +// 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 @@ -29,6 +33,20 @@ type BootParams struct { CloudInit *CloudInitServer `json:"cloud-init,omitempty"` // Cloud-init server configuration } +// 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 { @@ -42,3 +60,53 @@ func (b *BootParams) ParseFromJSON(body []byte) error { 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 index efb57b607..b677b0564 100644 --- a/pkg/bss/bootscript.go +++ b/pkg/bss/bootscript.go @@ -3,12 +3,16 @@ package bss import ( "fmt" "strings" + + "github.com/OpenCHAMI/cloud-init/pkg/cmdline" ) -// GenerateBootScript creates an iPXE boot script from the given boot parameters -func GenerateBootScript(params *BootParams, retry int, arch string) (string, error) { +func GenerateIPXEScript(params *BootParams, retry int, arch string) (string, error) { if params.Kernel == "" { - return "", fmt.Errorf("kernel must be specified to generate boot script") + return "", fmt.Errorf("kernel must be specified") + } + if params.Initrd == "" { + return "", fmt.Errorf("initrd must be specified") } var script strings.Builder @@ -17,6 +21,11 @@ func GenerateBootScript(params *BootParams, retry int, arch string) (string, err // 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 { @@ -24,17 +33,17 @@ func GenerateBootScript(params *BootParams, retry int, arch string) (string, err // NFS root requires network configuration kernelCmd += " rd.neednet=1" if params.RootFS.Server != "" && params.RootFS.Path != "" { - kernelCmd += fmt.Sprintf(" root=nfs://%s:%s", params.RootFS.Server, params.RootFS.Path) + cmd.AddParam("root", fmt.Sprintf("nfs://%s:%s", params.RootFS.Server, params.RootFS.Path)) if params.RootFS.Options != "" { - kernelCmd += fmt.Sprintf(" rootflags=%s", params.RootFS.Options) + cmd.AddParam("rootflags", params.RootFS.Options) } } case "local": // Local root filesystem if params.RootFS.Path != "" { - kernelCmd += fmt.Sprintf(" root=%s", params.RootFS.Path) + cmd.AddParam("root", params.RootFS.Path) if params.RootFS.Options != "" { - kernelCmd += fmt.Sprintf(" rootflags=%s", params.RootFS.Options) + cmd.AddParam("rootflags", params.RootFS.Options) } } } @@ -42,18 +51,14 @@ func GenerateBootScript(params *BootParams, retry int, arch string) (string, err // Add cloud-init server configuration if specified if params.CloudInit != nil && params.CloudInit.URL != "" { - kernelCmd += fmt.Sprintf(" ds=nocloud-net;s=%s", params.CloudInit.URL) if params.CloudInit.Version != "" { - kernelCmd += fmt.Sprintf(";v=%s", 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)) } } - // Add any additional parameters - if params.Params != "" { - kernelCmd += " " + params.Params - } - - script.WriteString(kernelCmd + " || goto boot_retry\n") + script.WriteString(kernelCmd + " " + cmd.String() + " || goto boot_retry\n") // Add initrd if specified if params.Initrd != "" { diff --git a/pkg/bss/bootscript_test.go b/pkg/bss/bootscript_test.go index f79aa4d43..1372faad9 100644 --- a/pkg/bss/bootscript_test.go +++ b/pkg/bss/bootscript_test.go @@ -14,10 +14,10 @@ func TestGenerateBootScript_Basic(t *testing.T) { Params: "console=ttyS0,115200 root=/dev/sda1", } - script, err := GenerateBootScript(params, 0, "") + 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 console=ttyS0,115200 root=/dev/sda1") + 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") @@ -28,9 +28,10 @@ func TestGenerateBootScript_Basic(t *testing.T) { func TestGenerateBootScript_WithRetry(t *testing.T) { params := &BootParams{ Kernel: "http://example.com/kernel", + Initrd: "http://example.com/initrd", } - script, err := GenerateBootScript(params, 3, "") + script, err := GenerateIPXEScript(params, 3, "") assert.NoError(t, err) assert.Contains(t, script, "retry=3") } @@ -38,9 +39,10 @@ func TestGenerateBootScript_WithRetry(t *testing.T) { func TestGenerateBootScript_WithArch(t *testing.T) { params := &BootParams{ Kernel: "http://example.com/kernel", + Initrd: "http://example.com/initrd", } - script, err := GenerateBootScript(params, 0, "x86_64") + script, err := GenerateIPXEScript(params, 0, "x86_64") assert.NoError(t, err) assert.Contains(t, script, "arch=x86_64") } @@ -52,11 +54,11 @@ func TestGenerateBootScript_WithOptions(t *testing.T) { Params: "console=ttyS0,115200 root=/dev/sda1", } - script, err := GenerateBootScript(params, 2, "x86_64") + 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 console=ttyS0,115200 root=/dev/sda1") + 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") @@ -69,23 +71,25 @@ func TestGenerateBootScript_ErrorCases(t *testing.T) { Params: "console=ttyS0,115200", } - _, err := GenerateBootScript(params, 0, "") + _, err := GenerateIPXEScript(params, 0, "") assert.Error(t, err) assert.Contains(t, err.Error(), "kernel must be specified") // Test empty params params = &BootParams{} - _, err = GenerateBootScript(params, 0, "") + _, 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 := GenerateBootScript(params, 1, "x86_64") + script, err := GenerateIPXEScript(params, 1, "x86_64") assert.NoError(t, err) // Verify the retry chain URL is properly formatted @@ -100,7 +104,7 @@ func TestGenerateBootScript_Ordering(t *testing.T) { Params: "console=ttyS0,115200", } - script, err := GenerateBootScript(params, 0, "") + script, err := GenerateIPXEScript(params, 0, "") assert.NoError(t, err) // Split the script into lines to properly check command ordering @@ -139,7 +143,7 @@ func TestGenerateBootScript_WithNFSRoot(t *testing.T) { }, } - script, err := GenerateBootScript(params, 0, "") + script, err := GenerateIPXEScript(params, 0, "") assert.NoError(t, err) // Verify NFS root parameters @@ -162,7 +166,7 @@ func TestGenerateBootScript_WithLocalRoot(t *testing.T) { }, } - script, err := GenerateBootScript(params, 0, "") + script, err := GenerateIPXEScript(params, 0, "") assert.NoError(t, err) // Verify local root parameters @@ -182,10 +186,10 @@ func TestGenerateBootScript_WithRootFSAndParams(t *testing.T) { Server: "10.0.0.1", Path: "/nfsroot", }, - Params: "console=ttyS0,115200 ip=dhcp", + Params: "console=ttyS0,115200", } - script, err := GenerateBootScript(params, 0, "") + script, err := GenerateIPXEScript(params, 0, "") assert.NoError(t, err) // Verify both rootfs and additional parameters are included @@ -203,7 +207,7 @@ func TestGenerateBootScript_WithCloudInit(t *testing.T) { }, } - script, err := GenerateBootScript(params, 0, "") + script, err := GenerateIPXEScript(params, 0, "") assert.NoError(t, err) // Verify cloud-init parameters @@ -214,7 +218,6 @@ func TestGenerateBootScript_WithCloudInitAndRootFS(t *testing.T) { params := &BootParams{ Kernel: "http://example.com/kernel", Initrd: "http://example.com/initrd", - Params: "console=ttyS0,115200,ip=dhcp", RootFS: &RootFS{ Type: "nfs", Server: "10.0.0.1", @@ -225,11 +228,10 @@ func TestGenerateBootScript_WithCloudInitAndRootFS(t *testing.T) { }, } - script, err := GenerateBootScript(params, 0, "") + 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") - assert.Contains(t, script, "console=ttyS0,115200,ip=dhcp") } diff --git a/pkg/bss/handlers.go b/pkg/bss/handlers.go index d99c5b189..10e9af8f2 100644 --- a/pkg/bss/handlers.go +++ b/pkg/bss/handlers.go @@ -1,178 +1,235 @@ 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" ) -// BootParamsHandler handles HTTP requests for boot parameters -type BootParamsHandler struct { - store Store -} +// CreateBootParamsHandler creates an HTTP handler for creating boot parameters +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 + } -// NewBootParamsHandler creates a new BootParamsHandler -func NewBootParamsHandler(store Store) *BootParamsHandler { - return &BootParamsHandler{ - store: store, - } -} + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "Failed to read request body", http.StatusBadRequest) + return + } -// CreateBootParams handles POST requests to create new boot parameters -func (h *BootParamsHandler) CreateBootParams(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } + var params BootParams + if err := json.Unmarshal(body, ¶ms); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } - body, err := io.ReadAll(r.Body) - if err != nil { - http.Error(w, "Failed to read request body", http.StatusBadRequest) - return - } + if err := params.ParseFromJSON(body); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } - var params BootParams - if err := json.Unmarshal(body, ¶ms); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } + // Generate a unique ID for the boot parameters + id := generateBootParamsID() - if err := params.ParseFromJSON(body); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } + // Create new boot parameters + if err := store.Set(id, ¶ms); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } - // Get ID from query parameter - id := r.URL.Query().Get("id") - if id == "" { - http.Error(w, "ID parameter is required", 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 + } - // Create new boot parameters - if err := h.store.Set(id, ¶ms); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - 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) } +} - // Get the stored params to include version in response - storedParams, err := h.store.Get(id) - if err != nil { - http.Error(w, "Failed to retrieve stored parameters", http.StatusInternalServerError) - return - } +// UpdateBootParamsHandler creates an HTTP handler for updating boot parameters +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 + } - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(storedParams) -} + // Get ID from URL parameters + id := chi.URLParam(r, "id") + if id == "" { + http.Error(w, "ID is required in path", http.StatusBadRequest) + return + } -// UpdateBootParams handles PUT requests to update existing boot parameters -func (h *BootParamsHandler) UpdateBootParams(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPut { - 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 + } - 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 + } - 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 + } - 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 ID from query parameter - id := r.URL.Query().Get("id") - if id == "" { - http.Error(w, "ID parameter is required", 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 + } - // Update existing boot parameters - if err := h.store.Update(id, ¶ms); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(updatedParams) } +} - // Get the updated params to include version in response - updatedParams, err := h.store.Get(id) - if err != nil { - http.Error(w, "Failed to retrieve stored parameters", http.StatusInternalServerError) - return - } +// GetBootParamsHandler creates an HTTP handler for retrieving boot parameters +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 + } - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(updatedParams) -} + // Get ID from URL parameters + id := chi.URLParam(r, "id") + if id == "" { + http.Error(w, "ID is required in path", http.StatusBadRequest) + return + } -// GetBootParams handles GET requests to retrieve boot parameters -func (h *BootParamsHandler) GetBootParams(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } + params, err := store.Get(id) + if err != nil { + http.Error(w, "Boot parameters not found", http.StatusNotFound) + return + } - id := r.URL.Query().Get("id") - if id == "" { - http.Error(w, "ID parameter is required", http.StatusBadRequest) - return + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(params) } +} - params, err := h.store.Get(id) - if err != nil { - http.Error(w, "Boot parameters not found", http.StatusNotFound) - return - } +// GenerateBootScriptHandler creates an HTTP handler for generating boot scripts +func GenerateBootScriptHandler(store Store, smd *smdclient.SMDClient) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(params) -} + 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 + } -// GenerateBootScriptHandler handles GET requests to generate boot scripts -func (h *BootParamsHandler) GenerateBootScriptHandler(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - 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) + } - id := r.URL.Query().Get("id") - if id == "" { - http.Error(w, "ID parameter is required", http.StatusBadRequest) - return - } + if len(templates) == 0 { + http.Error(w, "No templates found for any groups", http.StatusNotFound) + return + } - params, err := h.store.Get(id) - if err != nil { - http.Error(w, "Boot parameters not found", 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 + } + } - retry := 0 - if retryStr := r.URL.Query().Get("retry"); retryStr != "" { - if _, err := fmt.Sscanf(retryStr, "%d", &retry); err != nil { - http.Error(w, "Invalid retry parameter", http.StatusBadRequest) + 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)) } +} - arch := r.URL.Query().Get("arch") +// 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) +} - script, err := GenerateBootScript(params, retry, arch) - if err != nil { - http.Error(w, "Failed to generate boot script", http.StatusInternalServerError) - return +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 + } } - - w.Header().Set("Content-Type", "text/plain") - w.Write([]byte(script)) + return strings.TrimSpace(ip) } diff --git a/pkg/bss/handlers_test.go b/pkg/bss/handlers_test.go index fb9a679e1..c44d419ea 100644 --- a/pkg/bss/handlers_test.go +++ b/pkg/bss/handlers_test.go @@ -7,96 +7,200 @@ import ( "net/http/httptest" "testing" - "github.com/stretchr/testify/assert" + "github.com/OpenCHAMI/cloud-init/pkg/smdclient" ) -func TestBootParamsHandler(t *testing.T) { +func TestCreateBootParamsHandler(t *testing.T) { store := NewMemoryStore() - handler := NewBootParamsHandler(store) + handler := CreateBootParamsHandler(store) - // Test data - testParams := &BootParams{ - Kernel: "http://example.com/kernel", - Initrd: "http://example.com/initrd", + // 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) - // Test creating new parameters - body, _ := json.Marshal(testParams) - req := httptest.NewRequest("POST", "/bootparams?id=test", bytes.NewReader(body)) + 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.CreateBootParams(w, req) + handler(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) + } - assert.Equal(t, http.StatusCreated, w.Code) - var response BootParams - json.Unmarshal(w.Body.Bytes(), &response) - assert.Equal(t, testParams.Kernel, response.Kernel) - assert.Equal(t, testParams.Initrd, response.Initrd) - assert.Equal(t, 1, response.Version) + // 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 retrieving parameters - req = httptest.NewRequest("GET", "/bootparams?id=test", nil) + // Test non-existent ID + req = httptest.NewRequest("GET", "/bootparams/nonexistent", nil) w = httptest.NewRecorder() - handler.GetBootParams(w, req) + handler(w, req) - assert.Equal(t, http.StatusOK, w.Code) - json.Unmarshal(w.Body.Bytes(), &response) - assert.Equal(t, testParams.Kernel, response.Kernel) - assert.Equal(t, testParams.Initrd, response.Initrd) - assert.Equal(t, 1, response.Version) + 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) - // Test updating parameters - updatedParams := &BootParams{ - Kernel: "http://example.com/new-kernel", - Initrd: "http://example.com/new-initrd", + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) } - body, _ = json.Marshal(updatedParams) - req = httptest.NewRequest("PUT", "/bootparams?id=test", bytes.NewReader(body)) + + // Test missing ID + req = httptest.NewRequest("PUT", "/bootparams/", bytes.NewBuffer(body)) w = httptest.NewRecorder() - handler.UpdateBootParams(w, req) + handler(w, req) - assert.Equal(t, http.StatusOK, w.Code) - json.Unmarshal(w.Body.Bytes(), &response) - assert.Equal(t, updatedParams.Kernel, response.Kernel) - assert.Equal(t, updatedParams.Initrd, response.Initrd) - assert.Equal(t, 2, response.Version) + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status %d, got %d", http.StatusBadRequest, w.Code) + } - // Test generating boot script - req = httptest.NewRequest("GET", "/bootscript?id=test", nil) + // Test non-existent ID + req = httptest.NewRequest("PUT", "/bootparams/nonexistent", bytes.NewBuffer(body)) w = httptest.NewRecorder() - handler.GenerateBootScriptHandler(w, req) + handler(w, req) - assert.Equal(t, http.StatusOK, w.Code) - assert.Contains(t, w.Body.String(), updatedParams.Kernel) - assert.Contains(t, w.Body.String(), updatedParams.Initrd) + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status %d, got %d", http.StatusBadRequest, w.Code) + } - // Test error cases - // Missing ID - req = httptest.NewRequest("POST", "/bootparams", bytes.NewReader(body)) + // Test invalid JSON + req = httptest.NewRequest("PUT", "/bootparams/"+id, bytes.NewBuffer([]byte("invalid json"))) w = httptest.NewRecorder() - handler.CreateBootParams(w, req) - assert.Equal(t, http.StatusBadRequest, w.Code) + 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) - // Invalid JSON - req = httptest.NewRequest("POST", "/bootparams?id=test", bytes.NewReader([]byte("invalid"))) + // 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.CreateBootParams(w, req) - assert.Equal(t, http.StatusBadRequest, w.Code) + handler(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status %d, got %d", http.StatusBadRequest, w.Code) + } - // Non-existent parameters - req = httptest.NewRequest("GET", "/bootparams?id=nonexistent", nil) + // Test non-existent ID + req = httptest.NewRequest("GET", "/bootscript/nonexistent", nil) w = httptest.NewRecorder() - handler.GetBootParams(w, req) - assert.Equal(t, http.StatusNotFound, w.Code) + handler(w, req) - // Update non-existent parameters - req = httptest.NewRequest("PUT", "/bootparams?id=nonexistent", bytes.NewReader(body)) + 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.UpdateBootParams(w, req) - assert.Equal(t, http.StatusBadRequest, w.Code) + handler(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) + } - // Create with existing ID - req = httptest.NewRequest("POST", "/bootparams?id=test", bytes.NewReader(body)) + // Test with invalid retry parameter + req = httptest.NewRequest("GET", "/bootscript/"+id+"?retry=invalid", nil) w = httptest.NewRecorder() - handler.CreateBootParams(w, req) - assert.Equal(t, http.StatusBadRequest, w.Code) + handler(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status %d, got %d", http.StatusBadRequest, w.Code) + } } diff --git a/pkg/bss/router.go b/pkg/bss/router.go new file mode 100644 index 000000000..bcb6e908e --- /dev/null +++ b/pkg/bss/router.go @@ -0,0 +1,27 @@ +package bss + +import ( + "net/http" + + "github.com/OpenCHAMI/cloud-init/pkg/smdclient" + "github.com/go-chi/chi/v5" +) + +// NewRouter creates a new router with all the BSS endpoints +func NewBSSRouter(store Store, smd *smdclient.SMDClient) http.Handler { + r := chi.NewRouter() + + // 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)) + + r.Put("/bootparameters", V1AddBootParamsHandler(store, smd)) + r.Post("/bootparameters", V1AddBootParamsHandler(store, smd)) + + // Boot script endpoint + r.Get("/bootscript", GenerateBootScriptHandler(store, smd)) + + return r +} diff --git a/pkg/bss/store.go b/pkg/bss/store.go index 93d481c10..c9574a7d8 100644 --- a/pkg/bss/store.go +++ b/pkg/bss/store.go @@ -5,26 +5,55 @@ import ( "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 + // Set stores boot parameters with the given ID and creates a new version Set(id string, params *BootParams) error - // Get retrieves boot parameters by ID + // Get retrieves the latest version of boot parameters by ID Get(id string) (*BootParams, error) - // Update updates existing boot parameters and increments version + // 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]*BootParams + 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]*BootParams), + params: make(map[string]*VersionedBootParams), + v1: make(map[string]*BootParamsV1), } } @@ -37,37 +66,172 @@ func (s *MemoryStore) Set(id string, params *BootParams) error { 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 - params.Version = 1 - s.params[id] = params + versioned.Versions[0].Version = 1 + s.params[id] = versioned return nil } -// Get retrieves boot parameters by ID +// 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() - params, exists := s.params[id] + versioned, exists := s.params[id] if !exists { return nil, errors.New("boot parameters not found") } - return params, nil + 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 } -// Update updates existing boot parameters and increments version +// 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() - existing, exists := s.params[id] + 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") } - // Preserve the ID and increment version - params.Version = existing.Version + 1 - s.params[id] = params + 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 index 5d048fe4a..d534a6ff6 100644 --- a/pkg/bss/store_test.go +++ b/pkg/bss/store_test.go @@ -6,9 +6,8 @@ import ( "github.com/stretchr/testify/assert" ) -func TestMemoryStore(t *testing.T) { - store := NewMemoryStore() - +// 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) @@ -30,6 +29,20 @@ func TestMemoryStore(t *testing.T) { 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) @@ -43,14 +56,74 @@ func TestMemoryStore(t *testing.T) { err = store.Update("test", updatedParams) assert.NoError(t, err) - // Test retrieving updated parameters + // 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/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" ) From 47b93c2cb9c18a482489068493136d0d3c881be2 Mon Sep 17 00:00:00 2001 From: Alex Lovell-Troy Date: Wed, 19 Mar 2025 12:14:40 +0100 Subject: [PATCH 04/22] feat(quackstore): implement QuackStore for persistent storage with tests - Added QuackStore implementation for managing groups, instances, and cluster defaults using Quack for persistence. - Introduced unit tests for QuackStore to ensure functionality and data integrity. - Updated go.mod to include new dependencies and replaced the quack module path. - Enhanced CloudConfigFile JSON marshaling and unmarshaling for better encoding handling. --- pkg/cistore/models.go | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/pkg/cistore/models.go b/pkg/cistore/models.go index b9d5e7809..fdcc4bcd4 100644 --- a/pkg/cistore/models.go +++ b/pkg/cistore/models.go @@ -1,8 +1,10 @@ package cistore import ( + "encoding/base64" "encoding/json" "errors" + "fmt" base "github.com/Cray-HPE/hms-base" ) @@ -73,11 +75,20 @@ type CloudConfigFile struct { // MarshalJSON implements json.Marshaler func (f CloudConfigFile) MarshalJSON() ([]byte, error) { type Alias CloudConfigFile + var content string + switch f.Encoding { + case "base64": + content = base64.StdEncoding.EncodeToString(f.Content) + case "plain", "": + content = string(f.Content) + default: + return nil, fmt.Errorf("unsupported encoding: %s", f.Encoding) + } return json.Marshal(&struct { Content string `json:"content"` *Alias }{ - Content: string(f.Content), + Content: content, Alias: (*Alias)(&f), }) } @@ -91,11 +102,20 @@ func (f *CloudConfigFile) UnmarshalJSON(data []byte) error { }{ Alias: (*Alias)(f), } - if err := json.Unmarshal(data, &aux); err != nil { return err } - - f.Content = []byte(aux.Content) + switch f.Encoding { + case "base64": + content, err := base64.StdEncoding.DecodeString(aux.Content) + if err != nil { + return err + } + f.Content = content + case "plain", "": + f.Content = []byte(aux.Content) + default: + return fmt.Errorf("unsupported encoding: %s", f.Encoding) + } return nil } From cf6de8d9397e92324dc312bc6e091870b97e0b1c Mon Sep 17 00:00:00 2001 From: Alex Lovell-Troy Date: Wed, 19 Mar 2025 12:17:32 +0100 Subject: [PATCH 05/22] test(memstore): add unit tests for MemStore implementation - Introduced a new test file for MemStore to validate its functionality. - Integrated standard test suite from cistore testing package to ensure comprehensive testing. --- internal/memstore/ciMemStore_test.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 internal/memstore/ciMemStore_test.go 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) +} From 7c48b3aade3a9e4f14432ca1ee0bd84660707af4 Mon Sep 17 00:00:00 2001 From: Alex Lovell-Troy Date: Wed, 19 Mar 2025 16:57:14 +0100 Subject: [PATCH 06/22] feat(cloud-init-server): add support for multiple storage backends - Introduced a new command-line flag for selecting the storage backend (mem or quack). - Default storage backend set to memstore, with a new option for quackstore using a specified database path. - Updated the datastore initialization logic to handle both memstore and quackstore, including error handling for quackstore initialization. --- cmd/cloud-init-server/main.go | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/cmd/cloud-init-server/main.go b/cmd/cloud-init-server/main.go index 82fd1c66d..6f48935b2 100644 --- a/cmd/cloud-init-server/main.go +++ b/cmd/cloud-init-server/main.go @@ -15,6 +15,7 @@ import ( "time" "github.com/OpenCHAMI/cloud-init/internal/memstore" + "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" @@ -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) From aee755f37e6b6f1332d87702b42e4891ce659fad Mon Sep 17 00:00:00 2001 From: Devon Bautista Date: Wed, 19 Mar 2025 15:54:01 -0600 Subject: [PATCH 07/22] fix: correct UnmarshalJSON and add MarshalJSON for CloudConfigFile --- pkg/cistore/models.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/cistore/models.go b/pkg/cistore/models.go index fdcc4bcd4..a273c6450 100644 --- a/pkg/cistore/models.go +++ b/pkg/cistore/models.go @@ -95,6 +95,9 @@ func (f CloudConfigFile) MarshalJSON() ([]byte, error) { // UnmarshalJSON implements json.Unmarshaler func (f *CloudConfigFile) UnmarshalJSON(data []byte) error { + // Use temporary struct so json.Unmarshal does not recurse indefinitely. + // Also to convert Content from bytes to string so json.Unmarshal + // doesn't try to base64 decode the bytes. type Alias CloudConfigFile aux := &struct { Content string `json:"content"` From 714e64bc937ad9a6896bbba78819344b245f747c Mon Sep 17 00:00:00 2001 From: Devon Bautista Date: Thu, 20 Mar 2025 11:08:15 -0600 Subject: [PATCH 08/22] chore: clarify comments in custom marshal/unmarshal funcs --- pkg/cistore/models.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/pkg/cistore/models.go b/pkg/cistore/models.go index a273c6450..cbfea7ef7 100644 --- a/pkg/cistore/models.go +++ b/pkg/cistore/models.go @@ -95,9 +95,14 @@ func (f CloudConfigFile) MarshalJSON() ([]byte, error) { // UnmarshalJSON implements json.Unmarshaler func (f *CloudConfigFile) UnmarshalJSON(data []byte) error { - // Use temporary struct so json.Unmarshal does not recurse indefinitely. - // Also to convert Content from bytes to string so json.Unmarshal - // doesn't try to base64 decode the bytes. + // Use an auxiliary struct so that: + // + // 1. json.Unmarshal doesn't recurse forever and overflow the stack. + // 2. json.Unmarshal doesn't try to base64-decode "content" in the data + // before assigning the bytes to f.Content. Content is unmarshalled + // as a string instead of bytes in order to prevent this. After + // unmarshalling, the string is converted back to bytes and assigned + // to f.Content. type Alias CloudConfigFile aux := &struct { Content string `json:"content"` From 7a42fd579a6e1499b20bab5f202cc67ed4221259 Mon Sep 17 00:00:00 2001 From: Alex Lovell-Troy Date: Mon, 24 Mar 2025 12:27:35 -0400 Subject: [PATCH 09/22] test: add unit tests for CloudConfigFile JSON marshal/unmarshal --- pkg/cistore/models_test.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/pkg/cistore/models_test.go b/pkg/cistore/models_test.go index 96b2d92ad..0ad9cb96d 100644 --- a/pkg/cistore/models_test.go +++ b/pkg/cistore/models_test.go @@ -59,12 +59,10 @@ func TestCloudConfigFile_MarshalJSON_Plain(t *testing.T) { } func TestCloudConfigFile_MarshalJSON_Base64(t *testing.T) { - originalConfig := "#cloud-config\nusers:\n - name: test" - b64Config := base64.StdEncoding.EncodeToString([]byte(originalConfig)) f := CloudConfigFile{ Name: "encodedconfig.yaml", Encoding: "base64", - Content: []byte(b64Config), + Content: []byte("#cloud-config\nusers:\n - name: test"), } data, err := json.Marshal(f) @@ -75,5 +73,5 @@ func TestCloudConfigFile_MarshalJSON_Base64(t *testing.T) { assert.NoError(t, err) assert.Equal(t, "encodedconfig.yaml", out["filename"]) assert.Equal(t, "base64", out["encoding"]) - assert.Equal(t, b64Config, out["content"]) + assert.Equal(t, "#cloud-config\nusers:\n - name: test", out["content"]) } From 6232a2a0b4686d0dbb650bb0ea0f9db1fd1a12f0 Mon Sep 17 00:00:00 2001 From: Devon Bautista Date: Mon, 24 Mar 2025 10:46:06 -0600 Subject: [PATCH 10/22] fix(tests): use base64-encoded data for base64 marshal test --- pkg/cistore/models_test.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pkg/cistore/models_test.go b/pkg/cistore/models_test.go index 0ad9cb96d..96b2d92ad 100644 --- a/pkg/cistore/models_test.go +++ b/pkg/cistore/models_test.go @@ -59,10 +59,12 @@ func TestCloudConfigFile_MarshalJSON_Plain(t *testing.T) { } func TestCloudConfigFile_MarshalJSON_Base64(t *testing.T) { + originalConfig := "#cloud-config\nusers:\n - name: test" + b64Config := base64.StdEncoding.EncodeToString([]byte(originalConfig)) f := CloudConfigFile{ Name: "encodedconfig.yaml", Encoding: "base64", - Content: []byte("#cloud-config\nusers:\n - name: test"), + Content: []byte(b64Config), } data, err := json.Marshal(f) @@ -73,5 +75,5 @@ func TestCloudConfigFile_MarshalJSON_Base64(t *testing.T) { assert.NoError(t, err) assert.Equal(t, "encodedconfig.yaml", out["filename"]) assert.Equal(t, "base64", out["encoding"]) - assert.Equal(t, "#cloud-config\nusers:\n - name: test", out["content"]) + assert.Equal(t, b64Config, out["content"]) } From 8e33ba53aa316688503c9ea0e65f41df64003bf9 Mon Sep 17 00:00:00 2001 From: Alex Lovell-Troy Date: Mon, 24 Mar 2025 14:08:28 -0400 Subject: [PATCH 11/22] refactor: Use an alternate Unmarshal implementation --- pkg/cistore/models.go | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/pkg/cistore/models.go b/pkg/cistore/models.go index cbfea7ef7..b546bcd92 100644 --- a/pkg/cistore/models.go +++ b/pkg/cistore/models.go @@ -1,10 +1,8 @@ package cistore import ( - "encoding/base64" "encoding/json" "errors" - "fmt" base "github.com/Cray-HPE/hms-base" ) @@ -72,27 +70,6 @@ type CloudConfigFile struct { Encoding string `json:"encoding,omitempty" enums:"base64,plain"` } -// MarshalJSON implements json.Marshaler -func (f CloudConfigFile) MarshalJSON() ([]byte, error) { - type Alias CloudConfigFile - var content string - switch f.Encoding { - case "base64": - content = base64.StdEncoding.EncodeToString(f.Content) - case "plain", "": - content = string(f.Content) - default: - return nil, fmt.Errorf("unsupported encoding: %s", f.Encoding) - } - return json.Marshal(&struct { - Content string `json:"content"` - *Alias - }{ - Content: content, - Alias: (*Alias)(&f), - }) -} - // UnmarshalJSON implements json.Unmarshaler func (f *CloudConfigFile) UnmarshalJSON(data []byte) error { // Use an auxiliary struct so that: From 80068bee904fea40961d2e02dd2b23832668a775 Mon Sep 17 00:00:00 2001 From: Alex Lovell-Troy Date: Fri, 18 Apr 2025 15:32:22 -0400 Subject: [PATCH 12/22] chore: add base64 encoding import for future functionality --- pkg/cistore/models.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/cistore/models.go b/pkg/cistore/models.go index b546bcd92..ec02c4934 100644 --- a/pkg/cistore/models.go +++ b/pkg/cistore/models.go @@ -1,8 +1,10 @@ package cistore import ( + "encoding/base64" "encoding/json" "errors" + "fmt" base "github.com/Cray-HPE/hms-base" ) From f85096b8e3b687addb4f5d137ecb8779031f98ec Mon Sep 17 00:00:00 2001 From: Alex Lovell-Troy Date: Fri, 18 Apr 2025 15:37:43 -0400 Subject: [PATCH 13/22] refactor: simplify UnmarshalJSON and add MarshalJSON for CloudConfigFile - Removed base64 encoding handling from UnmarshalJSON for clarity. - Added MarshalJSON implementation to ensure proper JSON serialization of CloudConfigFile. --- pkg/cistore/models.go | 36 ++++++++++++++---------------------- 1 file changed, 14 insertions(+), 22 deletions(-) diff --git a/pkg/cistore/models.go b/pkg/cistore/models.go index ec02c4934..5e6aabe1d 100644 --- a/pkg/cistore/models.go +++ b/pkg/cistore/models.go @@ -1,10 +1,8 @@ package cistore import ( - "encoding/base64" "encoding/json" "errors" - "fmt" base "github.com/Cray-HPE/hms-base" ) @@ -74,14 +72,6 @@ type CloudConfigFile struct { // UnmarshalJSON implements json.Unmarshaler func (f *CloudConfigFile) UnmarshalJSON(data []byte) error { - // Use an auxiliary struct so that: - // - // 1. json.Unmarshal doesn't recurse forever and overflow the stack. - // 2. json.Unmarshal doesn't try to base64-decode "content" in the data - // before assigning the bytes to f.Content. Content is unmarshalled - // as a string instead of bytes in order to prevent this. After - // unmarshalling, the string is converted back to bytes and assigned - // to f.Content. type Alias CloudConfigFile aux := &struct { Content string `json:"content"` @@ -92,17 +82,19 @@ func (f *CloudConfigFile) UnmarshalJSON(data []byte) error { if err := json.Unmarshal(data, &aux); err != nil { return err } - switch f.Encoding { - case "base64": - content, err := base64.StdEncoding.DecodeString(aux.Content) - if err != nil { - return err - } - f.Content = content - case "plain", "": - f.Content = []byte(aux.Content) - default: - return fmt.Errorf("unsupported encoding: %s", f.Encoding) - } + f.Content = []byte(aux.Content) return nil } + +// MarshalJSON implements json.Marshaler +func (f CloudConfigFile) MarshalJSON() ([]byte, error) { + type Alias CloudConfigFile + aux := &struct { + Content string `json:"content"` + *Alias + }{ + Content: string(f.Content), + Alias: (*Alias)(&f), + } + return json.Marshal(aux) +} From b1769db8d40988c537d0430308115d2438193e7b Mon Sep 17 00:00:00 2001 From: Alex Lovell-Troy Date: Wed, 23 Apr 2025 14:55:22 -0400 Subject: [PATCH 14/22] feat(quackstore): implement unique instance ID generation - Added functionality to generate a unique instance ID in the format "i-XXXXXX" using a random 6-digit hexadecimal string. - Updated the comment to clarify the instance ID generation process. --- internal/quackstore/quackstore.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) 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) } From 0ff39706886fd47439797e8ca06dbd591ffb4f7b Mon Sep 17 00:00:00 2001 From: Devon Bautista Date: Wed, 19 Mar 2025 15:54:01 -0600 Subject: [PATCH 15/22] fix: correct UnmarshalJSON and add MarshalJSON for CloudConfigFile --- pkg/cistore/models.go | 34 +++++++++++++++++++++------------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/pkg/cistore/models.go b/pkg/cistore/models.go index 5e6aabe1d..ef9af9d2f 100644 --- a/pkg/cistore/models.go +++ b/pkg/cistore/models.go @@ -1,8 +1,10 @@ package cistore import ( + "encoding/base64" "encoding/json" "errors" + "fmt" base "github.com/Cray-HPE/hms-base" ) @@ -72,6 +74,9 @@ type CloudConfigFile struct { // UnmarshalJSON implements json.Unmarshaler func (f *CloudConfigFile) UnmarshalJSON(data []byte) error { + // Use temporary struct so json.Unmarshal does not recurse indefinitely. + // Also to convert Content from bytes to string so json.Unmarshal + // doesn't try to base64 decode the bytes. type Alias CloudConfigFile aux := &struct { Content string `json:"content"` @@ -79,22 +84,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) - return nil -} -// MarshalJSON implements json.Marshaler -func (f CloudConfigFile) MarshalJSON() ([]byte, error) { - type Alias CloudConfigFile - aux := &struct { - Content string `json:"content"` - *Alias - }{ - Content: string(f.Content), - Alias: (*Alias)(&f), + // 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 json.Marshal(aux) + + return nil } From ab1e3d7215f1854e26e5bd544e8e637cec3e4a61 Mon Sep 17 00:00:00 2001 From: Devon Bautista Date: Thu, 20 Mar 2025 11:08:15 -0600 Subject: [PATCH 16/22] chore: clarify comments in custom marshal/unmarshal funcs --- pkg/cistore/models.go | 35 ++++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/pkg/cistore/models.go b/pkg/cistore/models.go index ef9af9d2f..1ccdcb82c 100644 --- a/pkg/cistore/models.go +++ b/pkg/cistore/models.go @@ -74,9 +74,14 @@ type CloudConfigFile struct { // UnmarshalJSON implements json.Unmarshaler func (f *CloudConfigFile) UnmarshalJSON(data []byte) error { - // Use temporary struct so json.Unmarshal does not recurse indefinitely. - // Also to convert Content from bytes to string so json.Unmarshal - // doesn't try to base64 decode the bytes. + // Use an auxiliary struct so that: + // + // 1. json.Unmarshal doesn't recurse forever and overflow the stack. + // 2. json.Unmarshal doesn't try to base64-decode "content" in the data + // before assigning the bytes to f.Content. Content is unmarshalled + // as a string instead of bytes in order to prevent this. After + // unmarshalling, the string is converted back to bytes and assigned + // to f.Content. type Alias CloudConfigFile aux := &struct { Content string `json:"content"` @@ -90,6 +95,7 @@ func (f *CloudConfigFile) UnmarshalJSON(data []byte) error { return err } +<<<<<<< HEAD // Handle encoding switch aux.Encoding { case "base64": @@ -102,6 +108,29 @@ func (f *CloudConfigFile) UnmarshalJSON(data []byte) error { f.Content = []byte(aux.Content) default: return fmt.Errorf("unsupported encoding: %s", aux.Encoding) +======= + f.Content = []byte(aux.Content) + return nil +} + +// Custom marshaler for CloudConfigFile +func (f CloudConfigFile) MarshalJSON() ([]byte, error) { + // Use temporary struct to marshal so json.Marshal doesn't recurse + // indefinitely. Also to convert Content from bytes to string so + // json.Marshal doesn't try to base64 encode the bytes. + // Use an auxiliary struct so that: + // + // 1. json.Marshal doesn't recurse forever and overflow the stack. + // 2. json.Marshal doesn't try to base64-encode f.Content. f.Content is + // converted from bytes to a string and then assigned to aux.Content + // to prevent this. Then, aux gets marshalled instead of f. + type Alias CloudConfigFile + aux := &struct { + Content string `json:"content"` + Alias + }{ + Alias: (Alias)(f), +>>>>>>> cf9a810 (chore: clarify comments in custom marshal/unmarshal funcs) } return nil From 99b3731523536731fe3239e21a4c4f8919433d5b Mon Sep 17 00:00:00 2001 From: Alex Lovell-Troy Date: Wed, 19 Mar 2025 12:14:40 +0100 Subject: [PATCH 17/22] feat(quackstore): implement QuackStore for persistent storage with tests - Added QuackStore implementation for managing groups, instances, and cluster defaults using Quack for persistence. - Introduced unit tests for QuackStore to ensure functionality and data integrity. - Updated go.mod to include new dependencies and replaced the quack module path. - Enhanced CloudConfigFile JSON marshaling and unmarshaling for better encoding handling. --- pkg/cistore/models.go | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/pkg/cistore/models.go b/pkg/cistore/models.go index 1ccdcb82c..d2dc911be 100644 --- a/pkg/cistore/models.go +++ b/pkg/cistore/models.go @@ -95,7 +95,6 @@ func (f *CloudConfigFile) UnmarshalJSON(data []byte) error { return err } -<<<<<<< HEAD // Handle encoding switch aux.Encoding { case "base64": @@ -108,8 +107,8 @@ func (f *CloudConfigFile) UnmarshalJSON(data []byte) error { f.Content = []byte(aux.Content) default: return fmt.Errorf("unsupported encoding: %s", aux.Encoding) -======= - f.Content = []byte(aux.Content) + } + return nil } @@ -130,8 +129,7 @@ func (f CloudConfigFile) MarshalJSON() ([]byte, error) { Alias }{ Alias: (Alias)(f), ->>>>>>> cf9a810 (chore: clarify comments in custom marshal/unmarshal funcs) } - return nil + return json.Marshal(aux) } From 9cedbd44c3d40df213dcf096d92ae32664cf68be Mon Sep 17 00:00:00 2001 From: Alex Lovell-Troy Date: Thu, 24 Apr 2025 10:00:55 -0400 Subject: [PATCH 18/22] feat(bss): add BSS API endpoints for boot parameters and scripts - Introduced new BSS API endpoints for managing boot parameters and generating boot scripts. - Added handlers for creating, updating, and retrieving boot parameters. - Implemented routing for BSS endpoints in the main application. - Updated go.mod to include new dependencies for BSS functionality. - Enhanced documentation for the new API endpoints with Swagger annotations. --- cmd/cloud-init-server/main.go | 32 ++++++ go.mod | 77 ++++++------- go.sum | 208 +++++++++++++++++----------------- pkg/bss/handlers.go | 44 +++++++ pkg/bss/router.go | 33 +++--- 5 files changed, 239 insertions(+), 155 deletions(-) diff --git a/cmd/cloud-init-server/main.go b/cmd/cloud-init-server/main.go index 6f48935b2..f8cc2850c 100644 --- a/cmd/cloud-init-server/main.go +++ b/cmd/cloud-init-server/main.go @@ -16,6 +16,7 @@ import ( "github.com/OpenCHAMI/cloud-init/internal/memstore" "github.com/OpenCHAMI/cloud-init/internal/quackstore" + "github.com/OpenCHAMI/cloud-init/pkg/bss" "github.com/OpenCHAMI/cloud-init/pkg/cistore" "github.com/OpenCHAMI/cloud-init/pkg/smdclient" "github.com/OpenCHAMI/cloud-init/pkg/wgtunnel" @@ -53,6 +54,7 @@ var ( wireGuardMiddleware func(http.Handler) http.Handler storageBackend = "mem" // Default to memstore dbPath = "cloud-init.db" // Default database path for quackstore + bssEnabled = false ) func main() { @@ -74,6 +76,7 @@ func main() { 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.BoolVar(&bssEnabled, "bss", bssEnabled, "Enable BSS endpoints") flag.Parse() if debug { @@ -209,6 +212,11 @@ func main() { initCiAdminRouter(router_admin, ciHandler) router.Mount("/cloud-init/admin", router_admin) + if bssEnabled { + bssStorage := bss.NewMemoryStore() + initBssRouter(router, ciHandler, bssStorage) + } + // Serve all routes log.Fatal().Err(http.ListenAndServe(ciEndpoint, router)).Msg("Server closed") } @@ -258,3 +266,27 @@ func initCiAdminRouter(router chi.Router, handler *CiHandler) { }) } + +func initBssRouter(router chi.Router, handler *CiHandler, bssStorage bss.Store) { + // BSS API subrouter + router.Route("/boot/v1/", func(r chi.Router) { + + // Boot parameters endpoints + 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)) + + // Boot script endpoint + r.Get("/bootscript", bss.GenerateBootScriptHandler(bssStorage, handler.sm.(*smdclient.SMDClient))) + + }) + 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)) + // Boot script endpoint + r.Get("/bootscript", bss.GenerateBootScriptHandler(bssStorage, handler.sm.(*smdclient.SMDClient))) + }) + +} diff --git a/go.mod b/go.mod index 5ec5ae461..f984ae729 100644 --- a/go.mod +++ b/go.mod @@ -1,85 +1,86 @@ module github.com/OpenCHAMI/cloud-init -go 1.23 +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/smd/v2 v2.17.7 - github.com/go-chi/chi/v5 v5.1.0 + 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.3 + 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 - github.com/rs/zerolog v1.33.0 + github.com/rs/zerolog v1.34.0 github.com/stretchr/testify v1.10.0 github.com/swaggo/swag v1.16.4 gopkg.in/yaml.v3 v3.0.1 ) require ( + github.com/Cray-HPE/hms-base/v2 v2.3.0 // indirect + github.com/Cray-HPE/hms-xname v1.4.0 // indirect github.com/KyleBanks/depth v1.2.1 // indirect - github.com/PuerkitoBio/purell v1.1.1 // indirect - github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect - github.com/apache/arrow/go/v14 v14.0.2 // indirect + github.com/apache/arrow-go/v18 v18.2.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect - github.com/go-jose/go-jose/v4 v4.0.4 // indirect - github.com/go-openapi/jsonpointer v0.19.5 // indirect - github.com/go-openapi/jsonreference v0.19.6 // indirect - github.com/go-openapi/spec v0.20.4 // indirect - github.com/go-openapi/swag v0.19.15 // indirect - github.com/goccy/go-json v0.10.4 // indirect - github.com/google/flatbuffers v23.5.26+incompatible // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.0 // indirect + github.com/go-openapi/jsonpointer v0.21.1 // indirect + github.com/go-openapi/jsonreference v0.21.0 // indirect + github.com/go-openapi/spec v0.21.0 // indirect + github.com/go-openapi/swag v0.23.1 // indirect + 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/josharian/intern v1.0.0 // indirect - github.com/klauspost/compress v1.16.7 // indirect - github.com/klauspost/cpuid/v2 v2.2.5 // indirect - github.com/lestrrat-go/blackmagic v1.0.2 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/cpuid/v2 v2.2.10 // indirect + github.com/lestrrat-go/blackmagic v1.0.3 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect github.com/lestrrat-go/httprc v1.0.6 // indirect github.com/lestrrat-go/iter v1.0.2 // indirect github.com/lestrrat-go/option v1.0.1 // indirect - github.com/mailru/easyjson v0.7.7 // indirect - github.com/marcboeker/go-duckdb v1.7.0 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mailru/easyjson v0.9.0 // indirect + github.com/marcboeker/go-duckdb v1.8.5 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/pierrec/lz4/v4 v4.1.18 // indirect + github.com/pierrec/lz4/v4 v4.1.22 // indirect 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/zeebo/xxh3 v1.0.2 // indirect - golang.org/x/mod v0.17.0 // indirect - golang.org/x/sync v0.10.0 // indirect - golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect - golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect + golang.org/x/mod v0.24.0 // indirect + golang.org/x/sync v0.13.0 // indirect + golang.org/x/tools v0.32.0 // indirect + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect ) require ( github.com/Cray-HPE/hms-base v1.15.1 - github.com/Cray-HPE/hms-certs v1.5.0 // indirect - github.com/Cray-HPE/hms-securestorage v1.14.0 // indirect - github.com/fsnotify/fsnotify v1.8.0 // indirect + github.com/Cray-HPE/hms-certs v1.7.0 // indirect + github.com/Cray-HPE/hms-securestorage v1.17.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-retryablehttp v0.7.7 // indirect github.com/hashicorp/go-rootcerts v1.0.2 // indirect - github.com/hashicorp/go-secure-stdlib/parseutil v0.1.8 // indirect + github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect github.com/hashicorp/go-sockaddr v1.0.7 // indirect github.com/hashicorp/hcl v1.0.1-vault-7 // indirect - github.com/hashicorp/vault/api v1.15.0 // indirect + github.com/hashicorp/vault/api v1.16.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect 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 - golang.org/x/crypto v0.31.0 // indirect - golang.org/x/net v0.33.0 // indirect - golang.org/x/sys v0.28.0 // indirect - golang.org/x/text v0.21.0 // indirect - golang.org/x/time v0.8.0 // indirect + golang.org/x/crypto v0.37.0 // indirect + golang.org/x/net v0.39.0 // indirect + golang.org/x/sys v0.32.0 // indirect + golang.org/x/text v0.24.0 // indirect + golang.org/x/time v0.11.0 // indirect gopkg.in/yaml.v2 v2.4.0 ) diff --git a/go.sum b/go.sum index 9140e2e30..2d303b1be 100644 --- a/go.sum +++ b/go.sum @@ -1,59 +1,68 @@ github.com/Cray-HPE/hms-base v1.15.1 h1:+f9cl9BsDWvewvGBPzinmBSU//I7yhwaSUTaNUwxwxQ= github.com/Cray-HPE/hms-base v1.15.1/go.mod h1:+G8KFLPtanLC5lQ602hrf3MDfLTmIXedTavVCOdz5XA= -github.com/Cray-HPE/hms-certs v1.5.0 h1:FzsYDzXxvAGBYtrSJsUj2pPLEykeiCtlLpWsa+RZMU0= -github.com/Cray-HPE/hms-certs v1.5.0/go.mod h1:jtnNV3KcxUgoWgA2mMKJtyyoKBnk+ABo86UGxYStH9o= -github.com/Cray-HPE/hms-securestorage v1.14.0 h1:9s9eoUM6HYwOms16CK9hHBxk9Sr1RM+MmYpIKvzsyR8= -github.com/Cray-HPE/hms-securestorage v1.14.0/go.mod h1:izwfNP4T+AATt+XRfv4i9QjODL5Adpb/0nvby6YxBww= +github.com/Cray-HPE/hms-base/v2 v2.2.1-0.20250414152022-3ab999198b9c h1:z/4etf7PvM5MIwKstKVRx1uV8SodFiivRlXUZYSgGT4= +github.com/Cray-HPE/hms-base/v2 v2.2.1-0.20250414152022-3ab999198b9c/go.mod h1:vE93SJ+Dptk1Y5+C/DKJPt/tjCZs3HzZUbZaEnfgnug= +github.com/Cray-HPE/hms-base/v2 v2.3.0 h1:BjUWnxHplxg+YqjHaEcFH6TvsFb6cqYN3WI2JA6KNTo= +github.com/Cray-HPE/hms-base/v2 v2.3.0/go.mod h1:vE93SJ+Dptk1Y5+C/DKJPt/tjCZs3HzZUbZaEnfgnug= +github.com/Cray-HPE/hms-certs v1.7.0 h1:iNzKUJMRImKC+2IVx8vYiPD6ii95AQoZx9Y+DAbJgtk= +github.com/Cray-HPE/hms-certs v1.7.0/go.mod h1:OJ1pVX7rnA7fwBcucbFLq00Cf/2k4V6lDyOuROh8Hs4= +github.com/Cray-HPE/hms-securestorage v1.17.0 h1:9Dr96LITvt9hqs+/CIDFNOauUPSTobU8TC3jEfd8I/A= +github.com/Cray-HPE/hms-securestorage v1.17.0/go.mod h1:XYnykBCkkdWCLsMNjoMNmYfFn9UDFc02UN16k8ZHktE= +github.com/Cray-HPE/hms-xname v1.4.0 h1:i47YmE8rbSfJ64simKCCC6ZVcGid3rDIX6/jfVbISAM= +github.com/Cray-HPE/hms-xname v1.4.0/go.mod h1:wH7t1UXYck0VdHSWjrMsxZmaCK5W1lmwgNnsYAFPTus= 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/smd/v2 v2.17.7 h1:TleOZtY+wJTQiBDNZQ7mqFUdEFDFitRG0wQo3cZe2Lg= -github.com/OpenCHAMI/smd/v2 v2.17.7/go.mod h1:RtFmMtTYmMUc74gw4FnfZKDpFMTQJeemAVXB0O+Qaa0= -github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= -github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= -github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= -github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= -github.com/apache/arrow/go/v14 v14.0.2 h1:N8OkaJEOfI3mEZt07BIkvo4sC6XDbL+48MBPWO5IONw= -github.com/apache/arrow/go/v14 v14.0.2/go.mod h1:u3fgh3EdgN/YQ8cVQRguVW3R+seMybFg8QBQ5LU+eBY= +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= +github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= +github.com/apache/arrow-go/v18 v18.1.0 h1:agLwJUiVuwXZdwPYVrlITfx7bndULJ/dggbnLFgDp/Y= +github.com/apache/arrow-go/v18 v18.1.0/go.mod h1:tigU/sIgKNXaesf5d7Y95jBBKS5KsxTqYBKXFsvKzo0= +github.com/apache/arrow-go/v18 v18.2.0 h1:QhWqpgZMKfWOniGPhbUxrHohWnooGURqL2R2Gg4SO1Q= +github.com/apache/arrow-go/v18 v18.2.0/go.mod h1:Ic/01WSwGJWRrdAZcxjBZ5hbApNJ28K96jGYaxzzGUc= +github.com/apache/thrift v0.21.0 h1:tdPmh/ptjE1IJnhbhrcl2++TauVjy242rkV/UzJChnE= +github.com/apache/thrift v0.21.0/go.mod h1:W1H8aR/QRtYNvrPeFXBtobyRkd0/YVhTc6i07XIAgDw= 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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 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= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= -github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= -github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= -github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= -github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E= -github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc= -github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= -github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= -github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= -github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs= -github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns= -github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M= -github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I= -github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= -github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= -github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= +github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-jose/go-jose/v4 v4.1.0 h1:cYSYxd3pw5zd2FSXk2vGdn9igQU2PS8MuxrCOCl0FdY= +github.com/go-jose/go-jose/v4 v4.1.0/go.mod h1:GG/vqmYm3Von2nYiB2vGTXzdoNKE5tix5tuc6iAd+sw= +github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic= +github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= +github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= +github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= +github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY= +github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= +github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= +github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg= github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= -github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM= -github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= +github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/google/flatbuffers v23.5.26+incompatible h1:M9dgRyhJemaM4Sw8+66GHBu8ioaQmyPLg1b8VwK5WJg= -github.com/google/flatbuffers v23.5.26+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q= +github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -72,72 +81,75 @@ github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISH github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= -github.com/hashicorp/go-secure-stdlib/parseutil v0.1.8 h1:iBt4Ew4XEGLfh6/bPk4rSYmuZJGizr6/x/AEizP0CQc= -github.com/hashicorp/go-secure-stdlib/parseutil v0.1.8/go.mod h1:aiJI+PIApBRQG7FZTEBx5GiiX+HbOHilUdNxUZi4eV0= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= -github.com/hashicorp/vault/api v1.15.0 h1:O24FYQCWwhwKnF7CuSqP30S51rTV7vz1iACXE/pj5DA= -github.com/hashicorp/vault/api v1.15.0/go.mod h1:+5YTO09JGn0u+b6ySD/LLVf8WkJCPLAL2Vkmrn2+CM8= +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/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/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I= -github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= -github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg= -github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/klauspost/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4= +github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k= -github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= +github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs= +github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k= github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= -github.com/lestrrat-go/jwx/v2 v2.1.3 h1:Ud4lb2QuxRClYAmRleF50KrbKIoM1TddXgBrneT5/Jo= -github.com/lestrrat-go/jwx/v2 v2.1.3/go.mod h1:q6uFgbgZfEmQrfJfrCo90QcQOcXFMfbI/fO0NqRtvZo= +github.com/lestrrat-go/jwx/v2 v2.1.5 h1:PQI5gzadLfJ22ckLrejPVX6eGXKM4M4eGi5fW2jjA3o= +github.com/lestrrat-go/jwx/v2 v2.1.5/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= -github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= -github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= -github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/marcboeker/go-duckdb v1.7.0 h1:c9DrS13ta+gqVgg9DiEW8I+PZBE85nBMLL/YMooYoUY= -github.com/marcboeker/go-duckdb v1.7.0/go.mod h1:WtWeqqhZoTke/Nbd7V9lnBx7I2/A/q0SAq/urGzPCMs= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/marcboeker/go-duckdb v1.8.5 h1:tkYp+TANippy0DaIOP5OEfBEwbUINqiFqgwMQ44jME0= +github.com/marcboeker/go-duckdb v1.8.5/go.mod h1:6mK7+WQE4P4u5AFLvVBmhFxY5fvhymFptghgJX6B+/8= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 h1:AMFGa4R4MiIpspGNG7Z948v4n35fFGB3RR3G/ry4FWs= +github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY= +github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 h1:+n/aFZefKZp7spd8DFdX7uMikMLXX4oubIzJF4kv/wI= +github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= -github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/openchami/chi-middleware/auth v0.0.0-20240812224658-b16b83c70700 h1:XADGipD2FZ9swuFUqeL7h63j3voiq9qA7P0aKsqgZKg= github.com/openchami/chi-middleware/auth v0.0.0-20240812224658-b16b83c70700/go.mod h1:kswb9kU5cZAFRAvf1dAUJRWbQyjDEb0qkxW4ncDdEXg= github.com/openchami/chi-middleware/log v0.0.0-20240812224658-b16b83c70700 h1:Gzt5f6RK39CHvY3SJudzBb/RK4tVh/S3CpJ0eQlbNdg= github.com/openchami/chi-middleware/log v0.0.0-20240812224658-b16b83c70700/go.mod h1:UuXvr2loD4MtvZeKr57W0WpBs+gm0KM1kdtcXrE8M6s= -github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ= -github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= +github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= -github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= -github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= +github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +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/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= @@ -145,7 +157,6 @@ github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 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= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -157,49 +168,40 @@ github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= -golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= -golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= -golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= -golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c h1:KL/ZBHXgKGVmuZBZ01Lt57yE5ws8ZPSkkihmEyq7FXc= +golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= +golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= +golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= +golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= -golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= -golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= -gonum.org/v1/gonum v0.12.0 h1:xKuo6hzt+gMav00meVPUlXwSdoEJP46BR+wdxQEFK2o= -gonum.org/v1/gonum v0.12.0/go.mod h1:73TDxJfAAHeA8Mk9mf8NlIppyhQNo5GLTcYeqgo2lvY= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= +golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU= +golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +gonum.org/v1/gonum v0.15.1 h1:FNy7N6OUZVUaWG9pTiD+jlhdQ3lMP+/LcTpJ6+a8sQ0= +gonum.org/v1/gonum v0.15.1/go.mod h1:eZTZuRFrzu5pcyjN5wJhcIhnUdNijYxX1T2IcrOGY0o= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= -gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/bss/handlers.go b/pkg/bss/handlers.go index 10e9af8f2..e92586e66 100644 --- a/pkg/bss/handlers.go +++ b/pkg/bss/handlers.go @@ -15,6 +15,16 @@ import ( ) // 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 { @@ -63,6 +73,18 @@ func CreateBootParamsHandler(store Store) http.HandlerFunc { } // 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 { @@ -113,6 +135,15 @@ func UpdateBootParamsHandler(store Store) http.HandlerFunc { } // 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 { @@ -139,6 +170,19 @@ func GetBootParamsHandler(store Store) http.HandlerFunc { } // 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.SMDClient) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { diff --git a/pkg/bss/router.go b/pkg/bss/router.go index bcb6e908e..ca5215c20 100644 --- a/pkg/bss/router.go +++ b/pkg/bss/router.go @@ -1,27 +1,32 @@ package bss import ( - "net/http" - "github.com/OpenCHAMI/cloud-init/pkg/smdclient" "github.com/go-chi/chi/v5" ) -// NewRouter creates a new router with all the BSS endpoints -func NewBSSRouter(store Store, smd *smdclient.SMDClient) http.Handler { +// 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) { - // 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)) - - r.Put("/bootparameters", V1AddBootParamsHandler(store, smd)) - r.Post("/bootparameters", V1AddBootParamsHandler(store, smd)) + r.Put("/bootparameters", V1AddBootParamsHandler(store, smd)) + r.Post("/bootparameters", V1AddBootParamsHandler(store, smd)) - // Boot script endpoint - r.Get("/bootscript", GenerateBootScriptHandler(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 } From 3dbbefb489f6c9612af7edf9143b3eef5fdcc896 Mon Sep 17 00:00:00 2001 From: Alex Lovell-Troy Date: Fri, 2 May 2025 08:28:47 -0400 Subject: [PATCH 19/22] feat: Add netboot server implementation and API endpoints for boot parameters - Introduced a new netboot server in cmd/netboot-server/main.go. - Updated .goreleaser.yaml to include netboot-server build configuration. - Added API endpoints for creating, retrieving, and updating boot parameters in docs/swagger.yaml and docs/docs.go. - Defined BootParams schema in swagger documentation and Go code. --- .goreleaser.yaml | 29 +++- cmd/netboot-server/main.go | 143 +++++++++++++++++++ docs/docs.go | 279 +++++++++++++++++++++++++++++++++++++ docs/swagger.json | 279 +++++++++++++++++++++++++++++++++++++ docs/swagger.yaml | 185 ++++++++++++++++++++++++ go.mod | 5 +- go.sum | 16 ++- pkg/bss/handlers.go | 2 +- 8 files changed, 929 insertions(+), 9 deletions(-) create mode 100644 cmd/netboot-server/main.go diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 7add4f407..995f1c680 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: diff --git a/cmd/netboot-server/main.go b/cmd/netboot-server/main.go new file mode 100644 index 000000000..b7f5ac4ad --- /dev/null +++ b/cmd/netboot-server/main.go @@ -0,0 +1,143 @@ +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 +) + +func main() { + var rootCmd = &cobra.Command{ + Use: "netboot-server", + Short: "Starts the netboot server for OpenCHAMI", + Long: `This command starts the netboot server for OpenCHAMI, providing boot parameter management and boot script generation.`, + PreRunE: func(cmd *cobra.Command, args []string) error { + // Validate flags + if smdEndpoint == "" && !fakeSMDEnabled { + //cmd.Usage() // Print usage if validation fails + return errors.New("either --smd-endpoint or --fake-smd must be set for inventory details") + } + if clusterName == "" { + //cmd.Usage() // Print usage if validation fails + 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() + }, + } + + // Define CLI flags + rootCmd.Flags().StringVar(&clusterName, "cluster-name", os.Getenv("CLUSTER_NAME"), "Cluster name for SMD (default from CLUSTER_NAME env)") + rootCmd.Flags().StringVar(&jwksUrl, "jwks-url", os.Getenv("JWKS_URL"), "JWKS URL for JWT validation (default from JWKS_URL env)") + rootCmd.Flags().StringVar(&smdEndpoint, "smd-endpoint", os.Getenv("SMD_ENDPOINT"), "SMD endpoint (default from SMD_ENDPOINT env)") + rootCmd.Flags().StringVar(&tokenEndpoint, "token-endpoint", os.Getenv("TOKEN_ENDPOINT"), "Token endpoint (default from TOKEN_ENDPOINT env)") + rootCmd.Flags().StringVar(&accessToken, "access-token", os.Getenv("ACCESS_TOKEN"), "Access token (default from ACCESS_TOKEN env)") + rootCmd.Flags().StringVar(&certPath, "cert-path", os.Getenv("CERT_PATH"), "Path to certificate for secure connections (default from CERT_PATH env)") + rootCmd.Flags().BoolVar(&insecure, "insecure", os.Getenv("INSECURE") == "true", "Disable TLS verification (default from INSECURE env)") + rootCmd.Flags().BoolVar(&fakeSMDEnabled, "fake-smd", os.Getenv("CLOUD_INIT_SMD_SIMULATOR") == "true", "Enable FakeSMDClient simulation (default from CLOUD_INIT_SMD_SIMULATOR env)") + + // Execute Cobra 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 + bssStorage := bss.NewMemoryStore() + + // 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). + 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.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/docs/docs.go b/docs/docs.go index 14ed03d8c..ab0f1406a 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,77 @@ const docTemplate = `{ } }, "definitions": { + "bss.BootParams": { + "type": "object", + "properties": { + "cloud-init": { + "description": "Cloud-init server configuration", + "allOf": [ + { + "$ref": "#/definitions/bss.CloudInitServer" + } + ] + }, + "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..5833110dd 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,77 @@ } }, "definitions": { + "bss.BootParams": { + "type": "object", + "properties": { + "cloud-init": { + "description": "Cloud-init server configuration", + "allOf": [ + { + "$ref": "#/definitions/bss.CloudInitServer" + } + ] + }, + "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..71d6c3fbf 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1,4 +1,51 @@ definitions: + bss.BootParams: + properties: + cloud-init: + allOf: + - $ref: '#/definitions/bss.CloudInitServer' + description: Cloud-init server configuration + 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 +249,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 e32b7d6a6..7910516a7 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ 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 @@ -33,6 +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/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 diff --git a/go.sum b/go.sum index 2d303b1be..bb1509a1a 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,5 @@ github.com/Cray-HPE/hms-base v1.15.1 h1:+f9cl9BsDWvewvGBPzinmBSU//I7yhwaSUTaNUwxwxQ= github.com/Cray-HPE/hms-base v1.15.1/go.mod h1:+G8KFLPtanLC5lQ602hrf3MDfLTmIXedTavVCOdz5XA= -github.com/Cray-HPE/hms-base/v2 v2.2.1-0.20250414152022-3ab999198b9c h1:z/4etf7PvM5MIwKstKVRx1uV8SodFiivRlXUZYSgGT4= -github.com/Cray-HPE/hms-base/v2 v2.2.1-0.20250414152022-3ab999198b9c/go.mod h1:vE93SJ+Dptk1Y5+C/DKJPt/tjCZs3HzZUbZaEnfgnug= github.com/Cray-HPE/hms-base/v2 v2.3.0 h1:BjUWnxHplxg+YqjHaEcFH6TvsFb6cqYN3WI2JA6KNTo= github.com/Cray-HPE/hms-base/v2 v2.3.0/go.mod h1:vE93SJ+Dptk1Y5+C/DKJPt/tjCZs3HzZUbZaEnfgnug= github.com/Cray-HPE/hms-certs v1.7.0 h1:iNzKUJMRImKC+2IVx8vYiPD6ii95AQoZx9Y+DAbJgtk= @@ -14,12 +12,12 @@ 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= github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= -github.com/apache/arrow-go/v18 v18.1.0 h1:agLwJUiVuwXZdwPYVrlITfx7bndULJ/dggbnLFgDp/Y= -github.com/apache/arrow-go/v18 v18.1.0/go.mod h1:tigU/sIgKNXaesf5d7Y95jBBKS5KsxTqYBKXFsvKzo0= github.com/apache/arrow-go/v18 v18.2.0 h1:QhWqpgZMKfWOniGPhbUxrHohWnooGURqL2R2Gg4SO1Q= github.com/apache/arrow-go/v18 v18.2.0/go.mod h1:Ic/01WSwGJWRrdAZcxjBZ5hbApNJ28K96jGYaxzzGUc= github.com/apache/thrift v0.21.0 h1:tdPmh/ptjE1IJnhbhrcl2++TauVjy242rkV/UzJChnE= @@ -27,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= @@ -91,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= @@ -150,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= @@ -170,8 +176,6 @@ github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= -golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c h1:KL/ZBHXgKGVmuZBZ01Lt57yE5ws8ZPSkkihmEyq7FXc= -golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= diff --git a/pkg/bss/handlers.go b/pkg/bss/handlers.go index e92586e66..22a471fec 100644 --- a/pkg/bss/handlers.go +++ b/pkg/bss/handlers.go @@ -183,7 +183,7 @@ func GetBootParamsHandler(store Store) http.HandlerFunc { // @Failure 422 {string} string "Unprocessable entity" // @Failure 500 {string} string "Internal server error" // @Router /bootscript/{id} [get] -func GenerateBootScriptHandler(store Store, smd *smdclient.SMDClient) http.HandlerFunc { +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) From 1ee56ff87d1f388be7482265db74b89743b59297 Mon Sep 17 00:00:00 2001 From: Alex Lovell-Troy Date: Mon, 19 May 2025 13:38:16 -0400 Subject: [PATCH 20/22] feat(bss): add CmdLine field to BootParams for kernel command line configuration --- pkg/bss/bootparams.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/bss/bootparams.go b/pkg/bss/bootparams.go index 0068273b0..3f56cd1d9 100644 --- a/pkg/bss/bootparams.go +++ b/pkg/bss/bootparams.go @@ -31,6 +31,7 @@ type BootParams struct { 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 From 8c4c2b8c5956d3c947e674c664f092a74eafa8e9 Mon Sep 17 00:00:00 2001 From: Alex Lovell-Troy Date: Wed, 21 May 2025 09:40:17 -0400 Subject: [PATCH 21/22] feat: enhance netboot server with versioning and QuackStore integration - Added QuackStore for persistent storage of boot parameters, supporting versioning and group templates. - Implemented API endpoints for managing boot parameters, including creation, retrieval, updating, and versioning. - Introduced a version command to display build information and server version. - Updated .goreleaser.yaml to support new archive formats (zip and tar.gz). - Enhanced documentation with new fields and API specifications in swagger and Go code. - Added tests for QuackStore functionality to ensure data integrity and operations. --- .goreleaser.yaml | 5 +- cmd/cloud-init-server/main.go | 32 --- cmd/netboot-server/main.go | 79 ++++++-- cmd/netboot-server/version.go | 108 ++++++++++ docs/docs.go | 4 + docs/swagger.json | 4 + docs/swagger.yaml | 3 + pkg/bss/quackstore.go | 358 ++++++++++++++++++++++++++++++++++ pkg/bss/quackstore_test.go | 183 +++++++++++++++++ scripts/test_server.sh | 97 +++++++++ 10 files changed, 821 insertions(+), 52 deletions(-) create mode 100644 cmd/netboot-server/version.go create mode 100644 pkg/bss/quackstore.go create mode 100644 pkg/bss/quackstore_test.go create mode 100755 scripts/test_server.sh diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 995f1c680..89cdbeede 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -123,7 +123,9 @@ docker_manifests: - *arm64v8_linux_image archives: - - format: tar.gz + - formats: + - tar.gz + - zip # this name template makes the OS and Arch compatible with the results of uname. name_template: >- {{ .ProjectName }}_ @@ -142,6 +144,7 @@ checksum: name_template: 'checksums.txt' snapshot: version_template: "{{ incpatch .Version }}-next" + changelog: sort: asc filters: diff --git a/cmd/cloud-init-server/main.go b/cmd/cloud-init-server/main.go index f8cc2850c..6f48935b2 100644 --- a/cmd/cloud-init-server/main.go +++ b/cmd/cloud-init-server/main.go @@ -16,7 +16,6 @@ import ( "github.com/OpenCHAMI/cloud-init/internal/memstore" "github.com/OpenCHAMI/cloud-init/internal/quackstore" - "github.com/OpenCHAMI/cloud-init/pkg/bss" "github.com/OpenCHAMI/cloud-init/pkg/cistore" "github.com/OpenCHAMI/cloud-init/pkg/smdclient" "github.com/OpenCHAMI/cloud-init/pkg/wgtunnel" @@ -54,7 +53,6 @@ var ( wireGuardMiddleware func(http.Handler) http.Handler storageBackend = "mem" // Default to memstore dbPath = "cloud-init.db" // Default database path for quackstore - bssEnabled = false ) func main() { @@ -76,7 +74,6 @@ func main() { 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.BoolVar(&bssEnabled, "bss", bssEnabled, "Enable BSS endpoints") flag.Parse() if debug { @@ -212,11 +209,6 @@ func main() { initCiAdminRouter(router_admin, ciHandler) router.Mount("/cloud-init/admin", router_admin) - if bssEnabled { - bssStorage := bss.NewMemoryStore() - initBssRouter(router, ciHandler, bssStorage) - } - // Serve all routes log.Fatal().Err(http.ListenAndServe(ciEndpoint, router)).Msg("Server closed") } @@ -266,27 +258,3 @@ func initCiAdminRouter(router chi.Router, handler *CiHandler) { }) } - -func initBssRouter(router chi.Router, handler *CiHandler, bssStorage bss.Store) { - // BSS API subrouter - router.Route("/boot/v1/", func(r chi.Router) { - - // Boot parameters endpoints - 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)) - - // Boot script endpoint - r.Get("/bootscript", bss.GenerateBootScriptHandler(bssStorage, handler.sm.(*smdclient.SMDClient))) - - }) - 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)) - // Boot script endpoint - r.Get("/bootscript", bss.GenerateBootScriptHandler(bssStorage, handler.sm.(*smdclient.SMDClient))) - }) - -} diff --git a/cmd/netboot-server/main.go b/cmd/netboot-server/main.go index b7f5ac4ad..c6a6a33c8 100644 --- a/cmd/netboot-server/main.go +++ b/cmd/netboot-server/main.go @@ -28,21 +28,29 @@ var ( certPath string insecure bool fakeSMDEnabled bool + storageBackend string + dbPath string ) func main() { - var rootCmd = &cobra.Command{ + // Create root command + rootCmd := &cobra.Command{ Use: "netboot-server", - Short: "Starts the netboot server for OpenCHAMI", - Long: `This command starts the netboot server for OpenCHAMI, providing boot parameter management and boot script generation.`, + 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 { - // Validate flags if smdEndpoint == "" && !fakeSMDEnabled { - //cmd.Usage() // Print usage if validation fails return errors.New("either --smd-endpoint or --fake-smd must be set for inventory details") } if clusterName == "" { - //cmd.Usage() // Print usage if validation fails return errors.New("cluster name must be set via --cluster-name or CLUSTER_NAME") } return nil @@ -52,17 +60,32 @@ func main() { }, } - // Define CLI flags - rootCmd.Flags().StringVar(&clusterName, "cluster-name", os.Getenv("CLUSTER_NAME"), "Cluster name for SMD (default from CLUSTER_NAME env)") - rootCmd.Flags().StringVar(&jwksUrl, "jwks-url", os.Getenv("JWKS_URL"), "JWKS URL for JWT validation (default from JWKS_URL env)") - rootCmd.Flags().StringVar(&smdEndpoint, "smd-endpoint", os.Getenv("SMD_ENDPOINT"), "SMD endpoint (default from SMD_ENDPOINT env)") - rootCmd.Flags().StringVar(&tokenEndpoint, "token-endpoint", os.Getenv("TOKEN_ENDPOINT"), "Token endpoint (default from TOKEN_ENDPOINT env)") - rootCmd.Flags().StringVar(&accessToken, "access-token", os.Getenv("ACCESS_TOKEN"), "Access token (default from ACCESS_TOKEN env)") - rootCmd.Flags().StringVar(&certPath, "cert-path", os.Getenv("CERT_PATH"), "Path to certificate for secure connections (default from CERT_PATH env)") - rootCmd.Flags().BoolVar(&insecure, "insecure", os.Getenv("INSECURE") == "true", "Disable TLS verification (default from INSECURE env)") - rootCmd.Flags().BoolVar(&fakeSMDEnabled, "fake-smd", os.Getenv("CLOUD_INIT_SMD_SIMULATOR") == "true", "Enable FakeSMDClient simulation (default from CLOUD_INIT_SMD_SIMULATOR env)") - - // Execute Cobra command + // 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) } @@ -90,8 +113,21 @@ func startServer() error { } } - // Initialize BSS storage - bssStorage := bss.NewMemoryStore() + // 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() @@ -117,6 +153,10 @@ func startServer() error { 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 { @@ -127,6 +167,7 @@ func startServer() error { // 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)) 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 ab0f1406a..334224652 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -855,6 +855,10 @@ const docTemplate = `{ } ] }, + "cmdline": { + "description": "Command line for the kernel", + "type": "string" + }, "initrd": { "description": "Initrd image URL/path", "type": "string" diff --git a/docs/swagger.json b/docs/swagger.json index 5833110dd..c516cd374 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -847,6 +847,10 @@ } ] }, + "cmdline": { + "description": "Command line for the kernel", + "type": "string" + }, "initrd": { "description": "Initrd image URL/path", "type": "string" diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 71d6c3fbf..adffcf4e5 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -5,6 +5,9 @@ definitions: 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 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/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 From ba498120449ac702806c0b45abab4989978659b1 Mon Sep 17 00:00:00 2001 From: Alex Lovell-Troy Date: Thu, 22 May 2025 11:37:44 -0400 Subject: [PATCH 22/22] fix: update archive formats in GoReleaser configuration and modify snapshot build command --- .goreleaser.yaml | 4 +--- README.md | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 89cdbeede..be1869229 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -123,9 +123,7 @@ docker_manifests: - *arm64v8_linux_image archives: - - formats: - - tar.gz - - zip + - format : "tar.gz" # this name template makes the OS and Arch compatible with the results of uname. name_template: >- {{ .ProjectName }}_ 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.