From e9a487ca10c2343265a29ec5830bf544821ffe6d Mon Sep 17 00:00:00 2001 From: yasun Date: Wed, 26 Nov 2025 12:28:10 +0800 Subject: [PATCH] feat: sync with openapi spec updates and implement adapter status mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename timestamp fields: CreatedAt/UpdatedAt → CreatedTime/UpdatedTime - Add last_report_time field to adapter_status table - Implement Upsert for adapter status (POST creates or updates) - Add adapter-to-condition mapping: validator→ValidatorSuccessful, dns→DnsSuccessful - Convert ConditionRequest to AdapterCondition with LastTransitionTime - Status Aggregation - Update all tests for new field names and types All unit tests and integration tests passing. update update update update update update update update update update --- .gitignore | 1 + Makefile | 2 + data/generated/openapi/openapi.go | 244 --------- openapi/openapi.yaml | 514 +++++++++++------- pkg/api/adapter_status_types.go | 51 +- pkg/api/cluster_types.go | 62 ++- pkg/api/metadata_types.go | 8 +- pkg/api/node_pool_types.go | 48 +- pkg/dao/adapter_status.go | 97 ++++ pkg/dao/cluster.go | 18 + .../migrations/202511111044_add_clusters.go | 10 +- .../migrations/202511111055_add_node_pools.go | 8 +- .../202511111105_add_adapter_status.go | 7 +- pkg/handlers/cluster_nodepools_test.go | 30 +- pkg/handlers/cluster_status.go | 24 +- pkg/handlers/nodepool_status.go | 24 +- pkg/services/adapter_status.go | 9 + pkg/services/cluster.go | 84 +-- pkg/services/node_pool.go | 85 +-- pkg/services/status_aggregation.go | 174 ++++++ pkg/services/status_aggregation_test.go | 53 ++ test/integration/adapter_status_test.go | 34 +- test/integration/clusters_test.go | 4 +- 23 files changed, 928 insertions(+), 663 deletions(-) delete mode 100755 data/generated/openapi/openapi.go create mode 100644 pkg/services/status_aggregation.go create mode 100644 pkg/services/status_aggregation_test.go diff --git a/.gitignore b/.gitignore index 44adaca..32330e3 100755 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,4 @@ secrets # Ignore generated OpenAPI code /pkg/api/openapi/ +/data/generated/ diff --git a/Makefile b/Makefile index 9d93710..cf5b9b6 100755 --- a/Makefile +++ b/Makefile @@ -211,6 +211,7 @@ test-integration: install secrets # Regenerate openapi client and models generate: rm -rf pkg/api/openapi + mkdir -p data/generated/openapi $(container_tool) build -t hyperfleet-openapi -f Dockerfile.openapi . $(eval OPENAPI_IMAGE_ID=`$(container_tool) create -t hyperfleet-openapi -f Dockerfile.openapi .`) $(container_tool) cp $(OPENAPI_IMAGE_ID):/local/pkg/api/openapi ./pkg/api/openapi @@ -220,6 +221,7 @@ generate: # Regenerate openapi client and models using vendor (avoids downloading dependencies) generate-vendor: rm -rf pkg/api/openapi + mkdir -p data/generated/openapi $(container_tool) build -t hyperfleet-openapi-vendor -f Dockerfile.openapi.vendor . $(eval OPENAPI_IMAGE_ID=`$(container_tool) create -t hyperfleet-openapi-vendor -f Dockerfile.openapi.vendor .`) $(container_tool) cp $(OPENAPI_IMAGE_ID):/local/pkg/api/openapi ./pkg/api/openapi diff --git a/data/generated/openapi/openapi.go b/data/generated/openapi/openapi.go deleted file mode 100755 index b20c9c2..0000000 --- a/data/generated/openapi/openapi.go +++ /dev/null @@ -1,244 +0,0 @@ -// Code generated for package openapi by go-bindata DO NOT EDIT. (@generated) -// sources: -// ../../openapi/openapi.yaml -package openapi - -import ( - "bytes" - "compress/gzip" - "fmt" - "io" - "io/ioutil" - "os" - "path/filepath" - "strings" - "time" -) - -func bindataRead(data []byte, name string) ([]byte, error) { - gz, err := gzip.NewReader(bytes.NewBuffer(data)) - if err != nil { - return nil, fmt.Errorf("Read %q: %v", name, err) - } - - var buf bytes.Buffer - _, err = io.Copy(&buf, gz) - clErr := gz.Close() - - if err != nil { - return nil, fmt.Errorf("Read %q: %v", name, err) - } - if clErr != nil { - return nil, err - } - - return buf.Bytes(), nil -} - -type asset struct { - bytes []byte - info os.FileInfo -} - -type bindataFileInfo struct { - name string - size int64 - mode os.FileMode - modTime time.Time -} - -// Name return file name -func (fi bindataFileInfo) Name() string { - return fi.name -} - -// Size return file size -func (fi bindataFileInfo) Size() int64 { - return fi.size -} - -// Mode return file mode -func (fi bindataFileInfo) Mode() os.FileMode { - return fi.mode -} - -// Mode return file modify time -func (fi bindataFileInfo) ModTime() time.Time { - return fi.modTime -} - -// IsDir return file whether a directory -func (fi bindataFileInfo) IsDir() bool { - return fi.mode&os.ModeDir != 0 -} - -// Sys return file is sys mode -func (fi bindataFileInfo) Sys() interface{} { - return nil -} - -var _openapiYaml = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\xec\x5d\x7b\x73\xdb\xb6\x96\xff\xdf\x9f\xe2\x4c\xf7\xce\x38\xe9\x46\x92\x9d\x74\x76\xb6\x9a\xe9\xcc\xe6\xb1\xbd\xf5\x6e\xea\x64\x63\xb7\x9d\xd9\x3b\x77\x63\x88\x3c\x32\x91\x80\x00\x0b\x80\x72\x74\xbb\xfd\xee\x77\xf0\x22\x41\x8a\x94\x28\x59\x79\x38\x96\xa7\x7f\x54\x20\x1e\x07\x07\x38\xbf\xf3\xc3\xc1\x23\xa2\x40\x4e\x0a\x3a\x85\x27\xe3\x93\xf1\xc9\x11\xe5\x73\x31\x3d\x02\xd0\x54\x33\x9c\xc2\x4f\xcb\x02\xe5\x8f\x0c\x51\xc3\xd3\xd7\x67\x47\x00\x0b\x94\x8a\x0a\x3e\x85\x53\x9b\x1d\x20\x11\x5c\x93\x44\x9b\x32\x00\x9c\xe4\xcd\x42\x97\x48\xf2\x23\x80\x14\x55\x22\x69\xa1\x6d\xc9\xff\x1f\xd9\xbc\xcd\xaa\xa1\x90\x62\x41\x53\x54\xa0\x68\x5e\x30\x84\xe7\x6f\x7e\x79\x01\xa2\x40\x49\x4c\x29\x05\x73\x21\x21\x27\x9c\x5c\x53\x7e\x0d\x09\x2b\x95\x46\x09\x12\x95\x28\x65\x82\x0a\x08\x4f\x41\x67\x48\x25\x28\x4d\x74\xa9\x20\xa3\x4a\x0b\xb9\x1c\x1f\xd9\xc6\xbe\xfd\xf6\xa9\x4c\x32\xaa\x31\xd1\xa5\xc4\x6f\xbf\x9d\xc2\x45\xdc\x0c\x67\xcb\x47\xc0\x05\xcc\x4a\x45\x39\x2a\x05\x4c\x5c\xd3\xc4\x26\xe1\x02\xb9\x86\x44\xa2\x95\x63\x6c\x6b\xbb\x40\xae\x29\x47\xe6\xe5\x13\x12\x32\xc2\x53\x66\xc4\x60\x0c\x84\x4c\x32\x54\xda\x09\xee\x6a\x72\xc5\x9e\xa6\xa4\xd0\x28\x95\xcf\x6d\xe4\x05\x55\x60\x42\xe7\x34\x51\x20\xe6\x75\xff\x4c\xea\x91\x26\xd7\x6a\x0a\x7f\xfb\xfb\x51\x41\x74\xa6\x8c\x82\x27\xa4\xa0\x93\xcc\xe8\x6d\x6e\xf4\x36\x59\x9c\x4e\xbc\x26\x94\xd3\xff\x35\xfa\x81\x80\x5a\x75\x67\xe9\xd4\xa4\x3f\xf7\x19\xfd\x67\x55\xe6\x39\x91\xcb\x29\xbc\xa4\x4a\x07\x7d\x86\x8f\x05\x91\x24\xc7\xba\x5a\xf3\x37\x82\xbf\x48\x9c\x4f\xe1\xf8\x5f\x26\x89\xc8\x0b\xc1\x91\x6b\x35\xa9\x73\x4e\xfe\xa7\x44\xb9\x7c\x6d\x7e\xab\x71\x41\xae\xf1\x78\xf7\xa2\x17\xf4\x1f\xbb\x16\x17\x32\x45\xf9\x6c\x79\x9b\xd2\x5b\x94\xbd\x40\x22\x93\xcc\x15\x0e\xc5\x24\xaa\x42\x70\x85\x91\xee\x8e\x1f\x9f\x9c\x1c\xd7\x3f\x5b\xf6\x70\x99\x21\x48\xfc\xbd\x44\xa5\x21\x23\x0a\x54\x99\x24\x88\x29\xa6\xe3\xa8\x84\x31\x33\xe4\x3a\xae\x04\x80\x14\x05\xa3\x89\x1d\xe5\xc9\x3b\x25\x78\xf3\x2b\x80\x4a\x32\xcc\x49\x3b\x15\x3a\x7b\xe5\xf2\xaa\x89\x9f\x27\x66\x5a\xd4\x8a\x48\x71\x4e\x4a\xa6\x7b\xbb\xf0\x94\x43\xc9\xf1\x43\x81\x89\xc6\x14\x50\x4a\x21\x2b\x3d\x7c\x96\x5e\xfc\xa7\x11\xc1\xc9\x5f\x08\xd5\x6d\x14\xe6\x83\xef\x6d\xdb\x28\x9e\x1b\x73\xc7\x60\x16\x47\x1d\x3d\xf6\x20\x66\xfe\x7c\x66\x02\x1c\x6f\x56\x90\xc9\xe3\x0f\x58\x0c\x3a\x17\xda\x62\x8f\x19\xf1\x2b\x87\x53\x57\x20\x66\xef\x30\xd1\x40\xb9\xc5\x83\xa0\x35\xa0\x0a\x24\x92\x74\x64\xb0\xc9\xa2\x9b\xe9\x65\x69\xd4\x3b\x5b\x3a\xe4\x40\xb9\xa0\x49\xa4\xde\x33\x6d\x0a\x9d\xbf\xba\x34\xf6\xab\x0d\x9e\xe8\x68\x66\xcd\x44\xba\x1c\xc3\x19\xa7\x9a\x12\x66\xe0\xce\xb5\x3f\x2e\x32\xa2\x10\x6e\x28\x63\x30\x43\xf8\xe6\x5c\xe8\x37\x48\xd2\xe5\x37\xa6\xcd\xaa\x6a\x9f\x97\x04\x00\x0b\xd9\x31\x2f\xf4\x12\x4a\xae\x29\x83\xea\xe3\xeb\x57\x17\x97\x0d\x2c\x1e\xaf\xc2\x8a\x81\x35\x97\xd8\x6d\x2e\xa7\x3b\x98\x8b\xd5\x92\x1b\x85\xa0\x7d\x9b\x61\x86\xc8\x1d\x80\x9b\x3c\x0a\x88\xf9\x5c\x32\xfd\x39\xcd\xeb\xeb\x30\x2d\x08\x03\xf1\x4c\xa4\xcb\xba\x16\x93\x48\x25\xa6\x53\xd0\xb2\xc4\xa3\x35\x12\xae\x97\xaf\x5b\xba\x01\xda\x75\x16\xf9\xc6\xc9\x76\xbc\xde\x71\x4e\xfe\xf0\xff\xf7\x96\xa6\x7f\x0e\xf5\xa2\xcf\x96\x67\x69\x1b\x33\xfe\x8a\x95\x1f\x35\x26\x7a\xf6\xe2\x36\xce\xb4\xcb\xb1\x98\x62\x8e\x60\xd5\x12\x47\xba\xa1\x7c\x0a\x86\x29\x44\x49\x3d\x03\xd1\xad\x59\xbd\x2c\x70\x0a\x4a\x4b\xca\xaf\xbf\x2a\x57\xf6\x35\xd8\xda\xd0\x09\x3c\xe1\x22\xc5\x42\x08\xb6\x99\x10\x9e\x8b\x14\x5f\x9b\x9c\xcf\x96\x5e\x51\xab\x73\xda\x92\x43\xc3\x69\xab\x6a\x2d\x0f\x5f\xe3\x17\xdf\xa0\x2e\x25\x57\xd6\xf5\x30\x53\x5a\xcc\x3b\x2a\x20\xad\x2a\xba\x6d\x64\x4f\x93\xbd\x21\x9f\xef\x6a\x6d\x9e\x7d\x43\xd3\x61\x0e\x07\x12\x7c\x5f\x48\x70\xb0\x8d\x7b\xc0\x82\x1d\x31\x0a\x1d\xee\x21\xc2\xc1\x7a\xbb\x2c\xbe\xa2\xbf\xa1\x8e\x3b\x6c\xe1\xf7\x8c\x8c\x86\x11\x0b\x7c\xc9\x75\xfe\xeb\x98\xf0\x5f\x1c\x37\x6d\x2b\x7b\x6b\x72\x5a\xfb\xf6\xc9\x1f\xe1\x7f\x07\x91\xd6\xd0\x72\x1f\x6b\x0d\x95\x35\x68\x6b\xa7\x57\x0f\xd1\xaa\x36\x20\xdc\x0d\xeb\xae\x65\x8a\xd4\xb7\x0f\xa1\x2a\xe4\xdb\x1f\xe6\xdc\x39\x57\xf9\x35\xa0\xc6\x6d\x2d\x71\xe2\xe2\x1d\xb8\x05\xf9\xbe\xf0\x25\x7a\x99\xb7\x0f\xab\x40\xa8\xda\x7a\xd7\xaa\xfd\x75\xc6\x9a\x94\x52\x22\xd7\x21\x20\xee\xe3\x4c\xa6\x38\x92\x24\xab\x2a\xd6\x19\x71\xf3\x48\x62\x21\xa4\x19\x12\x57\xe0\xde\x9a\xf6\x61\x35\x70\x58\x0d\x34\xf0\xc1\xef\xdb\x38\x53\xbd\x07\x4b\x02\xf3\x21\x00\x54\x1f\x3e\x05\xd6\xdf\x40\xa7\x2e\x34\x8a\x02\xe5\x5e\x8f\x9e\xfe\x2a\xa0\x5a\x01\x75\x01\xe9\x0e\x90\xd2\x19\x55\x01\x61\x6a\x55\x05\x70\xfb\xee\xe4\x7b\xa0\xf3\xaa\x7d\xc2\x24\x92\x74\x69\x27\x0b\xd9\x50\x19\x1c\x45\xb5\x85\x68\x3b\x4f\x58\x99\xa2\x8b\x5b\xf8\xe2\xd4\x91\xf8\x4c\xe2\xdc\xd6\x31\x2f\x75\x29\x31\xda\x93\x1c\xaf\xf4\x4c\x65\xa2\x64\x06\x40\x85\x44\x1f\x7d\x37\xe2\x62\x0a\x34\x9d\xd8\x8a\xb4\x80\xb2\x48\x8d\xea\x4c\xef\x7d\x4b\x8c\x44\x7d\x3c\x00\x6e\x4f\x93\xf7\x6c\x71\xd6\x40\x9d\x1a\x71\x8e\xbf\xdb\x04\x9c\x0a\xe5\xc2\x58\x99\x9d\x8a\x5c\x68\x28\x79\x8a\x52\x69\xbf\x45\x5e\xa9\x24\x2d\xd1\x4c\x47\xca\x17\x84\xd1\x14\xd4\x92\x6b\xf2\x61\x1c\x37\xf4\xdd\xb0\x86\x08\x37\xad\xcc\x69\xb3\x7e\x4c\xa3\x4d\xb0\xa8\xd2\xef\x87\x0d\x58\x22\xf8\x9c\xd1\x44\x2b\xb8\xa1\x3a\xb3\x15\xc7\x84\x06\xc3\xfe\x96\x13\x62\xfc\x65\xae\xf9\x1a\x63\xb8\xfb\xc2\x6f\x30\xad\xf4\x16\xbd\x1b\xa9\x1c\x10\xd5\xbd\x6f\x94\xf2\xc0\xef\xee\x39\xbf\xdb\x3b\x08\x0e\x39\x8d\x70\xe0\x5c\x07\xce\x75\x20\x40\x07\x02\x74\x2f\x09\x90\xc8\x0b\xa2\xe9\x8c\x32\xaa\x97\x9b\xf9\x4e\x9c\x7b\x1d\x71\xe9\xdd\x8e\x5e\x0b\x00\x07\xf7\xff\xa5\xb9\x7f\x8d\x1f\xf4\xa4\x60\x84\x0e\x46\x98\x0e\x1c\x55\x98\x94\xb2\x9a\x5e\xae\xdb\xcf\x90\x48\x94\x4f\x4b\x9d\xf9\xb3\x69\x1d\x73\x73\x87\xb3\x15\xbd\x0c\x7c\x7f\x07\x2a\x0e\x33\xf8\x4e\xcd\xe0\xc3\x71\x85\x4d\xb1\xc9\xfa\x8b\x29\xde\x9e\xd7\x2b\x43\x1f\xda\x70\x6c\xcd\x26\xf9\x14\x43\xd2\x7e\x37\xd9\x23\xdf\xe8\xdc\xe0\x9c\x30\x15\xfc\x60\x5b\xd2\x75\x12\xbe\x32\xb5\xbf\xa0\x12\x13\xd3\xf1\x55\x55\x5b\x05\xfb\x54\xfc\x50\x30\x91\x62\xdc\x58\xc7\xa4\xef\x90\xfe\xd9\xf2\x56\xf2\x77\x2e\x65\x2b\x01\x3d\xb9\x7b\x4b\xf4\x30\x31\x8d\x69\x37\x65\x34\x29\x7b\x10\x90\x72\x8d\xd7\xd5\x50\x81\x81\xc1\x9c\x68\x9b\xfe\xe4\xf1\xaa\xdc\xa7\xc3\xc5\x35\x48\xb4\x2a\xb2\x49\xfd\xf4\x62\x3f\x3e\xe9\x97\x3b\x06\xa2\xa6\xbc\xca\x7e\xd9\xf7\x2c\x58\x11\xc1\xcf\x6a\x57\xe8\xe9\xeb\xb3\x37\x9e\xad\x86\x5a\x5c\x1d\x6e\x45\x17\xbc\x8c\x34\xae\x4e\xd3\x18\x29\x19\x99\x21\x53\xb1\xa9\x77\x14\x34\x7f\x24\x4d\xa9\xb1\x1b\xc2\x5e\x77\x54\xd3\x3b\x77\x5b\xb0\xe5\x9a\xf3\x2b\x4c\xb4\x77\x87\xaa\x35\x0c\x51\x50\x10\x2a\xed\x65\x1a\xab\xca\x05\x61\x25\xfa\x0a\x83\xa7\x24\x8c\xbd\x9a\x6f\x72\x92\x95\xc5\xdb\x4e\xbc\xc1\x39\x4a\xe4\x89\xf7\x70\x0d\x56\xbb\x46\x5b\xd5\x50\x45\x8d\xf9\xd5\x73\x94\x22\x66\x96\xc2\xa7\x6f\xaf\x91\x7b\x1a\x11\x7d\x4d\x04\x77\x5a\x53\x6b\x86\xc0\x57\xba\x3a\x06\xeb\x55\x79\x4e\xf2\x6a\x19\xd1\x88\xdd\x79\x49\x30\x75\x8b\x78\xbf\x64\x7e\xf0\xab\x59\x27\x11\x2d\xe4\x23\x78\x71\x7e\x31\x1e\x8f\x1f\x56\x35\x77\x74\x62\x55\x9a\xb6\xf1\xf4\x99\x4f\x4b\xcc\xdf\x32\x9a\x64\x50\x57\xec\x0e\x98\x71\x40\xae\xa9\x5e\xc2\x83\x70\xb5\xe9\x51\x75\x10\x43\x3d\x84\x1b\x52\x87\x2b\x89\xb6\x5d\xd4\xd4\x75\xd7\x5d\xe6\xe2\xd7\x71\xe7\xe2\x45\x91\xd7\xf7\xaa\xfc\x44\x4a\xb2\x8c\x52\xa9\xc6\xbc\x35\x83\xd7\x9e\x47\x0e\x55\x1f\xf7\xf5\xf4\xbf\xcb\x19\x4a\x8e\x1a\xd5\x48\xe9\x25\xc3\x48\x1a\xd0\x92\x24\xef\x8d\xd4\x71\xfc\xa7\x5e\xcc\xa5\x44\x93\x5d\x6d\x10\xfe\xf8\xb3\x97\x24\xb8\xc6\x46\xd5\xc9\x1f\xd3\x0e\x3c\x50\x5a\x96\xf6\x2a\x1d\x2c\x88\xa4\xa8\x60\xb6\xac\x67\xd0\xb2\xc0\x7a\x62\xe4\xa8\xc9\x40\xd9\xba\x66\xb6\xf9\x7b\x27\x66\x6f\xad\x35\xb7\xd8\x44\xcf\x14\xaf\x0b\xa8\x82\x24\xc3\x4b\x11\xad\x31\x2f\x74\x77\xfe\xd5\xa9\x0b\x6b\xa6\xaf\xbd\x31\x23\x9d\x9f\x1d\xdc\xbe\x99\x31\x0c\xb7\x2c\x94\x96\xab\xc6\xb6\xa6\x40\x63\x6c\x7f\xf6\x43\x03\x64\x26\x4a\xdd\x40\x81\x77\x62\x36\xc1\x0f\x98\x94\x11\x1a\xe1\x07\x62\x04\x5c\x45\x1d\x58\x04\x5c\x58\x0b\x07\x95\x0f\xef\x33\xb2\xd1\xba\x1a\xeb\x4e\x3d\x5d\x10\xca\xc8\x8c\x61\x5b\xe1\x06\x8c\xe1\xf8\x52\x96\x78\xdc\xf8\x24\x91\x28\x3b\x97\x19\x0b\x15\x5b\x8b\x2a\x88\x52\x98\x36\xb2\xe6\xa8\x94\x61\x3c\x36\xef\x93\x93\x28\x3b\x68\x54\xba\xb3\xcc\x86\xbe\xda\xfe\x56\xac\x6b\x6a\x16\x16\x8f\x4f\x47\x27\xe6\xbf\xcb\x93\x93\xa9\xfd\xef\x7f\x9b\x02\xbb\x68\xe5\xc0\xec\x43\xb5\x66\x08\x7b\x4b\xf2\x01\x3a\xfb\xb5\x56\xc0\x3b\x31\x73\xb4\xbf\x4f\x67\xdd\x79\xdd\xda\x48\xa9\x79\xc9\xd8\xf2\x0e\x2a\xee\x27\x24\xac\x11\xe8\xdd\x45\x6f\x99\xad\x64\x39\x48\x6f\x54\x75\x66\xff\xac\xda\x6a\x23\xb8\xbf\xad\x2d\xbd\x13\x59\x9e\x4e\xab\xa4\x5f\x0d\xe5\x3a\x5d\x93\xf7\x71\x2b\xef\xe3\xb5\xbe\xa2\x02\xff\x7a\x90\x46\xef\xc4\xac\x23\x87\x43\xfb\xc0\xbd\x63\x9f\xe7\x81\xbd\xa1\xad\x08\xa0\x37\xeb\xaa\x01\xcd\x9b\xb3\x57\xa0\x0c\xa7\x27\x6a\x95\x31\x36\xe2\xa0\x77\x98\x3e\x86\x1d\x18\x9a\x1a\x26\x36\xa7\xb8\x1e\xff\xf7\x4b\x07\xc3\xc6\x52\x44\x0b\x63\xae\x2a\x71\xce\x30\xd1\x9f\x8b\xd7\x7d\x4a\x36\xb6\x0b\xd5\xda\xb6\xe1\xff\x12\x33\xa8\xf8\x40\xd5\xd0\xea\xd4\x7e\x49\xb7\x9e\xd1\x56\xd9\x6b\x26\xe7\xca\x60\xec\x65\xcc\x3a\x37\x97\xb6\x5a\x19\xd6\xc1\x36\xbf\xfe\xd8\xb2\xdf\x2b\x91\x18\x93\x58\x43\x72\x47\xce\xd9\xb2\x23\x67\x23\xb1\x13\x02\x9a\x67\x2f\x3a\x34\x1c\xb9\x8d\xcd\x08\x10\x4c\xd4\xb4\x3e\x32\x2b\xaa\xea\x5b\xe4\x4e\x6e\x53\x4d\xdd\xdb\x2d\xaa\xc1\x9c\x50\xb6\x22\xc9\x2d\xaa\xd8\x0f\x6e\xe5\x94\xd3\xbc\xcc\x9b\x9e\xa7\x61\x58\x7f\x8d\x56\xb5\x14\x59\x6a\xbc\xbf\x17\x1f\x04\x87\xa4\x54\x5a\xe4\x28\x7d\x9a\x7a\x14\x80\xcd\x2d\x5f\x31\xbc\xc7\x12\x56\xf1\xdf\x50\x1b\xab\xfd\x26\xfc\x0e\xe5\xab\xe6\x55\x23\x66\x01\xc3\x6e\xd0\xde\xc2\x4a\xc2\x85\x69\x52\xdf\x2b\x5a\x59\x47\xbc\xa7\x3c\xad\x76\xc6\x6b\xbb\x4f\xab\xbd\xf7\xd1\xe9\xe3\x27\x55\x7a\x66\x5b\xcb\xb4\x2e\xd4\x74\x32\x21\x05\x1d\xd7\xbb\x34\xe3\x44\xe4\x8d\x63\x54\x5d\x15\x34\xb6\xf5\x1b\x5f\x56\x23\x59\xc8\x17\x54\x0a\x9e\x23\xd7\x96\xb5\xa4\x65\xd2\xb0\x2e\x00\x8d\x24\x9f\x42\xc1\x88\x36\x93\xa0\xd6\x73\x81\x49\x03\x50\xb7\xe0\x66\x5b\xf0\xb2\x6e\x2e\xb8\x3a\xc8\xf6\x95\x87\x29\xd8\xa7\x1d\xa2\x64\x46\x94\x7e\xab\x25\xe1\xca\x7a\x83\xb7\xc6\x10\x07\xf0\x9b\x8d\x54\x74\x2b\x1e\x1e\x1e\x8e\x68\x82\xf7\x46\x76\xbe\x7e\x39\xb8\x96\xa4\x6f\xb5\x24\xdc\x6d\x51\x38\x88\xaf\x6f\xcd\xd8\xb7\xd4\x6c\x43\x8b\x29\x57\xfb\xd7\xdf\x8b\xf3\x0b\x90\x98\x08\x99\xaa\xd0\x97\x3e\xe5\x39\x28\x1a\xa7\x22\x27\x94\x1b\x43\xed\x29\xf0\xd9\xf5\x16\x39\x20\x28\x95\x83\x88\xff\xf0\xa0\x65\xe4\xee\x72\x33\xfd\x19\x23\x00\xdc\x92\x20\x18\x58\x8c\x7e\x1a\xd8\x8a\xbd\x7a\x81\xc9\x1a\x9f\x6e\x31\x75\x08\x9f\xf7\x1b\x15\x6d\xf4\x6d\xc7\xdb\x7a\xca\xe7\x94\xbf\x44\x7e\xad\xb3\xe6\x10\xe5\xe4\x43\x48\xfe\xb7\x27\x31\x0c\x99\xa5\x98\xe4\x53\xf8\xbf\xbf\x91\xd1\x3f\x4e\x46\xdf\x8f\xfe\xfe\xaf\x7f\xe9\x73\x8d\xe1\xa8\x94\x11\x05\x1e\x94\x9c\xfe\x5e\x46\x71\x45\x0b\xb0\x31\x8a\x34\xbd\x12\x0c\xf5\x4c\x17\x05\x26\xcf\x85\xc4\xde\x98\x6c\x74\x98\x2e\x1a\xcf\xea\x2a\x24\x69\xf9\x03\x80\xe7\x2f\x7f\x86\x54\xa0\xe2\xc7\x1a\xde\x73\x71\x03\x99\xb8\xb1\x67\xd0\x78\x4e\xa4\xca\x08\x63\xd5\xcb\x5f\x8f\x80\x6a\xfb\xfa\x98\x3b\xbe\xe6\x9e\x31\x9b\x0b\x79\x43\x8c\x41\x69\x51\x3f\xaa\xa3\x05\xa4\xc2\xbf\xaa\xd3\x5c\xfe\x02\x3c\x2b\xb5\x6d\xd4\xbe\xca\xe3\xc1\xc9\xbf\x2e\x66\xfb\x0a\x33\x9c\x0b\x89\x40\x92\x04\x8b\x8a\x39\xf8\xed\xeb\x5d\x9c\x7a\xb4\x57\xd4\x60\xc0\x43\x17\xb6\x9f\x88\x41\x7c\x7a\x47\x1f\x3d\x9e\x75\x27\x56\x41\xad\x17\x52\x6e\xb7\xfe\x09\x96\xb4\xa6\xe7\xbd\x4f\x68\x99\xe9\x99\x74\x59\x56\x74\xde\xd3\xce\x5e\x63\x22\xcb\x48\x43\x40\x54\x65\x4d\x86\x3b\x87\xc8\xd2\x88\x5c\x73\xa1\x74\x78\x7a\xcf\xfc\x5d\x66\x54\xb9\x67\xb5\x0a\x89\x0a\x79\x75\xc6\xd1\xbf\x38\xe8\x8f\xae\x1a\xab\x63\x4c\xdc\xc0\x9c\xe1\x07\x7f\xb4\x6c\xdc\xe8\xe7\x2e\xdb\x7e\x96\x80\x45\xbf\xbb\x98\x57\xf4\x79\x7d\x50\xa7\x73\x95\x18\xa0\x62\xcd\x24\x72\x2c\x70\x33\xaa\x23\x2f\xf3\x36\x94\x86\x87\xc1\x5a\xc9\x5d\x69\x3f\x12\xca\x1a\x6e\x7d\x2d\x9e\xfa\x3d\xba\x30\xfa\xee\x3d\xb2\x07\x9c\x68\xba\x40\x1b\xdf\x98\x99\x84\x44\xb0\x32\xe7\x0f\xc7\x8d\xb2\xbf\xf8\x85\xd2\x4d\x86\xbc\x86\x4a\x77\xe9\xa0\xf5\xf6\x98\xfb\x3b\x17\xda\xf6\x37\x7a\xf9\xac\x7a\x77\x92\x5c\x5f\x4b\xbc\xb6\xf5\x2d\x28\xde\xc0\x5c\x8a\xbc\x71\x59\xa2\x8e\x20\x8d\x23\x20\xe9\x20\xd0\xb7\x59\xfd\xae\x57\xd6\x6f\xa6\xa3\x41\x53\xa6\x6d\xa8\xdb\xc6\x14\x1e\x84\x95\x63\xbd\x1b\xa7\x1e\x19\x6a\x62\x53\xaa\x57\x2b\xe7\x42\xc2\x8c\x24\xef\xc5\x7c\xfe\x70\x5b\x7d\x02\x75\x0b\x4b\xa7\xbc\x24\x23\xfc\x1a\x6b\x5e\xf9\x31\xc3\x7e\x2d\x55\xbc\x34\xbd\x8f\x82\x7f\x85\x14\x09\x2a\xd5\xec\xfc\xed\x66\x8b\x05\x8c\xf0\xd4\x9d\x3d\x7f\x27\x6e\x50\x69\x70\x87\x0a\xc4\xbc\x71\xe9\xe5\x58\x75\xf5\xde\xe5\x55\xad\x6a\x83\xfa\x5c\x45\x54\x81\x99\xe2\x76\xbd\x6f\x43\x98\x1d\x36\xbf\xa7\xd8\xca\x7a\x95\x5e\xd2\xfa\x38\x80\x9d\x5c\x21\xec\x1d\x94\xe4\x4c\x62\x2f\xba\x7d\xc5\x52\xa3\xca\xab\xba\x63\x57\xae\xfa\x78\x17\x72\x25\x78\xdd\xbd\x58\xdc\x6f\x10\xb7\x5a\x0f\x1d\x6f\x72\x5a\xc1\x5f\x39\xc3\x08\xf8\x61\x43\x39\x2d\xec\x50\xd1\x33\x90\x76\x56\x85\x47\x1f\x55\xdf\xb3\x8e\x96\x09\x3e\x7f\x7a\x7e\xfe\xea\xd2\xcc\xbe\x5c\xa4\x74\x4e\x31\x85\xd4\x1e\x3e\x63\xcb\xf6\xb3\x8f\x11\x7a\xc5\xa3\x14\x84\xf3\xe1\x23\x7b\xcb\x04\x53\xe3\xe3\xae\xa2\xbb\x66\xf1\x1d\xb3\xab\x48\xd4\xd7\x01\x1b\x7f\x2f\x69\xf2\x1e\xc4\xc2\xc8\x86\x37\x66\x8e\xdc\xd8\x20\x3c\xa9\x5f\xb4\x5d\x60\x7d\xdb\xcb\xde\x2e\xa8\x05\xb2\x93\xdd\xbb\xd0\xa0\xe5\x5b\x6f\x7b\x98\x82\xf1\x72\xa8\x79\x8a\x63\xe3\xb6\xc8\xa0\xe0\xeb\x47\xd8\x2b\x69\xac\xe2\x6c\xc1\x4d\x15\xad\x06\x73\xb6\xf0\xda\x1d\x8b\xf7\x11\x1c\xff\x48\x98\x5a\x49\xfd\x85\x9b\xc5\x0a\xef\x5d\x8d\x85\x91\x6b\xab\xda\x87\x02\xb6\xd4\xc7\xcf\x24\xc9\x28\xc7\x91\x44\x92\x1a\x63\xf3\xd5\x40\x22\xd2\x5a\x3f\x21\x76\xb0\x65\xdd\x3f\x95\x39\xe1\x75\xcd\xbe\x96\x8f\xe3\xa4\xf6\x14\x3b\x1f\x00\xcc\x16\x5a\x6d\x4c\xb7\x1a\x09\x7b\xc6\xa9\x23\x88\x32\x82\x33\x0e\xa4\x02\x62\x6f\x98\xf6\xe1\xec\xfa\x3c\x51\x04\xbd\x3f\xfc\x50\x77\xe3\x6a\xb5\x26\x7f\xcc\xee\x11\x5c\x45\xd9\xaa\x25\xae\xe3\x00\xee\x92\x0a\xc7\x9b\x0a\x6a\xba\x9d\xc5\xc7\x71\x64\xeb\x14\xe4\x5b\xfc\x64\x94\xed\x37\x27\x86\x85\xf6\x20\x47\x07\x55\x8b\x71\x3f\x60\xbe\xed\x81\x47\x6c\x4f\xad\x1e\x36\x61\xb3\x72\x4e\x5b\xe2\x67\x84\x96\x5d\xa0\x36\x08\x88\xba\x11\xa6\x1d\x3e\xdc\x6e\x6d\x1f\xfa\x15\xaf\xee\xfb\x2c\xc1\xad\xbb\xaa\xf6\x8c\xd7\x8b\x6e\x0e\xd6\xca\x76\x13\x8f\x37\x4f\x34\x52\x55\xf1\xe0\xe7\x2f\x7f\x36\x0e\xd0\x0f\x40\xcd\x66\x6b\x97\x7a\x65\x7f\xff\x60\xaf\x37\x5e\x79\x4e\x13\x9c\xb9\xa8\x79\xcd\x55\x25\xcb\x0f\x5a\x96\x78\xd5\x1c\xaa\x1d\x22\x7e\x07\x2f\x77\xf0\x72\x83\xeb\x3e\x78\xb9\x83\x97\xeb\xf3\x72\xfd\x71\xb6\x3c\x17\xdc\x9d\xe1\x9d\x0b\xd9\xbc\xb4\xad\x1e\x85\x87\xe3\x63\x4c\x72\xa8\x5b\x9d\xb5\xb6\x71\xea\x14\x28\x87\x17\xcf\x1e\x46\xd9\x7c\xd0\x17\x0a\xb2\x64\x82\x38\x39\xec\x7d\x9e\x2d\x2f\x13\xd0\xcd\xbb\x16\x83\xb7\x36\x1a\xb7\xe8\xfc\x0d\x81\xc6\xa6\x8a\xdd\x4a\xde\xb5\xa6\x5f\xde\x9c\xd5\xf6\x21\xd2\xcd\xf6\x3b\x10\x42\xaa\xbb\x84\x6f\x37\xe8\x62\x87\x28\x73\x6b\x4b\xa9\x88\x01\x63\x04\xaa\xbe\x22\x63\x7d\x8f\xd0\x84\x45\xbf\x37\x85\xa8\x07\x8d\x4b\xd1\x89\x74\x43\x71\x48\x45\x57\x7b\xb6\x2f\x6d\x3b\xb4\x7b\xf1\xad\x42\xf0\x61\x47\x20\xdc\x48\xf8\x02\x0e\x07\x89\x1b\x8e\xf2\xad\x0c\x57\x59\x54\x1f\xaf\x38\x1c\x11\x1a\x56\x45\x5b\x9f\x43\x0f\xd5\x74\x5e\x29\x82\xad\xcf\xe6\x34\x5f\x68\xdb\x69\x0b\xa7\x7a\x18\xb6\x62\xdf\x71\xca\x96\x53\x76\xbb\xfd\xe9\xbb\x76\x69\x6c\x90\x67\xea\x73\x15\x1d\x07\x64\xbf\x40\x27\x36\x70\xcb\xbf\xfb\xe5\xdb\x78\x93\xde\xb0\x83\xea\xfd\xef\x3d\xee\xd8\x57\x33\xfe\xb0\x65\x5f\x5b\xea\x6d\x0e\x93\x1f\x4c\xf6\x60\xb2\x07\x93\xfd\x6c\x26\xeb\x9e\x5a\xb8\x43\xcc\x70\x7b\xcc\x38\xf0\xc6\x3b\xc2\x1b\x0f\xf0\x7e\x80\xf7\x03\xbc\xef\x05\xde\xef\xcc\xe9\xbf\xf6\x3f\xdd\xb0\xe3\xf1\xbf\xf6\x24\xd8\xf5\xfc\x5f\xf5\xcf\x90\x7c\xb1\x07\x00\x9b\xd0\x79\xcb\x13\x80\xeb\xb7\x6e\x36\x9c\x0f\xfc\x1a\x4f\x00\x56\xc8\x75\xb7\x8e\x00\xee\xf9\xcc\xdb\xe6\x2b\x63\xf7\xfa\x20\xdc\x27\xd9\xbd\xaf\x66\xe2\x7e\x8f\x58\x1e\x0e\xf1\xf9\xbf\x2f\xe8\x10\x5f\x35\xd4\x9f\xf1\x14\x5f\x8b\xd8\xef\x7f\xc7\xec\xee\xb2\xe0\xe6\x83\x7b\x4d\xcd\x34\x1f\x37\x6b\x38\xac\x11\x90\xea\x1d\x3e\xf3\xcb\x3f\xcb\x17\x1e\xfe\xbc\x30\xd3\x25\xe8\x30\x7a\xf9\xb3\x51\x7d\xa6\x75\xe1\x13\xec\xf4\xc2\xa9\xcf\x7a\xe4\x5e\xdc\xb5\xc5\x47\x50\x4a\xe6\xf2\x4e\x27\x13\x26\x12\xc2\x32\xa1\xf4\xf4\xdf\x4f\x4e\xdc\xcb\x6f\x8d\xee\xbd\xc0\x05\x32\x51\xe4\xc8\xdd\x80\x2e\x88\xa4\x66\x92\xba\xed\xa2\x7f\x06\x00\x00\xff\xff\x9d\xec\xe5\x93\x8a\x81\x00\x00") - -func openapiYamlBytes() ([]byte, error) { - return bindataRead( - _openapiYaml, - "openapi.yaml", - ) -} - -func openapiYaml() (*asset, error) { - bytes, err := openapiYamlBytes() - if err != nil { - return nil, err - } - - info := bindataFileInfo{name: "openapi.yaml", size: 33162, mode: os.FileMode(420), modTime: time.Unix(1762937650, 0)} - a := &asset{bytes: bytes, info: info} - return a, nil -} - -// Asset loads and returns the asset for the given name. -// It returns an error if the asset could not be found or -// could not be loaded. -func Asset(name string) ([]byte, error) { - cannonicalName := strings.Replace(name, "\\", "/", -1) - if f, ok := _bindata[cannonicalName]; ok { - a, err := f() - if err != nil { - return nil, fmt.Errorf("Asset %s can't read by error: %v", name, err) - } - return a.bytes, nil - } - return nil, fmt.Errorf("Asset %s not found", name) -} - -// MustAsset is like Asset but panics when Asset would return an error. -// It simplifies safe initialization of global variables. -func MustAsset(name string) []byte { - a, err := Asset(name) - if err != nil { - panic("asset: Asset(" + name + "): " + err.Error()) - } - - return a -} - -// AssetInfo loads and returns the asset info for the given name. -// It returns an error if the asset could not be found or -// could not be loaded. -func AssetInfo(name string) (os.FileInfo, error) { - cannonicalName := strings.Replace(name, "\\", "/", -1) - if f, ok := _bindata[cannonicalName]; ok { - a, err := f() - if err != nil { - return nil, fmt.Errorf("AssetInfo %s can't read by error: %v", name, err) - } - return a.info, nil - } - return nil, fmt.Errorf("AssetInfo %s not found", name) -} - -// AssetNames returns the names of the assets. -func AssetNames() []string { - names := make([]string, 0, len(_bindata)) - for name := range _bindata { - names = append(names, name) - } - return names -} - -// _bindata is a table, holding each asset generator, mapped to its name. -var _bindata = map[string]func() (*asset, error){ - "openapi.yaml": openapiYaml, -} - -// AssetDir returns the file names below a certain -// directory embedded in the file by go-bindata. -// For example if you run go-bindata on data/... and data contains the -// following hierarchy: -// data/ -// foo.txt -// img/ -// a.png -// b.png -// then AssetDir("data") would return []string{"foo.txt", "img"} -// AssetDir("data/img") would return []string{"a.png", "b.png"} -// AssetDir("foo.txt") and AssetDir("notexist") would return an error -// AssetDir("") will return []string{"data"}. -func AssetDir(name string) ([]string, error) { - node := _bintree - if len(name) != 0 { - cannonicalName := strings.Replace(name, "\\", "/", -1) - pathList := strings.Split(cannonicalName, "/") - for _, p := range pathList { - node = node.Children[p] - if node == nil { - return nil, fmt.Errorf("Asset %s not found", name) - } - } - } - if node.Func != nil { - return nil, fmt.Errorf("Asset %s not found", name) - } - rv := make([]string, 0, len(node.Children)) - for childName := range node.Children { - rv = append(rv, childName) - } - return rv, nil -} - -type bintree struct { - Func func() (*asset, error) - Children map[string]*bintree -} - -var _bintree = &bintree{nil, map[string]*bintree{ - "openapi.yaml": &bintree{openapiYaml, map[string]*bintree{}}, -}} - -// RestoreAsset restores an asset under the given directory -func RestoreAsset(dir, name string) error { - data, err := Asset(name) - if err != nil { - return err - } - info, err := AssetInfo(name) - if err != nil { - return err - } - err = os.MkdirAll(_filePath(dir, filepath.Dir(name)), os.FileMode(0755)) - if err != nil { - return err - } - err = ioutil.WriteFile(_filePath(dir, name), data, info.Mode()) - if err != nil { - return err - } - err = os.Chtimes(_filePath(dir, name), info.ModTime(), info.ModTime()) - if err != nil { - return err - } - return nil -} - -// RestoreAssets restores an asset under the given directory recursively -func RestoreAssets(dir, name string) error { - children, err := AssetDir(name) - // File - if err != nil { - return RestoreAsset(dir, name) - } - // Dir - for _, child := range children { - err = RestoreAssets(dir, filepath.Join(name, child)) - if err != nil { - return err - } - } - return nil -} - -func _filePath(dir, name string) string { - cannonicalName := strings.Replace(name, "\\", "/", -1) - return filepath.Join(append([]string{dir}, strings.Split(cannonicalName, "/")...)...) -} diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 5827187..5d32c17 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -43,7 +43,7 @@ paths: **Note**: The `status` object in the response is read-only and computed by the service. It is NOT part of the request body. Initially, status.phase will be "NotReady" and - status.adapters will be empty until adapters POST their status. + status.conditions will be empty until status conditions are POSTed. parameters: [] responses: '201': @@ -183,7 +183,7 @@ paths: get: operationId: getNodePoolsStatuses summary: List all adapter statuses for nodepools - description: Returns current status object for each adapter that has reported status + description: Returns adapter status reports for this nodepool parameters: - name: cluster_id in: path @@ -216,13 +216,13 @@ paths: $ref: '#/components/schemas/Error' post: operationId: postNodePoolStatuses - summary: Create adapter status + summary: Create or update adapter status description: |- - Adapter creates its initial status object for this cluster. - Returns 409 if adapter already has a status object for this cluster + Adapter creates or updates its status report for this nodepool. + If adapter already has a status, it will be updated (upsert by adapter name). - Response includes the status id and href for future operations. - Adapter should store the returned id/href to update its status later. + Response includes the full adapter status with all conditions. + Adapter should call this endpoint every time it evaluates the nodepool. parameters: - name: cluster_id in: path @@ -258,7 +258,7 @@ paths: get: operationId: getClusterStatuses summary: List all adapter statuses for cluster - description: Returns current status object for each adapter that has reported status + description: Returns adapter status reports for this cluster parameters: - name: cluster_id in: path @@ -282,13 +282,13 @@ paths: description: The server cannot find the requested resource. post: operationId: postClusterStatuses - summary: Create adapter status + summary: Create or update adapter status description: |- - Adapter creates its initial status object for this cluster. - Returns 409 if adapter already has a status object for this cluster + Adapter creates or updates its status report for this cluster. + If adapter already has a status, it will be updated (upsert by adapter name). - Response includes the status id and href for future operations. - Adapter should store the returned id/href to update its status later. + Response includes the full adapter status with all conditions. + Adapter should call this endpoint every time it evaluates the cluster. parameters: - name: cluster_id in: path @@ -374,7 +374,7 @@ components: required: false schema: type: string - default: created_at + default: created_time explode: false QueryParams.page: name: page @@ -412,29 +412,91 @@ components: description: labels for the API resource as pairs of name:value strings allOf: - $ref: '#/components/schemas/ObjectReference' + AdapterCondition: + type: object + allOf: + - $ref: '#/components/schemas/ConditionBase' + description: |- + Condition in AdapterStatus + Used for standard Kubernetes condition types: "Available", "Applied", "Health" + Note: observed_generation is at AdapterStatus level, not per-condition, + since all conditions in one AdapterStatus share the same observed generation AdapterStatus: + type: object + required: + - conditions + - created_time + - last_report_time + properties: + conditions: + type: array + items: + $ref: '#/components/schemas/AdapterCondition' + description: |- + Kubernetes-style conditions tracking adapter state + Typically includes: Available, Applied, Health + created_time: + type: string + format: date-time + description: When this adapter status was first created (API-managed) + last_report_time: + type: string + format: date-time + description: |- + When this adapter last reported its status (API-managed) + Updated every time the adapter POSTs, even if conditions haven't changed + Used by Sentinel to detect adapter liveness + allOf: + - $ref: '#/components/schemas/AdapterStatusBase' + description: |- + AdapterStatus represents the complete status report from an adapter + Contains multiple conditions, job metadata, and adapter-specific data + example: + adapter: validator + observed_generation: 1 + conditions: + - type: Available + status: 'True' + reason: All validations passed + message: All 30 validation tests passed + last_transition_time: '2021-01-01T10:00:00Z' + - type: Applied + status: 'True' + reason: Validation job applied + message: Validation job applied successfully + last_transition_time: '2021-01-01T10:00:00Z' + - type: Health + status: 'True' + reason: All adapter operations completed successfully + message: All adapter runtime operations completed successfully + last_transition_time: '2021-01-01T10:00:00Z' + metadata: + job_name: validator-job-abc123 + job_namespace: hyperfleet-system + attempt: 1 + started_time: '2021-01-01T10:00:00Z' + completed_time: '2021-01-01T10:02:00Z' + duration: 2m + data: + validation_results: + total_tests: 30 + passed: 30 + failed: 0 + created_time: '2021-01-01T10:00:00Z' + last_report_time: '2021-01-01T10:02:00Z' + AdapterStatusBase: type: object required: - adapter - observed_generation - - conditions properties: adapter: type: string - description: Name of the adapter that generated this status (Validator, DNS...) + description: Adapter name (e.g., "validator", "dns", "provisioner") observed_generation: type: integer format: int32 - description: Which generation for an entity (Clusters, NodePools) was current at the time of creating this status - conditions: - type: array - items: - $ref: '#/components/schemas/Condition' - description: Kubernetes-style conditions tracking adapter state - data: - type: object - additionalProperties: {} - description: Adapter-specific data (structure varies by adapter type) + description: Which generation of the resource this status reflects metadata: type: object properties: @@ -445,102 +507,128 @@ components: attempt: type: integer format: int32 - started_at: + started_time: type: string - completed_at: + format: date-time + completed_time: type: string + format: date-time duration: type: string - description: Metadata about the adapter job/execution + description: Job execution metadata + data: + type: object + additionalProperties: {} + description: Adapter-specific data (structure varies by adapter type) + description: Base fields shared by AdapterStatus and AdapterStatusCreateRequest + AdapterStatusCreateRequest: + type: object + required: + - observed_time + - conditions + properties: + observed_time: + type: string + format: date-time + description: |- + When the adapter observed this resource state + API will use this to set AdapterStatus.last_report_time + conditions: + type: array + items: + $ref: '#/components/schemas/ConditionRequest' + allOf: + - $ref: '#/components/schemas/AdapterStatusBase' + description: Request payload for creating/updating adapter status example: adapter: validator observed_generation: 1 + observed_time: '2021-01-01T10:00:00Z' conditions: - - adapter: validator - type: Available + - type: Available status: 'True' reason: All validations passed message: All 30 validation tests passed - observed_generation: 1 - created_at: '2021-01-01T00:00:00Z' - updated_at: '2021-01-01T00:00:00Z' - - adapter: validator - type: Applied + - type: Applied status: 'True' reason: Validation job applied message: Validation job applied successfully - observed_generation: 1 - created_at: '2021-01-01T00:00:00Z' - updated_at: '2021-01-01T00:00:00Z' - - adapter: validator - type: Health + - type: Health status: 'True' - reason: Validation job healthy - message: Validation job is healthy - observed_generation: 1 - created_at: '2021-01-01T00:00:00Z' - updated_at: '2021-01-01T00:00:00Z' - data: - providerProperty1: providerValue1 - providerProperty2: providerValue2 + reason: All adapter operations completed successfully + message: All adapter runtime operations completed successfully metadata: - job_name: validator-job - job_namespace: default + job_name: validator-job-abc123 + job_namespace: hyperfleet-system attempt: 1 - started_at: '2021-01-01T00:00:00Z' - completed_at: '2021-01-01T00:00:00Z' - duration: 10s - AdapterStatusCreateRequest: - type: object - required: - - adapter - - observed_generation - - conditions - properties: - adapter: - type: string - description: Adapter identifier - observed_generation: - type: integer - format: int32 - description: Which cluster generation this status reflects - conditions: - type: array - items: - $ref: '#/components/schemas/Condition' + started_time: '2021-01-01T10:00:00Z' + completed_time: '2021-01-01T10:02:00Z' + duration: 2m data: - type: object - additionalProperties: {} - description: Adapter-specific data - metadata: - type: object - additionalProperties: {} - description: Job execution metadata + validation_results: + total_tests: 30 + passed: 30 + failed: 0 AdapterStatusList: type: object required: + - kind - items properties: + kind: + type: string + enum: + - AdapterStatusList items: type: array items: $ref: '#/components/schemas/AdapterStatus' allOf: - $ref: '#/components/schemas/List' + description: List of adapter statuses with pagination metadata + example: + kind: AdapterStatusList + page: 1 + size: 2 + total: 2 + items: + - adapter: validator + observed_generation: 1 + conditions: + - type: Available + status: 'True' + reason: All validations passed + message: All 30 validation tests passed + last_transition_time: '2021-01-01T10:00:00Z' + metadata: + job_name: validator-job-abc123 + duration: 2m + created_time: '2021-01-01T10:00:00Z' + last_report_time: '2021-01-01T10:02:00Z' + - adapter: dns + observed_generation: 1 + conditions: + - type: Available + status: 'True' + reason: DNS configured + message: DNS records created + last_transition_time: '2021-01-01T10:01:00Z' + created_time: '2021-01-01T10:01:00Z' + last_report_time: '2021-01-01T10:01:30Z' Cluster: type: object required: - - created_at - - updated_at + - created_time + - updated_time - created_by - updated_by - generation - status properties: - created_at: + created_time: type: string format: date-time - updated_at: + updated_time: type: string format: date-time created_by: @@ -567,31 +655,31 @@ components: environment: production team: platform spec: {} - created_at: '2021-01-01T00:00:00Z' - updated_at: '2021-01-01T00:00:00Z' + created_time: '2021-01-01T00:00:00Z' + updated_time: '2021-01-01T00:00:00Z' generation: 1 status: phase: Ready last_transition_time: '2021-01-01T00:00:00Z' observed_generation: 1 - updated_at: '2021-01-01T00:00:00Z' - adapters: - - adapter: validator - type: Available + last_updated_time: '2021-01-01T00:00:00Z' + conditions: + - type: ValidationSuccessful status: 'True' reason: All validations passed message: All 30 validation tests passed observed_generation: 1 - created_at: '2021-01-01T00:00:00Z' - updated_at: '2021-01-01T00:00:00Z' - - adapter: dns - type: Available + created_time: '2021-01-01T10:00:00Z' + last_updated_time: '2021-01-01T10:00:00Z' + last_transition_time: '2021-01-01T10:00:00Z' + - type: DNSSuccessful status: 'True' - reason: DNS records created - message: custom.domain.com created + reason: DNS configured + message: DNS records created for custom.domain.com observed_generation: 1 - created_at: '2021-01-01T00:00:00Z' - updated_at: '2021-01-01T00:00:00Z' + created_time: '2021-01-01T10:01:00Z' + last_updated_time: '2021-01-01T10:01:00Z' + last_transition_time: '2021-01-01T10:01:00Z' created_by: user-123@example.com updated_by: user-123@example.com ClusterBase: @@ -653,8 +741,8 @@ components: - phase - last_transition_time - observed_generation - - updated_at - - adapters + - last_updated_time + - conditions properties: phase: type: string @@ -664,55 +752,51 @@ components: - Failed description: |- Current cluster phase (native database column). - Updated when adapters report status. - Note: status.phase provides aggregated view from all adapter conditions. + Updated when conditions are reported. + Note: status.phase provides aggregated view from all conditions. last_transition_time: type: string format: date-time description: |- - When cluster last transitioned (updated by adapters, used by Sentinel for backoff) - Updated when adapters report status if the phase changes + When cluster last transitioned (used by Sentinel for backoff) + Updated when conditions are reported if the phase changes observed_generation: type: integer format: int32 description: |- - Last generation processed by adapters - Updated when adapters report status. - This will be the lowest value of each adapter's observed_generation values + Last generation processed + Updated when conditions are reported. + This will be the lowest value of each condition's observed_generation values The phase value is based on this generation - updated_at: + last_updated_time: type: string format: date-time description: |- - Time of the last complete report from adapters - Updated when adapters report status. - Oldest `updated_at` from the adapter conditions - adapters: + Time of the last update + Updated when conditions are reported. + Computed as min(conditions[].last_updated_time) to detect stale adapters. + Uses earliest (not latest) timestamp to ensure Sentinel can detect if any adapter has stopped reporting. + conditions: type: array items: - $ref: '#/components/schemas/ConditionAvailable' + $ref: '#/components/schemas/ResourceCondition' description: |- - Cluster status aggregation from all adapters. + Cluster status computed from all status conditions. This object is computed by the service and CANNOT be modified directly. - It is aggregated from adapter status updates posted to `/clusters/{id}/statuses`. + It is aggregated from condition updates posted to `/clusters/{id}/statuses`. - Provides quick overview of which adapters have reported and aggregated phase. - Condition: + Provides quick overview of all reported conditions and aggregated phase. + ConditionBase: type: object required: - - adapter - type - status - - observed_generation - - created_at - - updated_at + - last_transition_time properties: - adapter: - type: string - description: Adapter name type: type: string + description: Condition type status: type: string enum: @@ -726,51 +810,19 @@ components: message: type: string description: Human-readable message - observed_generation: - type: integer - format: int32 - created_at: - type: string - format: date-time - description: |- - Time when the condition was created - - In an adapter reporting conditions `updated_at==created_at` - - In the API, `created_at` doesn't change with new updates from adapters - updated_at: - type: string - format: date-time - description: Time when the condition was updated last_transition_time: type: string format: date-time - description: When this condition last transitioned (computed by service when status changes) - ConditionAvailable: - type: object - required: - - type - properties: - type: - type: string - enum: - - Available - allOf: - - $ref: '#/components/schemas/ConditionBase' - description: |- - StatusAvailable is the status condition from an adapter that is used by CLM to compute the phase. - `phase=ready` when all adaptors report `Available=true` - ConditionBase: + description: |- + When this condition last transitioned status (API-managed) + Only updated when status changes (True/False/Unknown), not when reason/message changes + description: Base condition fields shared by all condition types + ConditionRequest: type: object required: - - adapter - type - status - - observed_generation - - created_at - - updated_at properties: - adapter: - type: string - description: Adapter name type: type: string status: @@ -779,31 +831,13 @@ components: - 'True' - 'False' - Unknown - description: Condition status reason: type: string - description: Machine-readable reason code message: type: string - description: Human-readable message - observed_generation: - type: integer - format: int32 - created_at: - type: string - format: date-time - description: |- - Time when the condition was created - - In an adapter reporting conditions `updated_at==created_at` - - In the API, `created_at` doesn't change with new updates from adapters - updated_at: - type: string - format: date-time - description: Time when the condition was updated description: |- - Common data for status objects, part of: - - Status entity (stored in DB) - - Request payload + Condition data for create/update requests (from adapters) + observed_generation and observed_time are now at AdapterStatusCreateRequest level Error: type: object properties: @@ -847,17 +881,17 @@ components: NodePool: type: object required: - - created_at - - updated_at + - created_time + - updated_time - created_by - updated_by - owner_references - status properties: - created_at: + created_time: type: string format: date-time - updated_at: + updated_time: type: string format: date-time created_by: @@ -872,6 +906,45 @@ components: $ref: '#/components/schemas/NodePoolStatus' allOf: - $ref: '#/components/schemas/NodePoolBase' + example: + kind: NodePool + id: nodepool-123 + href: https://api.hyperfleet.com/v1/nodepools/nodepool-123 + name: worker-pool-1 + labels: + environment: production + pooltype: worker + spec: {} + created_time: '2021-01-01T00:00:00Z' + updated_time: '2021-01-01T00:00:00Z' + created_by: user-123@example.com + updated_by: user-123@example.com + owner_references: + id: cluster-123 + kind: Cluster + href: https://api.hyperfleet.com/v1/clusters/cluster-123 + status: + phase: Ready + last_transition_time: '2021-01-01T10:00:00Z' + observed_generation: 1 + last_updated_time: '2021-01-01T10:02:00Z' + conditions: + - type: ValidationSuccessful + status: 'True' + reason: All validations passed + message: NodePool validation passed + observed_generation: 1 + created_time: '2021-01-01T10:00:00Z' + last_updated_time: '2021-01-01T10:00:00Z' + last_transition_time: '2021-01-01T10:00:00Z' + - type: NodePoolSuccessful + status: 'True' + reason: NodePool provisioned successfully + message: NodePool has been scaled to desired count + observed_generation: 1 + created_time: '2021-01-01T10:01:00Z' + last_updated_time: '2021-01-01T10:01:00Z' + last_transition_time: '2021-01-01T10:01:00Z' NodePoolBase: type: object required: @@ -932,11 +1005,17 @@ components: Cluster specification CLM doesn't know how to unmarshall the spec, it only stores and forwards to adapters to do their job But CLM will validate the schema before accepting the request + example: + name: worker-pool-1 + labels: + environment: production + pooltype: worker + spec: {} NodePoolCreateResponse: type: object required: - - created_at - - updated_at + - created_time + - updated_time - created_by - updated_by - owner_references @@ -944,10 +1023,10 @@ components: - name - spec properties: - created_at: + created_time: type: string format: date-time - updated_at: + updated_time: type: string format: date-time created_by: @@ -1007,8 +1086,8 @@ components: - phase - observed_generation - last_transition_time - - updated_at - - adapters + - last_updated_time + - conditions properties: phase: type: string @@ -1018,34 +1097,35 @@ components: - Failed description: |- Current NodePool phase (native database column). - Updated when adapters report status. - Note: status.phase provides aggregated view from all adapter conditions. + Updated when conditions are reported. + Note: status.phase provides aggregated view from all conditions. observed_generation: type: integer format: int32 minimum: 1 description: |- - Last generation processed by adapters - Updated when adapters report status. - This will be the lowest value of each adapter's observed_generation values + Last generation processed + Updated when conditions are reported. + This will be the lowest value of each condition's observed_generation values The phase value is based on this generation last_transition_time: type: string format: date-time - description: When NodePool last transitioned (updated by adapters, used by Sentinel for backoff) - updated_at: + description: When NodePool last transitioned (used by Sentinel for backoff) + last_updated_time: type: string format: date-time description: |- - Time of the last complete report from adapters - Updated when adapters report status. - Oldest `updated_at` from the adapter conditions - adapters: + Time of the last update + Updated when conditions are reported. + Computed as min(conditions[].last_updated_time) to detect stale adapters. + Uses earliest (not latest) timestamp to ensure Sentinel can detect if any adapter has stopped reporting. + conditions: type: array items: - $ref: '#/components/schemas/ConditionAvailable' + $ref: '#/components/schemas/ResourceCondition' description: |- - NodePool status aggregation from all adapters. + NodePool status computed from all status conditions. This object is computed by the service and CANNOT be modified directly. ObjectReference: @@ -1065,6 +1145,34 @@ components: enum: - asc - desc + ResourceCondition: + type: object + required: + - observed_generation + - created_time + - last_updated_time + properties: + observed_generation: + type: integer + format: int32 + description: Generation of the spec that this condition reflects + created_time: + type: string + format: date-time + description: When this condition was first created (API-managed) + last_updated_time: + type: string + format: date-time + description: |- + When the corresponding adapter last reported (API-managed) + Updated every time the adapter POSTs, even if condition status hasn't changed + Copied from AdapterStatus.last_report_time + allOf: + - $ref: '#/components/schemas/ConditionBase' + description: |- + Condition in Cluster/NodePool status + Used for semantic condition types: "ValidationSuccessful", "DNSSuccessful", "NodePoolSuccessful", etc. + Includes observed_generation and last_updated_time to track adapter-specific state securitySchemes: BearerAuth: type: http diff --git a/pkg/api/adapter_status_types.go b/pkg/api/adapter_status_types.go index 767a011..bfe99eb 100644 --- a/pkg/api/adapter_status_types.go +++ b/pkg/api/adapter_status_types.go @@ -2,6 +2,7 @@ package api import ( "encoding/json" + "time" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/openapi" "gorm.io/datatypes" @@ -10,7 +11,7 @@ import ( // AdapterStatus database model type AdapterStatus struct { - Meta // Contains ID, CreatedAt, UpdatedAt, DeletedAt + Meta // Contains ID, CreatedTime, UpdatedTime, DeletedAt // Polymorphic association ResourceType string `json:"resource_type" gorm:"size:20;index:idx_resource;not null"` @@ -20,6 +21,10 @@ type AdapterStatus struct { Adapter string `json:"adapter" gorm:"size:255;not null;uniqueIndex:idx_resource_adapter"` ObservedGeneration int32 `json:"observed_generation" gorm:"not null"` + // API-managed timestamps + LastReportTime *time.Time `json:"last_report_time" gorm:"not null"` // Updated on every POST + CreatedTime *time.Time `json:"created_time" gorm:"not null"` // Set on first creation + // Stored as JSON Conditions datatypes.JSON `json:"conditions" gorm:"type:jsonb;not null"` Data datatypes.JSON `json:"data,omitempty" gorm:"type:jsonb"` @@ -45,29 +50,42 @@ func (as *AdapterStatus) BeforeCreate(tx *gorm.DB) error { // ToOpenAPI converts to OpenAPI model func (as *AdapterStatus) ToOpenAPI() *openapi.AdapterStatus { // Unmarshal Conditions - var conditions []openapi.Condition + var conditions []openapi.AdapterCondition if len(as.Conditions) > 0 { _ = json.Unmarshal(as.Conditions, &conditions) } // Unmarshal Data - var data map[string]interface{} + var data map[string]map[string]interface{} if len(as.Data) > 0 { _ = json.Unmarshal(as.Data, &data) } // Unmarshal Metadata - var metadata *openapi.AdapterStatusMetadata + var metadata *openapi.AdapterStatusBaseMetadata if len(as.Metadata) > 0 { _ = json.Unmarshal(as.Metadata, &metadata) } + // Set default times if nil (shouldn't happen in normal operation) + createdTime := time.Time{} + if as.CreatedTime != nil { + createdTime = *as.CreatedTime + } + + lastReportTime := time.Time{} + if as.LastReportTime != nil { + lastReportTime = *as.LastReportTime + } + return &openapi.AdapterStatus{ Adapter: as.Adapter, ObservedGeneration: as.ObservedGeneration, Conditions: conditions, Data: data, Metadata: metadata, + CreatedTime: createdTime, + LastReportTime: lastReportTime, } } @@ -76,11 +94,30 @@ func AdapterStatusFromOpenAPICreate( resourceType, resourceID string, req *openapi.AdapterStatusCreateRequest, ) *AdapterStatus { + // Set timestamps + // CreatedTime and LastReportTime should be set from req.ObservedTime + now := time.Now() + if !req.ObservedTime.IsZero() { + now = req.ObservedTime + } + + // Convert ConditionRequest to AdapterCondition (adding LastTransitionTime) + adapterConditions := make([]openapi.AdapterCondition, len(req.Conditions)) + for i, condReq := range req.Conditions { + adapterConditions[i] = openapi.AdapterCondition{ + Type: condReq.Type, + Status: condReq.Status, + Reason: condReq.Reason, + Message: condReq.Message, + LastTransitionTime: now, + } + } + // Marshal Conditions - conditionsJSON, _ := json.Marshal(req.Conditions) + conditionsJSON, _ := json.Marshal(adapterConditions) // Marshal Data - data := make(map[string]interface{}) + data := make(map[string]map[string]interface{}) if req.Data != nil { data = req.Data } @@ -100,5 +137,7 @@ func AdapterStatusFromOpenAPICreate( Conditions: conditionsJSON, Data: dataJSON, Metadata: metadataJSON, + CreatedTime: &now, + LastReportTime: &now, } } diff --git a/pkg/api/cluster_types.go b/pkg/api/cluster_types.go index d817813..c383f0a 100644 --- a/pkg/api/cluster_types.go +++ b/pkg/api/cluster_types.go @@ -27,8 +27,8 @@ type Cluster struct { StatusPhase string `json:"status_phase" gorm:"default:'NotReady'"` StatusLastTransitionTime *time.Time `json:"status_last_transition_time,omitempty"` StatusObservedGeneration int32 `json:"status_observed_generation" gorm:"default:0"` - StatusUpdatedAt *time.Time `json:"status_updated_at,omitempty"` - StatusAdapters datatypes.JSON `json:"status_adapters" gorm:"type:jsonb"` + StatusLastUpdatedTime *time.Time `json:"status_last_updated_time,omitempty"` + StatusConditions datatypes.JSON `json:"status_conditions" gorm:"type:jsonb"` // Audit fields CreatedBy string `json:"created_by" gorm:"size:255;not null"` @@ -78,10 +78,10 @@ func (c *Cluster) ToOpenAPI() *openapi.Cluster { _ = json.Unmarshal(c.Labels, &labels) } - // Unmarshal StatusAdapters - var statusAdapters []openapi.ConditionAvailable - if len(c.StatusAdapters) > 0 { - _ = json.Unmarshal(c.StatusAdapters, &statusAdapters) + // Unmarshal StatusConditions + var statusConditions []openapi.ResourceCondition + if len(c.StatusConditions) > 0 { + _ = json.Unmarshal(c.StatusConditions, &statusConditions) } // Generate Href if not set (fallback) @@ -91,32 +91,36 @@ func (c *Cluster) ToOpenAPI() *openapi.Cluster { } cluster := &openapi.Cluster{ - Id: &c.ID, - Kind: c.Kind, - Href: &href, - Name: c.Name, - Spec: spec, - Labels: &labels, - Generation: c.Generation, - CreatedAt: c.CreatedAt, - UpdatedAt: c.UpdatedAt, - CreatedBy: c.CreatedBy, - UpdatedBy: c.UpdatedBy, + Id: &c.ID, + Kind: c.Kind, + Href: &href, + Name: c.Name, + Spec: spec, + Labels: &labels, + Generation: c.Generation, + CreatedTime: c.CreatedTime, + UpdatedTime: c.UpdatedTime, + CreatedBy: c.CreatedBy, + UpdatedBy: c.UpdatedBy, } - // Build ClusterStatus - cluster.Status = openapi.ClusterStatus{ - Phase: c.StatusPhase, - ObservedGeneration: c.StatusObservedGeneration, - Adapters: statusAdapters, + // Build ClusterStatus - set required fields with defaults if nil + lastTransitionTime := time.Time{} + if c.StatusLastTransitionTime != nil { + lastTransitionTime = *c.StatusLastTransitionTime } - if c.StatusLastTransitionTime != nil { - cluster.Status.LastTransitionTime = *c.StatusLastTransitionTime + lastUpdatedTime := time.Time{} + if c.StatusLastUpdatedTime != nil { + lastUpdatedTime = *c.StatusLastUpdatedTime } - if c.StatusUpdatedAt != nil { - cluster.Status.UpdatedAt = *c.StatusUpdatedAt + cluster.Status = openapi.ClusterStatus{ + Phase: c.StatusPhase, + ObservedGeneration: c.StatusObservedGeneration, + Conditions: statusConditions, + LastTransitionTime: lastTransitionTime, + LastUpdatedTime: lastUpdatedTime, } return cluster @@ -134,8 +138,8 @@ func ClusterFromOpenAPICreate(req *openapi.ClusterCreateRequest, createdBy strin } labelsJSON, _ := json.Marshal(labels) - // Marshal empty StatusAdapters - statusAdaptersJSON, _ := json.Marshal([]openapi.ConditionAvailable{}) + // Marshal empty StatusConditions + statusConditionsJSON, _ := json.Marshal([]openapi.ResourceCondition{}) return &Cluster{ Kind: req.Kind, @@ -145,7 +149,7 @@ func ClusterFromOpenAPICreate(req *openapi.ClusterCreateRequest, createdBy strin Generation: 1, StatusPhase: "NotReady", StatusObservedGeneration: 0, - StatusAdapters: statusAdaptersJSON, + StatusConditions: statusConditionsJSON, CreatedBy: createdBy, UpdatedBy: createdBy, } diff --git a/pkg/api/metadata_types.go b/pkg/api/metadata_types.go index 24b89d4..8c70e37 100755 --- a/pkg/api/metadata_types.go +++ b/pkg/api/metadata_types.go @@ -35,10 +35,10 @@ type Metadata struct { // Meta is base model definition, embedded in all kinds type Meta struct { - ID string - CreatedAt time.Time - UpdatedAt time.Time - DeletedAt gorm.DeletedAt `gorm:"index"` + ID string + CreatedTime time.Time + UpdatedTime time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` } // PagingMeta List Paging metadata diff --git a/pkg/api/node_pool_types.go b/pkg/api/node_pool_types.go index cabfa59..5955f2e 100644 --- a/pkg/api/node_pool_types.go +++ b/pkg/api/node_pool_types.go @@ -33,8 +33,8 @@ type NodePool struct { StatusPhase string `json:"status_phase" gorm:"default:'NotReady'"` StatusObservedGeneration int32 `json:"status_observed_generation" gorm:"default:0"` StatusLastTransitionTime *time.Time `json:"status_last_transition_time,omitempty"` - StatusUpdatedAt *time.Time `json:"status_updated_at,omitempty"` - StatusAdapters datatypes.JSON `json:"status_adapters" gorm:"type:jsonb"` + StatusLastUpdatedTime *time.Time `json:"status_last_updated_time,omitempty"` + StatusConditions datatypes.JSON `json:"status_conditions" gorm:"type:jsonb"` // Audit fields CreatedBy string `json:"created_by" gorm:"size:255;not null"` @@ -88,10 +88,10 @@ func (np *NodePool) ToOpenAPI() *openapi.NodePool { _ = json.Unmarshal(np.Labels, &labels) } - // Unmarshal StatusAdapters - var statusAdapters []openapi.ConditionAvailable - if len(np.StatusAdapters) > 0 { - _ = json.Unmarshal(np.StatusAdapters, &statusAdapters) + // Unmarshal StatusConditions + var statusConditions []openapi.ResourceCondition + if len(np.StatusConditions) > 0 { + _ = json.Unmarshal(np.StatusConditions, &statusConditions) } // Generate Href if not set (fallback) @@ -119,25 +119,29 @@ func (np *NodePool) ToOpenAPI() *openapi.NodePool { Kind: &np.OwnerKind, Href: &ownerHref, }, - CreatedAt: np.CreatedAt, - UpdatedAt: np.UpdatedAt, - CreatedBy: np.CreatedBy, - UpdatedBy: np.UpdatedBy, + CreatedTime: np.CreatedTime, + UpdatedTime: np.UpdatedTime, + CreatedBy: np.CreatedBy, + UpdatedBy: np.UpdatedBy, } - // Build NodePoolStatus - nodePool.Status = openapi.NodePoolStatus{ - Phase: np.StatusPhase, - ObservedGeneration: np.StatusObservedGeneration, - Adapters: statusAdapters, + // Build NodePoolStatus - set required fields with defaults if nil + lastTransitionTime := time.Time{} + if np.StatusLastTransitionTime != nil { + lastTransitionTime = *np.StatusLastTransitionTime } - if np.StatusLastTransitionTime != nil { - nodePool.Status.LastTransitionTime = *np.StatusLastTransitionTime + lastUpdatedTime := time.Time{} + if np.StatusLastUpdatedTime != nil { + lastUpdatedTime = *np.StatusLastUpdatedTime } - if np.StatusUpdatedAt != nil { - nodePool.Status.UpdatedAt = *np.StatusUpdatedAt + nodePool.Status = openapi.NodePoolStatus{ + Phase: np.StatusPhase, + ObservedGeneration: np.StatusObservedGeneration, + Conditions: statusConditions, + LastTransitionTime: lastTransitionTime, + LastUpdatedTime: lastUpdatedTime, } return nodePool @@ -155,8 +159,8 @@ func NodePoolFromOpenAPICreate(req *openapi.NodePoolCreateRequest, ownerID, crea } labelsJSON, _ := json.Marshal(labels) - // Marshal empty StatusAdapters - statusAdaptersJSON, _ := json.Marshal([]openapi.ConditionAvailable{}) + // Marshal empty StatusConditions + statusConditionsJSON, _ := json.Marshal([]openapi.ResourceCondition{}) kind := "NodePool" if req.Kind != nil { @@ -172,7 +176,7 @@ func NodePoolFromOpenAPICreate(req *openapi.NodePoolCreateRequest, ownerID, crea OwnerKind: "Cluster", StatusPhase: "NotReady", StatusObservedGeneration: 0, - StatusAdapters: statusAdaptersJSON, + StatusConditions: statusConditionsJSON, CreatedBy: createdBy, UpdatedBy: createdBy, } diff --git a/pkg/dao/adapter_status.go b/pkg/dao/adapter_status.go index 30f07f8..c0f8c47 100644 --- a/pkg/dao/adapter_status.go +++ b/pkg/dao/adapter_status.go @@ -2,10 +2,16 @@ package dao import ( "context" + "encoding/json" + "errors" + "time" + "gorm.io/datatypes" + "gorm.io/gorm" "gorm.io/gorm/clause" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/openapi" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db" ) @@ -13,6 +19,7 @@ type AdapterStatusDao interface { Get(ctx context.Context, id string) (*api.AdapterStatus, error) Create(ctx context.Context, adapterStatus *api.AdapterStatus) (*api.AdapterStatus, error) Replace(ctx context.Context, adapterStatus *api.AdapterStatus) (*api.AdapterStatus, error) + Upsert(ctx context.Context, adapterStatus *api.AdapterStatus) (*api.AdapterStatus, error) Delete(ctx context.Context, id string) error FindByResource(ctx context.Context, resourceType, resourceID string) (api.AdapterStatusList, error) FindByResourcePaginated(ctx context.Context, resourceType, resourceID string, offset, limit int) (api.AdapterStatusList, int64, error) @@ -57,6 +64,47 @@ func (d *sqlAdapterStatusDao) Replace(ctx context.Context, adapterStatus *api.Ad return adapterStatus, nil } +// Upsert creates or updates an adapter status based on resource_type, resource_id, and adapter +// This implements the upsert semantic required by the new API spec +func (d *sqlAdapterStatusDao) Upsert(ctx context.Context, adapterStatus *api.AdapterStatus) (*api.AdapterStatus, error) { + g2 := (*d.sessionFactory).New(ctx) + + // Try to find existing adapter status + existing, err := d.FindByResourceAndAdapter(ctx, adapterStatus.ResourceType, adapterStatus.ResourceID, adapterStatus.Adapter) + + if err != nil { + // If not found, create new + if errors.Is(err, gorm.ErrRecordNotFound) { + return d.Create(ctx, adapterStatus) + } + // Other errors + db.MarkForRollback(ctx, err) + return nil, err + } + + // Update existing record + // Keep the original ID and CreatedTime + adapterStatus.ID = existing.ID + if existing.CreatedTime != nil { + adapterStatus.CreatedTime = existing.CreatedTime + } + + // Update LastReportTime to now + now := time.Now() + adapterStatus.LastReportTime = &now + + // Preserve LastTransitionTime for conditions whose status hasn't changed + adapterStatus.Conditions = preserveLastTransitionTime(existing.Conditions, adapterStatus.Conditions) + + // Save (update) the record + if err := g2.Omit(clause.Associations).Save(adapterStatus).Error; err != nil { + db.MarkForRollback(ctx, err) + return nil, err + } + + return adapterStatus, nil +} + func (d *sqlAdapterStatusDao) Delete(ctx context.Context, id string) error { g2 := (*d.sessionFactory).New(ctx) if err := g2.Omit(clause.Associations).Delete(&api.AdapterStatus{Meta: api.Meta{ID: id}}).Error; err != nil { @@ -113,3 +161,52 @@ func (d *sqlAdapterStatusDao) All(ctx context.Context) (api.AdapterStatusList, e } return statuses, nil } + +// preserveLastTransitionTime preserves LastTransitionTime for conditions whose status hasn't changed +// This implements the Kubernetes condition semantic where LastTransitionTime is only updated when status changes +func preserveLastTransitionTime(oldConditionsJSON, newConditionsJSON datatypes.JSON) datatypes.JSON { + // Unmarshal old conditions + var oldConditions []openapi.AdapterCondition + if len(oldConditionsJSON) > 0 { + if err := json.Unmarshal(oldConditionsJSON, &oldConditions); err != nil { + // If we can't unmarshal old conditions, return new conditions as-is + return newConditionsJSON + } + } + + // Unmarshal new conditions + var newConditions []openapi.AdapterCondition + if len(newConditionsJSON) > 0 { + if err := json.Unmarshal(newConditionsJSON, &newConditions); err != nil { + // If we can't unmarshal new conditions, return new conditions as-is + return newConditionsJSON + } + } + + // Build a map of old conditions by type for quick lookup + oldConditionsMap := make(map[string]openapi.AdapterCondition) + for _, oldCond := range oldConditions { + oldConditionsMap[oldCond.Type] = oldCond + } + + // Update new conditions: preserve LastTransitionTime if status hasn't changed + for i := range newConditions { + if oldCond, exists := oldConditionsMap[newConditions[i].Type]; exists { + // If status hasn't changed, preserve the old LastTransitionTime + if oldCond.Status == newConditions[i].Status { + newConditions[i].LastTransitionTime = oldCond.LastTransitionTime + } + // If status changed, keep the new LastTransitionTime (already set to now) + } + // If this is a new condition type, keep the new LastTransitionTime + } + + // Marshal back to JSON + updatedJSON, err := json.Marshal(newConditions) + if err != nil { + // If we can't marshal, return new conditions as-is + return newConditionsJSON + } + + return updatedJSON +} diff --git a/pkg/dao/cluster.go b/pkg/dao/cluster.go index ecbb4dd..6c077df 100644 --- a/pkg/dao/cluster.go +++ b/pkg/dao/cluster.go @@ -1,6 +1,7 @@ package dao import ( + "bytes" "context" "gorm.io/gorm/clause" @@ -48,6 +49,23 @@ func (d *sqlClusterDao) Create(ctx context.Context, cluster *api.Cluster) (*api. func (d *sqlClusterDao) Replace(ctx context.Context, cluster *api.Cluster) (*api.Cluster, error) { g2 := (*d.sessionFactory).New(ctx) + + // Get the existing cluster to compare spec + existing, err := d.Get(ctx, cluster.ID) + if err != nil { + db.MarkForRollback(ctx, err) + return nil, err + } + + // Compare spec: if changed, increment generation + if !bytes.Equal(existing.Spec, cluster.Spec) { + cluster.Generation = existing.Generation + 1 + } else { + // Spec unchanged, preserve generation + cluster.Generation = existing.Generation + } + + // Save the cluster if err := g2.Omit(clause.Associations).Save(cluster).Error; err != nil { db.MarkForRollback(ctx, err) return nil, err diff --git a/pkg/db/migrations/202511111044_add_clusters.go b/pkg/db/migrations/202511111044_add_clusters.go index f093ccc..3145534 100644 --- a/pkg/db/migrations/202511111044_add_clusters.go +++ b/pkg/db/migrations/202511111044_add_clusters.go @@ -16,13 +16,13 @@ func addClusters() *gormigrate.Migration { ID: "202511111044", Migrate: func(tx *gorm.DB) error { // Create clusters table - // ClusterStatus is stored as JSONB in status_adapters, and status fields + // ClusterStatus is stored as JSONB in status_conditions, and status fields // are flattened for efficient querying createTableSQL := ` CREATE TABLE IF NOT EXISTS clusters ( id VARCHAR(255) PRIMARY KEY, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), deleted_at TIMESTAMPTZ NULL, -- Core fields @@ -39,8 +39,8 @@ func addClusters() *gormigrate.Migration { status_phase VARCHAR(50) NOT NULL DEFAULT 'NotReady', status_last_transition_time TIMESTAMPTZ NULL, status_observed_generation INTEGER NOT NULL DEFAULT 0, - status_updated_at TIMESTAMPTZ NULL, - status_adapters JSONB NULL, + status_last_updated_time TIMESTAMPTZ NULL, + status_conditions JSONB NULL, -- Audit fields created_by VARCHAR(255) NOT NULL, diff --git a/pkg/db/migrations/202511111055_add_node_pools.go b/pkg/db/migrations/202511111055_add_node_pools.go index b06b096..d40411e 100644 --- a/pkg/db/migrations/202511111055_add_node_pools.go +++ b/pkg/db/migrations/202511111055_add_node_pools.go @@ -19,8 +19,8 @@ func addNodePools() *gormigrate.Migration { createTableSQL := ` CREATE TABLE IF NOT EXISTS node_pools ( id VARCHAR(255) PRIMARY KEY, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), deleted_at TIMESTAMPTZ NULL, -- Core fields @@ -42,8 +42,8 @@ func addNodePools() *gormigrate.Migration { status_phase VARCHAR(50) NOT NULL DEFAULT 'NotReady', status_last_transition_time TIMESTAMPTZ NULL, status_observed_generation INTEGER NOT NULL DEFAULT 0, - status_updated_at TIMESTAMPTZ NULL, - status_adapters JSONB NULL, + status_last_updated_time TIMESTAMPTZ NULL, + status_conditions JSONB NULL, -- Audit fields created_by VARCHAR(255) NOT NULL, diff --git a/pkg/db/migrations/202511111105_add_adapter_status.go b/pkg/db/migrations/202511111105_add_adapter_status.go index 9bb21b9..947ba8c 100644 --- a/pkg/db/migrations/202511111105_add_adapter_status.go +++ b/pkg/db/migrations/202511111105_add_adapter_status.go @@ -20,8 +20,8 @@ func addAdapterStatus() *gormigrate.Migration { createTableSQL := ` CREATE TABLE IF NOT EXISTS adapter_statuses ( id VARCHAR(255) PRIMARY KEY, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), deleted_at TIMESTAMPTZ NULL, -- Polymorphic association @@ -32,6 +32,9 @@ func addAdapterStatus() *gormigrate.Migration { adapter VARCHAR(255) NOT NULL, observed_generation INTEGER NOT NULL, + -- API-managed timestamps + last_report_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), + -- Stored as JSONB conditions JSONB NOT NULL, data JSONB NULL, diff --git a/pkg/handlers/cluster_nodepools_test.go b/pkg/handlers/cluster_nodepools_test.go index 625aa38..84a4c75 100644 --- a/pkg/handlers/cluster_nodepools_test.go +++ b/pkg/handlers/cluster_nodepools_test.go @@ -151,9 +151,9 @@ func TestClusterNodePoolsHandler_Get(t *testing.T) { mockClusterFunc: func(ctx context.Context, id string) (*api.Cluster, *errors.ServiceError) { return &api.Cluster{ Meta: api.Meta{ - ID: clusterID, - CreatedAt: now, - UpdatedAt: now, + ID: clusterID, + CreatedTime: now, + UpdatedTime: now, }, Name: "test-cluster", }, nil @@ -161,9 +161,9 @@ func TestClusterNodePoolsHandler_Get(t *testing.T) { mockNodePoolFunc: func(ctx context.Context, id string) (*api.NodePool, *errors.ServiceError) { return &api.NodePool{ Meta: api.Meta{ - ID: nodePoolID, - CreatedAt: now, - UpdatedAt: now, + ID: nodePoolID, + CreatedTime: now, + UpdatedTime: now, }, Kind: "NodePool", Name: "test-nodepool", @@ -193,9 +193,9 @@ func TestClusterNodePoolsHandler_Get(t *testing.T) { mockClusterFunc: func(ctx context.Context, id string) (*api.Cluster, *errors.ServiceError) { return &api.Cluster{ Meta: api.Meta{ - ID: clusterID, - CreatedAt: now, - UpdatedAt: now, + ID: clusterID, + CreatedTime: now, + UpdatedTime: now, }, Name: "test-cluster", }, nil @@ -213,9 +213,9 @@ func TestClusterNodePoolsHandler_Get(t *testing.T) { mockClusterFunc: func(ctx context.Context, id string) (*api.Cluster, *errors.ServiceError) { return &api.Cluster{ Meta: api.Meta{ - ID: clusterID, - CreatedAt: now, - UpdatedAt: now, + ID: clusterID, + CreatedTime: now, + UpdatedTime: now, }, Name: "test-cluster", }, nil @@ -223,9 +223,9 @@ func TestClusterNodePoolsHandler_Get(t *testing.T) { mockNodePoolFunc: func(ctx context.Context, id string) (*api.NodePool, *errors.ServiceError) { return &api.NodePool{ Meta: api.Meta{ - ID: nodePoolID, - CreatedAt: now, - UpdatedAt: now, + ID: nodePoolID, + CreatedTime: now, + UpdatedTime: now, }, Kind: "NodePool", Name: "test-nodepool", diff --git a/pkg/handlers/cluster_status.go b/pkg/handlers/cluster_status.go index b45eb49..aabb16f 100644 --- a/pkg/handlers/cluster_status.go +++ b/pkg/handlers/cluster_status.go @@ -1,7 +1,6 @@ package handlers import ( - "encoding/json" "net/http" "github.com/gorilla/mux" @@ -79,26 +78,11 @@ func (h clusterStatusHandler) Create(w http.ResponseWriter, r *http.Request) { return nil, err } - // Check if adapter status already exists - existing, _ := h.adapterStatusService.FindByResourceAndAdapter(ctx, "Cluster", clusterID, req.Adapter) - - var adapterStatus *api.AdapterStatus - if existing != nil { - // Update existing - existing.ObservedGeneration = req.ObservedGeneration - conditionsJSON, _ := json.Marshal(req.Conditions) - existing.Conditions = conditionsJSON - if req.Data != nil { - dataJSON, _ := json.Marshal(req.Data) - existing.Data = dataJSON - } - adapterStatus, err = h.adapterStatusService.Replace(ctx, existing) - } else { - // Create new - newStatus := api.AdapterStatusFromOpenAPICreate("Cluster", clusterID, &req) - adapterStatus, err = h.adapterStatusService.Create(ctx, newStatus) - } + // Create adapter status from request + newStatus := api.AdapterStatusFromOpenAPICreate("Cluster", clusterID, &req) + // Upsert (create or update based on resource_type + resource_id + adapter) + adapterStatus, err := h.adapterStatusService.Upsert(ctx, newStatus) if err != nil { return nil, err } diff --git a/pkg/handlers/nodepool_status.go b/pkg/handlers/nodepool_status.go index 582770f..9de8aca 100644 --- a/pkg/handlers/nodepool_status.go +++ b/pkg/handlers/nodepool_status.go @@ -1,7 +1,6 @@ package handlers import ( - "encoding/json" "net/http" "github.com/gorilla/mux" @@ -79,26 +78,11 @@ func (h nodePoolStatusHandler) Create(w http.ResponseWriter, r *http.Request) { return nil, err } - // Check if adapter status already exists - existing, _ := h.adapterStatusService.FindByResourceAndAdapter(ctx, "NodePool", nodePoolID, req.Adapter) - - var adapterStatus *api.AdapterStatus - if existing != nil { - // Update existing - existing.ObservedGeneration = req.ObservedGeneration - conditionsJSON, _ := json.Marshal(req.Conditions) - existing.Conditions = conditionsJSON - if req.Data != nil { - dataJSON, _ := json.Marshal(req.Data) - existing.Data = dataJSON - } - adapterStatus, err = h.adapterStatusService.Replace(ctx, existing) - } else { - // Create new - newStatus := api.AdapterStatusFromOpenAPICreate("NodePool", nodePoolID, &req) - adapterStatus, err = h.adapterStatusService.Create(ctx, newStatus) - } + // Create adapter status from request + newStatus := api.AdapterStatusFromOpenAPICreate("NodePool", nodePoolID, &req) + // Upsert (create or update based on resource_type + resource_id + adapter) + adapterStatus, err := h.adapterStatusService.Upsert(ctx, newStatus) if err != nil { return nil, err } diff --git a/pkg/services/adapter_status.go b/pkg/services/adapter_status.go index 1295ffa..7b56446 100644 --- a/pkg/services/adapter_status.go +++ b/pkg/services/adapter_status.go @@ -12,6 +12,7 @@ type AdapterStatusService interface { Get(ctx context.Context, id string) (*api.AdapterStatus, *errors.ServiceError) Create(ctx context.Context, adapterStatus *api.AdapterStatus) (*api.AdapterStatus, *errors.ServiceError) Replace(ctx context.Context, adapterStatus *api.AdapterStatus) (*api.AdapterStatus, *errors.ServiceError) + Upsert(ctx context.Context, adapterStatus *api.AdapterStatus) (*api.AdapterStatus, *errors.ServiceError) Delete(ctx context.Context, id string) *errors.ServiceError FindByResource(ctx context.Context, resourceType, resourceID string) (api.AdapterStatusList, *errors.ServiceError) FindByResourcePaginated(ctx context.Context, resourceType, resourceID string, listArgs *ListArguments) (api.AdapterStatusList, int64, *errors.ServiceError) @@ -55,6 +56,14 @@ func (s *sqlAdapterStatusService) Replace(ctx context.Context, adapterStatus *ap return adapterStatus, nil } +func (s *sqlAdapterStatusService) Upsert(ctx context.Context, adapterStatus *api.AdapterStatus) (*api.AdapterStatus, *errors.ServiceError) { + adapterStatus, err := s.adapterStatusDao.Upsert(ctx, adapterStatus) + if err != nil { + return nil, handleCreateError("AdapterStatus", err) + } + return adapterStatus, nil +} + func (s *sqlAdapterStatusService) Delete(ctx context.Context, id string) *errors.ServiceError { if err := s.adapterStatusDao.Delete(ctx, id); err != nil { return handleDeleteError("AdapterStatus", errors.GeneralError("Unable to delete adapter status: %s", err)) diff --git a/pkg/services/cluster.go b/pkg/services/cluster.go index 466fdc6..4a8371f 100644 --- a/pkg/services/cluster.go +++ b/pkg/services/cluster.go @@ -129,21 +129,19 @@ func (s *sqlClusterService) UpdateClusterStatusFromAdapters(ctx context.Context, return nil, errors.GeneralError("Failed to get adapter statuses: %s", err) } - // Build the list of ConditionAvailable - adapters := []openapi.ConditionAvailable{} - allReady := true - anyFailed := false + // Build the list of ResourceCondition + adapters := []openapi.ResourceCondition{} maxObservedGeneration := int32(0) for _, adapterStatus := range adapterStatuses { // Unmarshal Conditions from JSONB - var conditions []openapi.Condition + var conditions []openapi.AdapterCondition if err := json.Unmarshal(adapterStatus.Conditions, &conditions); err != nil { continue // Skip if can't unmarshal } // Find the "Available" condition - var availableCondition *openapi.Condition + var availableCondition *openapi.AdapterCondition for i := range conditions { if conditions[i].Type == "Available" { availableCondition = &conditions[i] @@ -152,61 +150,77 @@ func (s *sqlClusterService) UpdateClusterStatusFromAdapters(ctx context.Context, } if availableCondition == nil { - // No Available condition means adapter is not ready - allReady = false + // No Available condition, skip this adapter continue } - // Convert to ConditionAvailable - condAvail := openapi.ConditionAvailable{ - Type: availableCondition.Type, - Adapter: adapterStatus.Adapter, + // Convert to ResourceCondition + condResource := openapi.ResourceCondition{ + Type: MapAdapterToConditionType(adapterStatus.Adapter), Status: availableCondition.Status, Reason: availableCondition.Reason, Message: availableCondition.Message, - ObservedGeneration: availableCondition.ObservedGeneration, + ObservedGeneration: adapterStatus.ObservedGeneration, + LastTransitionTime: availableCondition.LastTransitionTime, } - adapters = append(adapters, condAvail) - // Check status - if availableCondition.Status != "True" { - allReady = false - if availableCondition.Status == "False" { - anyFailed = true - } + // Set CreatedTime with nil check + if adapterStatus.CreatedTime != nil { + condResource.CreatedTime = *adapterStatus.CreatedTime + } + + // Set LastUpdatedTime with nil check + if adapterStatus.LastReportTime != nil { + condResource.LastUpdatedTime = *adapterStatus.LastReportTime } + adapters = append(adapters, condResource) + // Track max observed generation if adapterStatus.ObservedGeneration > maxObservedGeneration { maxObservedGeneration = adapterStatus.ObservedGeneration } } - // Compute overall phase - phase := "NotReady" - if len(adapterStatuses) > 0 { - if allReady { - phase = "Ready" - } else if anyFailed { - phase = "Failed" + // Compute overall phase using required adapters + newPhase := ComputePhase(ctx, adapterStatuses, requiredClusterAdapters, cluster.Generation) + + // Calculate min(adapters[].last_report_time) for cluster.status.last_updated_time + // This uses the OLDEST adapter timestamp to ensure Sentinel can detect stale adapters + var minLastUpdatedTime *time.Time + for _, adapterStatus := range adapterStatuses { + if adapterStatus.LastReportTime != nil { + if minLastUpdatedTime == nil || adapterStatus.LastReportTime.Before(*minLastUpdatedTime) { + minLastUpdatedTime = adapterStatus.LastReportTime + } } } + // Save old phase to detect transitions + oldPhase := cluster.StatusPhase + // Update cluster status fields now := time.Now() - cluster.StatusPhase = phase + cluster.StatusPhase = newPhase cluster.StatusObservedGeneration = maxObservedGeneration - // Marshal adapters to JSON - adaptersJSON, err := json.Marshal(adapters) + // Marshal conditions to JSON + conditionsJSON, err := json.Marshal(adapters) if err != nil { - return nil, errors.GeneralError("Failed to marshal adapters: %s", err) + return nil, errors.GeneralError("Failed to marshal conditions: %s", err) + } + cluster.StatusConditions = conditionsJSON + + // Use min(adapters[].last_report_time) instead of now() + // This ensures Sentinel triggers reconciliation when ANY adapter is stale + if minLastUpdatedTime != nil { + cluster.StatusLastUpdatedTime = minLastUpdatedTime + } else { + cluster.StatusLastUpdatedTime = &now } - cluster.StatusAdapters = adaptersJSON - cluster.StatusUpdatedAt = &now - // Update last transition time if phase changed - if cluster.StatusLastTransitionTime == nil || cluster.StatusPhase != phase { + // Update last transition time only if phase changed + if cluster.StatusLastTransitionTime == nil || oldPhase != newPhase { cluster.StatusLastTransitionTime = &now } diff --git a/pkg/services/node_pool.go b/pkg/services/node_pool.go index 63ee330..9fa1336 100644 --- a/pkg/services/node_pool.go +++ b/pkg/services/node_pool.go @@ -129,21 +129,19 @@ func (s *sqlNodePoolService) UpdateNodePoolStatusFromAdapters(ctx context.Contex return nil, errors.GeneralError("Failed to get adapter statuses: %s", err) } - // Build the list of ConditionAvailable - adapters := []openapi.ConditionAvailable{} - allReady := true - anyFailed := false + // Build the list of ResourceCondition + adapters := []openapi.ResourceCondition{} maxObservedGeneration := int32(0) for _, adapterStatus := range adapterStatuses { // Unmarshal Conditions from JSONB - var conditions []openapi.Condition + var conditions []openapi.AdapterCondition if err := json.Unmarshal(adapterStatus.Conditions, &conditions); err != nil { continue // Skip if can't unmarshal } // Find the "Available" condition - var availableCondition *openapi.Condition + var availableCondition *openapi.AdapterCondition for i := range conditions { if conditions[i].Type == "Available" { availableCondition = &conditions[i] @@ -152,61 +150,78 @@ func (s *sqlNodePoolService) UpdateNodePoolStatusFromAdapters(ctx context.Contex } if availableCondition == nil { - // No Available condition means adapter is not ready - allReady = false + // No Available condition, skip this adapter continue } - // Convert to ConditionAvailable - condAvail := openapi.ConditionAvailable{ - Type: availableCondition.Type, - Adapter: adapterStatus.Adapter, + // Convert to ResourceCondition + condResource := openapi.ResourceCondition{ + Type: MapAdapterToConditionType(adapterStatus.Adapter), Status: availableCondition.Status, Reason: availableCondition.Reason, Message: availableCondition.Message, - ObservedGeneration: availableCondition.ObservedGeneration, + ObservedGeneration: adapterStatus.ObservedGeneration, + LastTransitionTime: availableCondition.LastTransitionTime, } - adapters = append(adapters, condAvail) - // Check status - if availableCondition.Status != "True" { - allReady = false - if availableCondition.Status == "False" { - anyFailed = true - } + // Set CreatedTime with nil check + if adapterStatus.CreatedTime != nil { + condResource.CreatedTime = *adapterStatus.CreatedTime + } + + // Set LastUpdatedTime with nil check + if adapterStatus.LastReportTime != nil { + condResource.LastUpdatedTime = *adapterStatus.LastReportTime } + adapters = append(adapters, condResource) + // Track max observed generation if adapterStatus.ObservedGeneration > maxObservedGeneration { maxObservedGeneration = adapterStatus.ObservedGeneration } } - // Compute overall phase - phase := "NotReady" - if len(adapterStatuses) > 0 { - if allReady { - phase = "Ready" - } else if anyFailed { - phase = "Failed" + // Compute overall phase using required adapters + // NodePool doesn't have a generation field, so we pass 0 which means "don't check generation" + newPhase := ComputePhase(ctx, adapterStatuses, requiredNodePoolAdapters, 0) + + // Calculate min(adapters[].last_report_time) for nodepool.status.last_updated_time + // This uses the OLDEST adapter timestamp to ensure Sentinel can detect stale adapters + var minLastUpdatedTime *time.Time + for _, adapterStatus := range adapterStatuses { + if adapterStatus.LastReportTime != nil { + if minLastUpdatedTime == nil || adapterStatus.LastReportTime.Before(*minLastUpdatedTime) { + minLastUpdatedTime = adapterStatus.LastReportTime + } } } + // Save old phase to detect transitions + oldPhase := nodePool.StatusPhase + // Update nodepool status fields now := time.Now() - nodePool.StatusPhase = phase + nodePool.StatusPhase = newPhase nodePool.StatusObservedGeneration = maxObservedGeneration - // Marshal adapters to JSON - adaptersJSON, err := json.Marshal(adapters) + // Marshal conditions to JSON + conditionsJSON, err := json.Marshal(adapters) if err != nil { - return nil, errors.GeneralError("Failed to marshal adapters: %s", err) + return nil, errors.GeneralError("Failed to marshal conditions: %s", err) + } + nodePool.StatusConditions = conditionsJSON + + // Use min(adapters[].last_report_time) instead of now() + // This ensures Sentinel triggers reconciliation when ANY adapter is stale + if minLastUpdatedTime != nil { + nodePool.StatusLastUpdatedTime = minLastUpdatedTime + } else { + nodePool.StatusLastUpdatedTime = &now } - nodePool.StatusAdapters = adaptersJSON - nodePool.StatusUpdatedAt = &now - // Update last transition time if phase changed - if nodePool.StatusLastTransitionTime == nil || nodePool.StatusPhase != phase { + // Update last transition time only if phase changed + if nodePool.StatusLastTransitionTime == nil || oldPhase != newPhase { nodePool.StatusLastTransitionTime = &now } diff --git a/pkg/services/status_aggregation.go b/pkg/services/status_aggregation.go new file mode 100644 index 0000000..cbe24d8 --- /dev/null +++ b/pkg/services/status_aggregation.go @@ -0,0 +1,174 @@ +package services + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "unicode" + + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" +) + +// requiredClusterAdapters defines the list of adapters that must be ready for a cluster to be "Ready" +// All of these adapters must have: +// 1. available === "True" +// 2. observed_generation === cluster.generation +// +// Based on HyperFleet MVP scope (GCP cluster adapters): +// - validation: Check GCP prerequisites +// - dns: Create Cloud DNS records +// - pullsecret: Store credentials in Secret Manager +// - hypershift: Create HostedCluster CR +// Note: placement is NOT required (handled separately) +var requiredClusterAdapters = []string{ + "validation", + "dns", + "pullsecret", + "hypershift", +} + +// requiredNodePoolAdapters defines the list of adapters that must be ready for a nodepool to be "Ready" +// Based on HyperFleet MVP scope (GCP nodepool adapters): +// - validation: Check nodepool prerequisites +// - hypershift: Create NodePool CR +var requiredNodePoolAdapters = []string{ + "validation", + "hypershift", +} + +// adapterConditionSuffixMap allows overriding the default suffix for specific adapters +// Currently empty - all adapters use "Successful" by default +// Future example: To make dns use "Ready" instead, uncomment: +// "dns": "Ready", +var adapterConditionSuffixMap = map[string]string{ + // Add custom mappings here when needed +} + +// MapAdapterToConditionType converts an adapter name to a semantic condition type +// by converting the adapter name to PascalCase and appending a suffix. +// +// Current behavior: All adapters → {AdapterName}Successful +// Examples: +// - "validator" → "ValidatorSuccessful" +// - "dns" → "DnsSuccessful" +// - "gcp-provisioner" → "GcpProvisionerSuccessful" +// +// Future customization: Override suffix in adapterConditionSuffixMap +// adapterConditionSuffixMap["dns"] = "Ready" → "DnsReady" +func MapAdapterToConditionType(adapterName string) string { + // Get the suffix for this adapter, default to "Successful" + suffix, exists := adapterConditionSuffixMap[adapterName] + if !exists { + suffix = "Successful" + } + + // Convert adapter name to PascalCase + // Remove hyphens and capitalize each part + parts := strings.Split(adapterName, "-") + var result strings.Builder + + for _, part := range parts { + if len(part) > 0 { + // Capitalize first letter + runes := []rune(part) + runes[0] = unicode.ToUpper(runes[0]) + result.WriteString(string(runes)) + } + } + + result.WriteString(suffix) + return result.String() +} + +// ComputePhase calculates the overall phase for a resource based on adapter statuses +// Phase is "Ready" when: numRequiredAdapters == numAdaptersAvailable +// +// An adapter is considered "available" when: +// 1. Available condition status == "True" +// 2. observed_generation == resource.generation (only checked if resourceGeneration > 0) +// +// Returns: +// - "Ready": Number of required adapters == number of available adapters +// - "Failed": At least one adapter has status == "False" +// - "NotReady": Otherwise (missing adapters, stale generation, status == "Unknown", etc.) +func ComputePhase(ctx context.Context, adapterStatuses api.AdapterStatusList, requiredAdapters []string, resourceGeneration int32) string { + log := logger.NewOCMLogger(ctx) + if len(adapterStatuses) == 0 { + return "NotReady" + } + + // Build a map of adapter name -> (available status, observed generation) + adapterMap := make(map[string]struct { + available string + observedGeneration int32 + }) + + for _, adapterStatus := range adapterStatuses { + // Unmarshal conditions to find "Available" + var conditions []struct { + Type string `json:"type"` + Status string `json:"status"` + } + if len(adapterStatus.Conditions) > 0 { + if err := json.Unmarshal(adapterStatus.Conditions, &conditions); err == nil { + for _, cond := range conditions { + if cond.Type == "Available" { + adapterMap[adapterStatus.Adapter] = struct { + available string + observedGeneration int32 + }{ + available: cond.Status, + observedGeneration: adapterStatus.ObservedGeneration, + } + break + } + } + } + } + } + + // Count available adapters and check for failures + numAvailable := 0 + anyFailed := false + + // Iterate over required adapters + for _, adapterName := range requiredAdapters { + adapterInfo, exists := adapterMap[adapterName] + + if !exists { + // Required adapter not found + log.Warning(fmt.Sprintf("Required adapter '%s' not found in adapter statuses", adapterName)) + continue + } + + // Check generation matching only if resourceGeneration > 0 + if resourceGeneration > 0 && adapterInfo.observedGeneration != resourceGeneration { + // Adapter is processing old generation (stale) + log.Warning(fmt.Sprintf("Required adapter '%s' has stale generation: observed=%d, expected=%d", + adapterName, adapterInfo.observedGeneration, resourceGeneration)) + continue + } + + // Check available status + if adapterInfo.available == "False" { + anyFailed = true + } else if adapterInfo.available == "True" { + numAvailable++ + } else { + // Status is neither "True" nor "False" (e.g., "Unknown") + log.Warning(fmt.Sprintf("Required adapter '%s' has unexpected available status: %s", adapterName, adapterInfo.available)) + } + } + + // Determine phase: Ready when numRequiredAdapters == numAdaptersAvailable + numRequired := len(requiredAdapters) + if numAvailable == numRequired && numRequired > 0 { + return "Ready" + } + if anyFailed { + return "Failed" + } + return "NotReady" +} diff --git a/pkg/services/status_aggregation_test.go b/pkg/services/status_aggregation_test.go new file mode 100644 index 0000000..56eedc6 --- /dev/null +++ b/pkg/services/status_aggregation_test.go @@ -0,0 +1,53 @@ +package services + +import "testing" + +func TestMapAdapterToConditionType(t *testing.T) { + tests := []struct { + adapter string + expected string + }{ + {"validator", "ValidatorSuccessful"}, + {"dns", "DnsSuccessful"}, + {"gcp-provisioner", "GcpProvisionerSuccessful"}, + {"unknown-adapter", "UnknownAdapterSuccessful"}, + {"multi-word-adapter", "MultiWordAdapterSuccessful"}, + {"single", "SingleSuccessful"}, + } + + for _, tt := range tests { + result := MapAdapterToConditionType(tt.adapter) + if result != tt.expected { + t.Errorf("MapAdapterToConditionType(%q) = %q, want %q", + tt.adapter, result, tt.expected) + } + } +} + +// Test custom suffix mapping (for future use) +func TestMapAdapterToConditionType_CustomSuffix(t *testing.T) { + // Temporarily add a custom mapping + adapterConditionSuffixMap["test-adapter"] = "Ready" + defer delete(adapterConditionSuffixMap, "test-adapter") + + result := MapAdapterToConditionType("test-adapter") + expected := "TestAdapterReady" + if result != expected { + t.Errorf("MapAdapterToConditionType(%q) = %q, want %q", + "test-adapter", result, expected) + } +} + +// Test that default behavior still works after custom suffix is removed +func TestMapAdapterToConditionType_DefaultAfterCustom(t *testing.T) { + // Add and then remove custom mapping + adapterConditionSuffixMap["dns"] = "Ready" + delete(adapterConditionSuffixMap, "dns") + + result := MapAdapterToConditionType("dns") + expected := "DnsSuccessful" + if result != expected { + t.Errorf("MapAdapterToConditionType(%q) = %q, want %q (should revert to default)", + "dns", result, expected) + } +} diff --git a/test/integration/adapter_status_test.go b/test/integration/adapter_status_test.go index f442caa..ab2215f 100644 --- a/test/integration/adapter_status_test.go +++ b/test/integration/adapter_status_test.go @@ -26,15 +26,15 @@ func TestClusterStatusPost(t *testing.T) { statusInput := openapi.AdapterStatusCreateRequest{ Adapter: "test-adapter", ObservedGeneration: cluster.Generation, - Conditions: []openapi.Condition{ + Conditions: []openapi.ConditionRequest{ { Type: "Ready", Status: "True", Reason: openapi.PtrString("AdapterReady"), }, }, - Data: map[string]interface{}{ - "test_key": "test_value", + Data: map[string]map[string]interface{}{ + "test_key": {"value": "test_value"}, }, } @@ -62,7 +62,7 @@ func TestClusterStatusGet(t *testing.T) { statusInput := openapi.AdapterStatusCreateRequest{ Adapter: fmt.Sprintf("adapter-%d", i), ObservedGeneration: cluster.Generation, - Conditions: []openapi.Condition{ + Conditions: []openapi.ConditionRequest{ { Type: "Ready", Status: "True", @@ -99,15 +99,15 @@ func TestNodePoolStatusPost(t *testing.T) { statusInput := openapi.AdapterStatusCreateRequest{ Adapter: "test-nodepool-adapter", ObservedGeneration: 1, - Conditions: []openapi.Condition{ + Conditions: []openapi.ConditionRequest{ { Type: "Ready", Status: "False", Reason: openapi.PtrString("Initializing"), }, }, - Data: map[string]interface{}{ - "nodepool_data": "value", + Data: map[string]map[string]interface{}{ + "nodepool_data": {"value": "test_value"}, }, } @@ -135,7 +135,7 @@ func TestNodePoolStatusGet(t *testing.T) { statusInput := openapi.AdapterStatusCreateRequest{ Adapter: fmt.Sprintf("nodepool-adapter-%d", i), ObservedGeneration: 1, - Conditions: []openapi.Condition{ + Conditions: []openapi.ConditionRequest{ { Type: "Ready", Status: "True", @@ -171,7 +171,7 @@ func TestAdapterStatusPaging(t *testing.T) { statusInput := openapi.AdapterStatusCreateRequest{ Adapter: fmt.Sprintf("adapter-%d", i), ObservedGeneration: cluster.Generation, - Conditions: []openapi.Condition{ + Conditions: []openapi.ConditionRequest{ { Type: "Ready", Status: "True", @@ -204,15 +204,15 @@ func TestAdapterStatusIdempotency(t *testing.T) { statusInput1 := openapi.AdapterStatusCreateRequest{ Adapter: "idempotency-test-adapter", ObservedGeneration: cluster.Generation, - Conditions: []openapi.Condition{ + Conditions: []openapi.ConditionRequest{ { Type: "Ready", Status: "False", Reason: openapi.PtrString("Initializing"), }, }, - Data: map[string]interface{}{ - "version": "1.0", + Data: map[string]map[string]interface{}{ + "version": {"value": "1.0"}, }, } @@ -226,15 +226,15 @@ func TestAdapterStatusIdempotency(t *testing.T) { statusInput2 := openapi.AdapterStatusCreateRequest{ Adapter: "idempotency-test-adapter", ObservedGeneration: cluster.Generation, - Conditions: []openapi.Condition{ + Conditions: []openapi.ConditionRequest{ { Type: "Ready", Status: "True", Reason: openapi.PtrString("AdapterReady"), }, }, - Data: map[string]interface{}{ - "version": "2.0", + Data: map[string]map[string]interface{}{ + "version": {"value": "2.0"}, }, } @@ -279,7 +279,7 @@ func TestAdapterStatusPagingEdgeCases(t *testing.T) { statusInput := openapi.AdapterStatusCreateRequest{ Adapter: fmt.Sprintf("edge-adapter-%d", i), ObservedGeneration: cluster.Generation, - Conditions: []openapi.Condition{ + Conditions: []openapi.ConditionRequest{ { Type: "Ready", Status: "True", @@ -312,7 +312,7 @@ func TestAdapterStatusPagingEdgeCases(t *testing.T) { singleStatus := openapi.AdapterStatusCreateRequest{ Adapter: "single-adapter", ObservedGeneration: singleCluster.Generation, - Conditions: []openapi.Condition{ + Conditions: []openapi.ConditionRequest{ { Type: "Ready", Status: "True", diff --git a/test/integration/clusters_test.go b/test/integration/clusters_test.go index 9aa653d..c76ab2f 100644 --- a/test/integration/clusters_test.go +++ b/test/integration/clusters_test.go @@ -38,8 +38,8 @@ func TestClusterGet(t *testing.T) { Expect(*clusterOutput.Id).To(Equal(clusterModel.ID), "found object does not match test object") Expect(clusterOutput.Kind).To(Equal("Cluster")) Expect(*clusterOutput.Href).To(Equal(fmt.Sprintf("/api/hyperfleet/v1/clusters/%s", clusterModel.ID))) - Expect(clusterOutput.CreatedAt).To(BeTemporally("~", clusterModel.CreatedAt)) - Expect(clusterOutput.UpdatedAt).To(BeTemporally("~", clusterModel.UpdatedAt)) + Expect(clusterOutput.CreatedTime).To(BeTemporally("~", clusterModel.CreatedTime)) + Expect(clusterOutput.UpdatedTime).To(BeTemporally("~", clusterModel.UpdatedTime)) } func TestClusterPost(t *testing.T) {