diff --git a/.github/workflows/nightly-release.yaml b/.github/workflows/nightly-release.yaml index 9786ac36c..fa2eb3a03 100644 --- a/.github/workflows/nightly-release.yaml +++ b/.github/workflows/nightly-release.yaml @@ -14,9 +14,22 @@ jobs: test: uses: ./.github/workflows/pr-tests.yaml + smoke-test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 + + - name: Setup Environment (Go, Node, Homebrew, UDS CLI, k3d) + uses: ./.github/actions/setup + + - name: smoke-test + run: | + uds run test:smoke + push: runs-on: ubuntu-latest - needs: test + needs: [test, smoke-test] permissions: contents: write packages: write @@ -37,16 +50,17 @@ jobs: - name: Setup Environment (Go, Node, Homebrew, UDS CLI, k3d) uses: ./.github/actions/setup - - name: smoke-test - run: uds run test:smoke + - name: Iron Bank Login + env: + REGISTRY_USERNAME: ${{ secrets.IRON_BANK_ROBOT_USERNAME }} + REGISTRY_PASSWORD: ${{ secrets.IRON_BANK_ROBOT_PASSWORD }} + run: echo "${{ env.REGISTRY_PASSWORD }}" | uds zarf tools registry login -u "${{ env.REGISTRY_USERNAME }}" --password-stdin registry1.dso.mil + shell: bash - name: Publish run: | - uds run build:publish-uds-runtime --set REF=nightly-unstable --set DIR=hack/nightly - - - name: Build binary artifacts - run: | - uds run build:all + uds run build:publish-uds-runtime --set REF=nightly-unstable --set FLAVOR=unicorn --set DIR=hack/nightly + uds run build:publish-uds-runtime --set REF=nightly-unstable --set FLAVOR=registry1 --set DIR=hack/nightly - name: Update nightly-unstable tag env: diff --git a/.github/workflows/post-release-tests.yaml b/.github/workflows/post-release-tests.yaml new file mode 100644 index 000000000..948a3841c --- /dev/null +++ b/.github/workflows/post-release-tests.yaml @@ -0,0 +1,36 @@ +# Copyright 2024 Defense Unicorns +# SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial + +# This workflow runs smoke tests against the unicorn and registry1 flavors of the released UDS Runtime packages. +name: Post Release Tests +on: + workflow_dispatch: + schedule: + - cron: "0 9 * * 1" # Runs Mondays at 9:00 AM UTC, which is 3:00 AM MT during Daylight Saving Time + +permissions: + contents: read + +jobs: + smoke-test-flavors: + runs-on: ubuntu-latest + strategy: + matrix: + flavor: [registry1, unicorn] + steps: + - name: Checkout + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 + + - name: Setup Environment (Go, Node, Homebrew, UDS CLI, k3d) + uses: ./.github/actions/setup + + - name: Iron Bank Login + env: + REGISTRY_USERNAME: ${{ secrets.IRON_BANK_ROBOT_USERNAME }} + REGISTRY_PASSWORD: ${{ secrets.IRON_BANK_ROBOT_PASSWORD }} + run: echo "${{ env.REGISTRY_PASSWORD }}" | uds zarf tools registry login -u "${{ env.REGISTRY_USERNAME }}" --password-stdin registry1.dso.mil + shell: bash + + - name: smoke-test + run: | + uds run test:smoke-flavor --set FLAVOR=${{ matrix.flavor }} diff --git a/.github/workflows/pr-tests.yaml b/.github/workflows/pr-tests.yaml index 829584186..be08a3dd6 100644 --- a/.github/workflows/pr-tests.yaml +++ b/.github/workflows/pr-tests.yaml @@ -31,7 +31,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - type: [unit, e2e, e2e-in-cluster, api, local-auth, e2e-reconnect] + type: [unit, e2e, e2e-in-cluster, api, local-auth, e2e-connections] steps: - name: Checkout uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 diff --git a/.github/workflows/tag-and-release.yaml b/.github/workflows/tag-and-release.yaml index ae48172a8..85eeb989e 100644 --- a/.github/workflows/tag-and-release.yaml +++ b/.github/workflows/tag-and-release.yaml @@ -12,6 +12,7 @@ permissions: contents: read jobs: + tag-new-version: permissions: write-all runs-on: ubuntu-latest @@ -50,14 +51,19 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: smoke-test - run: uds run test:smoke --set DIR=hack/test + run: uds run test:smoke + + - name: Iron Bank Login + env: + REGISTRY1_USERNAME: ${{ secrets.IRON_BANK_ROBOT_USERNAME }} + REGISTRY1_PASSWORD: ${{ secrets.IRON_BANK_ROBOT_PASSWORD }} + run: echo "${{ env.REGISTRY1_PASSWORD }}" | uds zarf tools registry login -u "${{ env.REGISTRY1_USERNAME }}" --password-stdin registry1.dso.mil + shell: bash - name: Publish run: | - uds run build:publish-uds-runtime - - - name: Build binary artifacts - run: uds run build:all + uds run build:publish-uds-runtime --set FLAVOR=unicorn + uds run build:publish-uds-runtime --set FLAVOR=registry1 - name: Tar ui/build for release run: tar -czf build/uds-runtime-ui.tar.gz ui/build diff --git a/hack/flavors/common/zarf.yaml b/hack/flavors/common/zarf.yaml new file mode 100644 index 000000000..fccebde14 --- /dev/null +++ b/hack/flavors/common/zarf.yaml @@ -0,0 +1,26 @@ +# Copyright 2024 Defense Unicorns +# SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial + +kind: ZarfPackageConfig +metadata: + name: uds-common-runtime + description: "UDS Common Runtime" +components: + - name: uds-runtime + required: true + charts: + - name: uds-runtime + localPath: ../../../chart + namespace: uds-runtime + version: 0.1.0 + actions: + onDeploy: + after: + - description: Validate Runtime Package + maxTotalSeconds: 300 + wait: + cluster: + kind: packages.uds.dev + name: uds-runtime + namespace: uds-runtime + condition: "'{.status.phase}'=Ready" diff --git a/hack/flavors/values/registry1-values.yaml b/hack/flavors/values/registry1-values.yaml new file mode 100644 index 000000000..abc1d6dad --- /dev/null +++ b/hack/flavors/values/registry1-values.yaml @@ -0,0 +1,6 @@ +# Copyright 2024 Defense Unicorns +# SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial + +image: + repository: registry1.dso.mil/ironbank/opensource/defenseunicorns/uds/runtime + tag: v0.8.0 diff --git a/release-please-config.json b/release-please-config.json index e451f792b..eac99705a 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -14,6 +14,7 @@ "zarf.yaml", "tasks.yaml", "tasks/build.yaml", + "tasks/test.yaml", "chart/values.yaml" ] } diff --git a/src/pkg/api/docs/docs.go b/src/pkg/api/docs/docs.go index 39527315c..b026e6254 100644 --- a/src/pkg/api/docs/docs.go +++ b/src/pkg/api/docs/docs.go @@ -28,6 +28,52 @@ const docTemplate = `{ } } }, + "/api/v1/cluster": { + "post": { + "description": "Switch the currently viewed cluster", + "consumes": [ + "application/json" + ], + "tags": [ + "cluster" + ], + "parameters": [ + { + "description": "example body: {", + "name": "request", + "in": "body", + "required": true, + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/client.ClusterInfo" + } + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/clusters": { + "get": { + "description": "get list of clusters with context names from local kubeconfig", + "produces": [ + "application/json" + ], + "tags": [ + "clusters" + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, "/api/v1/resources/cluster-ops/hpas": { "get": { "description": "Get HPAs", @@ -3213,6 +3259,22 @@ const docTemplate = `{ } } } + }, + "definitions": { + "client.ClusterInfo": { + "type": "object", + "properties": { + "context": { + "type": "string" + }, + "name": { + "type": "string" + }, + "selected": { + "type": "boolean" + } + } + } } }` diff --git a/src/pkg/api/docs/swagger.json b/src/pkg/api/docs/swagger.json index 7cdb68b81..8715a0b67 100644 --- a/src/pkg/api/docs/swagger.json +++ b/src/pkg/api/docs/swagger.json @@ -17,6 +17,52 @@ } } }, + "/api/v1/cluster": { + "post": { + "description": "Switch the currently viewed cluster", + "consumes": [ + "application/json" + ], + "tags": [ + "cluster" + ], + "parameters": [ + { + "description": "example body: {", + "name": "request", + "in": "body", + "required": true, + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/client.ClusterInfo" + } + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/clusters": { + "get": { + "description": "get list of clusters with context names from local kubeconfig", + "produces": [ + "application/json" + ], + "tags": [ + "clusters" + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, "/api/v1/resources/cluster-ops/hpas": { "get": { "description": "Get HPAs", @@ -3202,5 +3248,21 @@ } } } + }, + "definitions": { + "client.ClusterInfo": { + "type": "object", + "properties": { + "context": { + "type": "string" + }, + "name": { + "type": "string" + }, + "selected": { + "type": "boolean" + } + } + } } } \ No newline at end of file diff --git a/src/pkg/api/docs/swagger.yaml b/src/pkg/api/docs/swagger.yaml index dcdbf11d9..cf7ecbce3 100644 --- a/src/pkg/api/docs/swagger.yaml +++ b/src/pkg/api/docs/swagger.yaml @@ -1,3 +1,13 @@ +definitions: + client.ClusterInfo: + properties: + context: + type: string + name: + type: string + selected: + type: boolean + type: object info: contact: {} paths: @@ -10,6 +20,35 @@ paths: description: OK tags: - auth + /api/v1/cluster: + post: + consumes: + - application/json + description: Switch the currently viewed cluster + parameters: + - description: 'example body: {' + in: body + name: request + required: true + schema: + additionalProperties: + $ref: '#/definitions/client.ClusterInfo' + type: object + responses: + "200": + description: OK + tags: + - cluster + /api/v1/clusters: + get: + description: get list of clusters with context names from local kubeconfig + produces: + - application/json + responses: + "200": + description: OK + tags: + - clusters /api/v1/resources/cluster-ops/hpas: get: consumes: diff --git a/src/pkg/api/handlers.go b/src/pkg/api/handlers.go index b475e2f3d..f5cb1b5db 100644 --- a/src/pkg/api/handlers.go +++ b/src/pkg/api/handlers.go @@ -13,6 +13,7 @@ import ( _ "github.com/defenseunicorns/uds-runtime/src/pkg/api/docs" //nolint:staticcheck "github.com/defenseunicorns/uds-runtime/src/pkg/api/resources" "github.com/defenseunicorns/uds-runtime/src/pkg/api/rest" + "github.com/defenseunicorns/uds-runtime/src/pkg/k8s/client" "github.com/defenseunicorns/uds-runtime/src/pkg/k8s/session" ) @@ -965,3 +966,22 @@ func healthz(w http.ResponseWriter, _ *http.Request) { http.Error(w, "Internal Server Error", http.StatusInternalServerError) } } + +// @Description get list of clusters with context names from local kubeconfig +// @Tags clusters +// @Produce json +// @Success 200 +// @Router /api/v1/clusters [get] +func getClusters(ks *session.K8sSession) func(w http.ResponseWriter, r *http.Request) { + return client.ServeClusters(ks.InCluster, ks.CurrentCtx) +} + +// @Description Switch the currently viewed cluster +// @Tags cluster +// @Param request body map[string]client.ClusterInfo true "example body: {"cluster": {"name": string, "context": string, "selected": bool}}" +// @Accept json +// @Success 200 +// @Router /api/v1/cluster [post] +func switchCluster(ks *session.K8sSession) func(w http.ResponseWriter, r *http.Request) { + return ks.Switch() +} diff --git a/src/pkg/api/monitor/cluster.go b/src/pkg/api/monitor/cluster.go index fe116976d..52124cced 100644 --- a/src/pkg/api/monitor/cluster.go +++ b/src/pkg/api/monitor/cluster.go @@ -16,15 +16,20 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" ) -type ClusterData struct { - TotalPods int `json:"totalPods"` - TotalNodes int `json:"totalNodes"` - CPUCapacity float64 `json:"cpuCapacity"` - MemoryCapacity float64 `json:"memoryCapacity"` - CurrentUsage resources.Usage `json:"currentUsage"` - HistoricalUsage []resources.Usage `json:"historicalUsage"` +type ClusterOverviewData struct { + TotalPods int `json:"totalPods"` + TotalNodes int `json:"totalNodes"` + CPUCapacity float64 `json:"cpuCapacity"` + MemoryCapacity float64 `json:"memoryCapacity"` + CurrentUsage resources.Usage `json:"currentUsage"` + HistoricalUsage []resources.Usage `json:"historicalUsage"` + PackagesData []map[string]interface{} `json:"packagesData"` + PodsData []map[string]interface{} `json:"podsData"` } +var udsPackagesFieldsList = []string{".metadata.name", ".status.phase", ".status.endpoints"} +var podsFieldsList = []string{".metadata.name", ".metadata.namespace", ".status.phase"} + func BindClusterOverviewHandler(cache *resources.Cache) func(w http.ResponseWriter, r *http.Request) { // Return a function that sends the data to the client return func(w http.ResponseWriter, r *http.Request) { @@ -36,76 +41,10 @@ func BindClusterOverviewHandler(cache *resources.Cache) func(w http.ResponseWrit return } - // Get the current data - getUsage := func(cache *resources.Cache) { - var clusterData ClusterData - - // Timestamp the data - clusterData.CurrentUsage.Timestamp = time.Now() - // Get all available pod metrics - clusterData.TotalPods = len(cache.Pods.Resources) - // Get the current usage - clusterData.CurrentUsage.CPU, clusterData.CurrentUsage.Memory = cache.PodMetrics.GetUsage() - // Get the historical usage - clusterData.HistoricalUsage = cache.PodMetrics.GetHistoricalUsage() - - // Load node data - nodes := cache.Nodes.GetSparseResources("", "") - - // Calculate the total number of nodes - clusterData.TotalNodes = len(nodes) - - // Calculate the total capacity of the cluster - clusterData.CPUCapacity = 0.0 - clusterData.MemoryCapacity = 0.0 - - // Get the capacity of all nodes in the cluster - for _, node := range nodes { - // Get the CPU capacity for the node - cpu, found, err := unstructured.NestedString(node.Object, "status", "capacity", "cpu") - if !found || err != nil { - continue - } - parsed, err := strconv.ParseFloat(cpu, 64) - if err != nil { - continue - } - - // Convert from cores to milli-cores - clusterData.CPUCapacity += (parsed * 1000) - - // Get the memory capacity for the node - mem, found, err := unstructured.NestedString(node.Object, "status", "capacity", "memory") - if !found || err != nil { - continue - } - parsed, err = parseMemory(mem) - if err != nil { - continue - } - - clusterData.MemoryCapacity += parsed - } - - // Flush the headers at the end - defer flusher.Flush() - - // Convert the data to JSON - data, err := json.Marshal(clusterData) - if err != nil { - fmt.Fprintf(w, "data: Error: %v\n\n", err) - return - } - - // Write the data to the response - fmt.Fprintf(w, "data: %s\n\n", data) - } - - ticker := time.NewTicker(5 * time.Second) - defer ticker.Stop() - // Send the initial data - getUsage(cache) + getUsage(cache, w, flusher) + getPackagesData(cache, w, flusher) + getPodsData(cache, w, flusher) for { select { @@ -115,16 +54,140 @@ func BindClusterOverviewHandler(cache *resources.Cache) func(w http.ResponseWrit // If there is a pending update, send the data immediately case <-cache.MetricsChanges: - getUsage(cache) + getUsage(cache, w, flusher) - // Respond to node changes case <-cache.Nodes.Changes: - getUsage(cache) + getUsage(cache, w, flusher) + + case <-cache.UDSPackages.Changes: + getPackagesData(cache, w, flusher) + + case <-cache.Pods.Changes: + getPodsData(cache, w, flusher) } } } } +func initializeClusterData(cache *resources.Cache) ClusterOverviewData { + var clusterData ClusterOverviewData + + // Timestamp the data + clusterData.CurrentUsage.Timestamp = time.Now() + // Get all available pod metrics + clusterData.TotalPods = len(cache.Pods.Resources) + // Get the current usage + clusterData.CurrentUsage.CPU, clusterData.CurrentUsage.Memory = cache.PodMetrics.GetUsage() + // Get the historical usage + clusterData.HistoricalUsage = cache.PodMetrics.GetHistoricalUsage() + + return clusterData +} + +func getUsage(cache *resources.Cache, w http.ResponseWriter, flusher http.Flusher) { + clusterData := initializeClusterData(cache) + + // Load node data + nodes := cache.Nodes.GetSparseResources("", "") + + // Calculate the total number of nodes + clusterData.TotalNodes = len(nodes) + + // Calculate the total capacity of the cluster + clusterData.CPUCapacity = 0.0 + clusterData.MemoryCapacity = 0.0 + + // Get the capacity of all nodes in the cluster + for _, node := range nodes { + // Get the CPU capacity for the node + cpu, found, err := unstructured.NestedString(node.Object, "status", "capacity", "cpu") + if !found || err != nil { + continue + } + parsed, err := strconv.ParseFloat(cpu, 64) + if err != nil { + continue + } + + // Convert from cores to milli-cores + clusterData.CPUCapacity += (parsed * 1000) + + // Get the memory capacity for the node + mem, found, err := unstructured.NestedString(node.Object, "status", "capacity", "memory") + if !found || err != nil { + continue + } + parsed, err = parseMemory(mem) + if err != nil { + continue + } + + clusterData.MemoryCapacity += parsed + } + + defer flusher.Flush() + + // Convert the data to JSON + data, err := json.Marshal(clusterData) + if err != nil { + fmt.Fprintf(w, "event: usageDataUpdate\ndata: Error: %v\n\n", err) + return + } + + // Write the data to the response + fmt.Fprintf(w, "event: usageDataUpdate\ndata: %s\n\n", data) +} + +func getPackagesData(cache *resources.Cache, w http.ResponseWriter, flusher http.Flusher) { + var clusterData ClusterOverviewData + if cache.UDSPackages == nil { + return + } + var data []unstructured.Unstructured = cache.UDSPackages.GetResources("", "") + var fieldsList = udsPackagesFieldsList + + var filteredData = rest.FilterItemsByFields(data, fieldsList) + + clusterData.PackagesData = filteredData + + defer flusher.Flush() + + // Convert the data to JSON + payload, err := json.Marshal(clusterData) + if err != nil { + fmt.Fprintf(w, "event: packagesDataUpdate\ndata: Error: %v\n\n", err) + return + } + + // Write the data to the response + fmt.Fprintf(w, "event: packagesDataUpdate\ndata: %s\n\n", payload) +} + +func getPodsData(cache *resources.Cache, w http.ResponseWriter, flusher http.Flusher) { + var clusterData ClusterOverviewData + if cache.Pods == nil { + return + } + var data []unstructured.Unstructured = cache.Pods.GetResources("", "") + var fieldsList = podsFieldsList + + var filteredData = rest.FilterItemsByFields(data, fieldsList) + + clusterData.PodsData = filteredData + + defer flusher.Flush() + + // Convert the data to JSON + payload, err := json.Marshal(clusterData) + if err != nil { + fmt.Fprintf(w, "event: podsDataUpdate\ndata: Error: %v\n\n", err) + return + } + + // Write the data to the response + fmt.Fprintf(w, "event: podsDataUpdate\ndata: %s\n\n", payload) +} + func parseMemory(memoryString string) (float64, error) { // Remove the 'i' suffix if present memoryString = strings.TrimSuffix(memoryString, "i") diff --git a/src/pkg/api/monitor/cluster_test.go b/src/pkg/api/monitor/cluster_test.go index 11a24a217..8ae2f60a8 100644 --- a/src/pkg/api/monitor/cluster_test.go +++ b/src/pkg/api/monitor/cluster_test.go @@ -58,11 +58,42 @@ func TestBindClusterOverviewHandler(t *testing.T) { Resources: pods, } + // create resource list for UDS Packages + udsPackages := map[string]*unstructured.Unstructured{ + "package1": { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "package1", + }, + "status": map[string]interface{}{ + "phase": "Running", + "endpoints": "1", + }, + }, + }, + "package2": { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "package2", + }, + "status": map[string]interface{}{ + "phase": "Running", + "endpoints": "1", + }, + }, + }, + } + + udsPackagesResourceList := resources.ResourceList{ + Resources: udsPackages, + } + // Create a test cache cache := &resources.Cache{} cache.PodMetrics = podMetrics cache.Pods = &podResourceList cache.Nodes = &nodeResourceList + cache.UDSPackages = &udsPackagesResourceList // Create a request to pass to our handler req, err := http.NewRequest("GET", "/cluster-overview", nil) @@ -92,4 +123,8 @@ func TestBindClusterOverviewHandler(t *testing.T) { // Check if the response body contains expected data expectedSubstring := `"totalPods":2` require.Contains(t, rr.Body.String(), expectedSubstring) + + // Check UDS Package data + expectedSubstring = "{\"name\":\"package1\"}" + require.Contains(t, rr.Body.String(), expectedSubstring) } diff --git a/src/pkg/api/rest/filter.go b/src/pkg/api/rest/filter.go index d12c2f6ce..07144325c 100644 --- a/src/pkg/api/rest/filter.go +++ b/src/pkg/api/rest/filter.go @@ -22,12 +22,12 @@ func jsonMarshal(payload any, fieldsList []string) ([]byte, error) { switch payload := payload.(type) { // Handle single resource case unstructured.Unstructured: - filteredItem := filterItemsByFields([]unstructured.Unstructured{payload}, fieldsList) + filteredItem := FilterItemsByFields([]unstructured.Unstructured{payload}, fieldsList) data, err = json.Marshal(filteredItem[0]) // Handle multiple resources case []unstructured.Unstructured: - filteredItems := filterItemsByFields(payload, fieldsList) + filteredItems := FilterItemsByFields(payload, fieldsList) data, err = json.Marshal(filteredItems) default: @@ -45,10 +45,10 @@ func jsonMarshal(payload any, fieldsList []string) ([]byte, error) { return data, nil } -// filterItemsByFields filters the given items based on the specified field paths +// FilterItemsByFields filters the given items based on the specified field paths // - It takes a slice of unstructured.Unstructured and a slice of field paths. // - It returns a slice of maps, each representing a filtered item. -func filterItemsByFields(items []unstructured.Unstructured, fieldPaths []string) []map[string]interface{} { +func FilterItemsByFields(items []unstructured.Unstructured, fieldPaths []string) []map[string]interface{} { var filtered []map[string]interface{} // Iterate over each item and filter the fields diff --git a/src/pkg/api/rest/filter_test.go b/src/pkg/api/rest/filter_test.go index 43dbdbab8..feaa81a7a 100644 --- a/src/pkg/api/rest/filter_test.go +++ b/src/pkg/api/rest/filter_test.go @@ -251,7 +251,7 @@ func TestFilterItemsByFields(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := filterItemsByFields(tt.items, tt.fieldPaths) + got := FilterItemsByFields(tt.items, tt.fieldPaths) if !reflect.DeepEqual(got, tt.want) { t.Errorf("filterItemsByFields() = %v, want %v", got, tt.want) } diff --git a/src/pkg/api/start.go b/src/pkg/api/start.go index cb1435aa0..0df2336a8 100644 --- a/src/pkg/api/start.go +++ b/src/pkg/api/start.go @@ -36,7 +36,6 @@ var inAirgap bool // @BasePath /api/v1 // @schemes http https func Setup(assets *embed.FS) (*chi.Mux, bool, error) { - // Create a k8s session k8sSession, err := session.CreateK8sSession() if err != nil { return nil, false, fmt.Errorf("failed to setup k8s session: %w", err) @@ -45,7 +44,6 @@ func Setup(assets *embed.FS) (*chi.Mux, bool, error) { inCluster := k8sSession.InCluster if !inCluster { - // Start the cluster monitoring goroutine go k8sSession.StartClusterMonitoring() } @@ -66,10 +64,12 @@ func Setup(assets *embed.FS) (*chi.Mux, bool, error) { r.Get("/cluster-check", checkClusterConnection(k8sSession)) r.Route("/api/v1", func(r chi.Router) { r.Get("/auth", authHandler) + r.Get("/clusters", withLatestSession(k8sSession, getClusters)) + r.Post("/cluster", switchCluster(k8sSession)) r.Route("/monitor", func(r chi.Router) { r.Get("/pepr/", monitor.Pepr) r.Get("/pepr/{stream}", monitor.Pepr) - r.Get("/cluster-overview", monitor.BindClusterOverviewHandler(k8sSession.Cache)) + r.Get("/cluster-overview", withLatestCache(k8sSession, monitor.BindClusterOverviewHandler)) }) r.Route("/resources", func(r chi.Router) { @@ -307,3 +307,9 @@ func withLatestCache(k8sSession *session.K8sSession, handler func(cache *resourc handler(k8sSession.Cache)(w, r) } } + +func withLatestSession(k8sSession *session.K8sSession, handler func(k8sSession *session.K8sSession) func(w http.ResponseWriter, r *http.Request)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + handler(k8sSession)(w, r) + } +} diff --git a/src/pkg/k8s/client/client.go b/src/pkg/k8s/client/client.go index 79857b851..9e54edf11 100644 --- a/src/pkg/k8s/client/client.go +++ b/src/pkg/k8s/client/client.go @@ -4,11 +4,15 @@ package client import ( + "encoding/json" "fmt" + "log/slog" + "net/http" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" + clientCmdAPI "k8s.io/client-go/tools/clientcmd/api" metricsv "k8s.io/metrics/pkg/client/clientset/versioned" ) @@ -19,11 +23,22 @@ type Clients struct { Config *rest.Config } -// NewClient creates new Kubernetes cluster clients -func NewClient() (*Clients, error) { +type ClusterInfo struct { + Name string `json:"name"` + Context string `json:"context"` + Selected bool `json:"selected"` +} + +var ServerCheck = func(k8sClient *Clients) error { + _, err := k8sClient.Clientset.ServerVersion() + return err +} + +// New creates a Kubernetes cluster client with the option of overriding the default config +func New(overrides *clientcmd.ConfigOverrides) (*Clients, error) { config, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( clientcmd.NewDefaultClientConfigLoadingRules(), - &clientcmd.ConfigOverrides{}).ClientConfig() + overrides).ClientConfig() if err != nil { return nil, err @@ -46,24 +61,22 @@ func NewClient() (*Clients, error) { }, nil } -// IsRunningInCluster checks if the application is running in cluster -func IsRunningInCluster() (bool, error) { - _, err := rest.InClusterConfig() +func rawConfig() (clientCmdAPI.Config, error) { + rules := clientcmd.NewDefaultClientConfigLoadingRules() + return clientcmd.NewNonInteractiveDeferredLoadingClientConfig(rules, &clientcmd.ConfigOverrides{}).RawConfig() +} - if err == rest.ErrNotInCluster { - return false, nil - } else if err != nil { - return true, err +func Contexts() (map[string]*clientCmdAPI.Context, error) { + config, err := rawConfig() + if err != nil { + return nil, err } - - return true, nil + return config.Contexts, nil } -// Declare GetCurrentContext as a variable so it can be mocked -var GetCurrentContext = func() (string, string, error) { - // Actual implementation of GetCurrentContext - rules := clientcmd.NewDefaultClientConfigLoadingRules() - config, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(rules, &clientcmd.ConfigOverrides{}).RawConfig() +// CurrentContext returns the current context defined in the kubeconfig +func CurrentContext() (string, string, error) { + config, err := rawConfig() if err != nil { return "", "", err } @@ -74,3 +87,62 @@ var GetCurrentContext = func() (string, string, error) { } return contextName, context.Cluster, nil } + +func ServeClusters(inCluster bool, currentContext string) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + slog.Debug("ServeClusters called", "inCluster", inCluster, "currentContext", currentContext) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache") + + // running in cluster return [] + if inCluster { + data, err := json.Marshal([]interface{}{}) + if err != nil { + slog.Error("Failed to marshal empty data", "error", err) + http.Error(w, "JSON marshalling error", http.StatusInternalServerError) + return + } + + // Write the data to the response + if _, err := w.Write(data); err != nil { + http.Error(w, "Failed to write data", http.StatusInternalServerError) + } + + return + } + + contexts, err := Contexts() + if err != nil { + slog.Error("Failed to get contexts", "error", err) + http.Error(w, "Failed to get contexts", http.StatusInternalServerError) + return + } + + var clusters []ClusterInfo + for ctxName, context := range contexts { + clusters = append(clusters, ClusterInfo{Name: context.Cluster, Context: ctxName, Selected: currentContext == ctxName}) + } + + data, err := json.Marshal(clusters) + if err != nil { + slog.Error("Failed to marshal contexts", "error", err) + http.Error(w, "JSON marshalling error", http.StatusInternalServerError) + return + } + + // Write the data to the response + if _, err := w.Write(data); err != nil { + http.Error(w, "Failed to write data", http.StatusInternalServerError) + } + } +} + +// IsRunningInCluster checks if the application is running in cluster +func IsRunningInCluster() (bool, error) { + _, err := rest.InClusterConfig() + + if err == rest.ErrNotInCluster { + return false, nil + } + return true, err +} diff --git a/src/pkg/k8s/session/session.go b/src/pkg/k8s/session/session.go index fbffa448a..7bf4403d1 100644 --- a/src/pkg/k8s/session/session.go +++ b/src/pkg/k8s/session/session.go @@ -5,8 +5,9 @@ package session import ( "context" + "encoding/json" "fmt" - "log" + "log/slog" "net/http" "os" "strconv" @@ -15,6 +16,7 @@ import ( "github.com/defenseunicorns/uds-runtime/src/pkg/api/resources" "github.com/defenseunicorns/uds-runtime/src/pkg/api/rest" "github.com/defenseunicorns/uds-runtime/src/pkg/k8s/client" + "k8s.io/client-go/tools/clientcmd" ) type K8sSession struct { @@ -30,14 +32,14 @@ type K8sSession struct { createClient createClient } -type createClient func() (*client.Clients, error) +type createClient func(overrides *clientcmd.ConfigOverrides) (*client.Clients, error) type createCache func(ctx context.Context, client *client.Clients) (*resources.Cache, error) var lastStatus string // CreateK8sSession creates a new k8s session func CreateK8sSession() (*K8sSession, error) { - k8sClient, err := client.NewClient() + k8sClient, err := client.New(&clientcmd.ConfigOverrides{}) if err != nil { return nil, fmt.Errorf("failed to create k8s client: %w", err) } @@ -57,7 +59,8 @@ func CreateK8sSession() (*K8sSession, error) { var currentCtx, currentCluster string if !inCluster { // get the current context and cluster - currentCtx, currentCluster, err = client.GetCurrentContext() + currentCtx, currentCluster, err = client.CurrentContext() + slog.Info("Current context", "context", currentCtx, "cluster", currentCluster) if err != nil { cancel() return nil, fmt.Errorf("failed to get current context: %w", err) @@ -74,7 +77,7 @@ func CreateK8sSession() (*K8sSession, error) { Status: make(chan string), ready: true, createCache: resources.NewCache, - createClient: client.NewClient, + createClient: client.New, } return session, nil @@ -98,7 +101,7 @@ func (ks *K8sSession) StartClusterMonitoring() { defer ticker.Stop() // Initial cluster health check - _, err := ks.Clients.Clientset.ServerVersion() + err := client.ServerCheck(ks.Clients) handleConnStatus(ks, err) for range ticker.C { @@ -106,46 +109,47 @@ func (ks *K8sSession) StartClusterMonitoring() { if !ks.ready { continue } - _, err := ks.Clients.Clientset.ServerVersion() + err := client.ServerCheck(ks.Clients) handleConnStatus(ks, err) } } // HandleReconnection infinitely retries to re-create the client and cache of the formerly connected cluster func (ks *K8sSession) HandleReconnection() { - log.Println("Disconnected error received") - + // slog.Info("Disconnected error received, attempting to reconnect to cluster") // Set ready to false to block cluster check ticker ks.ready = false + // Cancel the previous context + ks.Cancel() for { - // Cancel the previous context - ks.Cancel() time.Sleep(getRetryInterval()) - currentCtx, currentCluster, err := client.GetCurrentContext() - if err != nil { - log.Printf("Error fetching current context: %v\n", err) - continue + // Break out of reconnection loop if switch to a new cluster occurs + if lastStatus == "switched" { + slog.Info("Switched to a new cluster, breaking out of reconnection loop") + break } - // If the current context or cluster is different from the original, skip reconnection - if currentCtx != ks.CurrentCtx || currentCluster != ks.CurrentCluster { - log.Println("Current context has changed. Skipping reconnection.") + k8sClient, err := ks.createClient(&clientcmd.ConfigOverrides{CurrentContext: ks.CurrentCtx}) + if err != nil { + slog.Debug("Retrying to create k8s client") continue } - k8sClient, err := ks.createClient() + // Ping server before recreating cache in case client was created but server is still unreachable. + // This avoids getting stuck in the cache.WaithForCacheSync() call that polls indefinitely and does not error out immediately. + err = client.ServerCheck(k8sClient) if err != nil { - log.Printf("Retrying to create k8s client: %v\n", err) + slog.Debug("Failed to ping server on new client instance", "error", err) continue } - // Create a new context and cache ctx, cancel := context.WithCancel(context.Background()) cache, err := ks.createCache(ctx, k8sClient) if err != nil { - log.Printf("Retrying to create cache: %v\n", err) + slog.Debug("Retrying to create cache") + cancel() continue } @@ -157,8 +161,8 @@ func (ks *K8sSession) HandleReconnection() { // immediately send success status to client now that cache is recreated ks.Status <- "success" lastStatus = "success" - log.Println("Successfully reconnected to cluster and recreated cache") + slog.Info("Successfully reconnected to cluster and recreated cache") break } } @@ -212,3 +216,73 @@ func (ks *K8sSession) ServeConnStatus() http.HandlerFunc { } } } + +// Switch returns a handler function that switches the current cluster and updates the K8sSession +func (ks *K8sSession) Switch() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache") + + if ks.InCluster { + http.Error(w, "cannot switch clusters while in-cluster", http.StatusBadRequest) + return + } + + // Set ready to false to block cluster check ticker since switching + ks.ready = false + + defer r.Body.Close() + + var body map[string]client.ClusterInfo + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid request format", http.StatusBadRequest) + return + } + + targetCluster := body["cluster"] + ks.CurrentCtx = targetCluster.Context + ks.CurrentCluster = targetCluster.Name + + slog.Info("Switching cluster", "clusterInfo", targetCluster) + + // Cancel the previous context and informers + ks.Cancel() + + targetClients, err := client.New(&clientcmd.ConfigOverrides{CurrentContext: targetCluster.Context}) + if err != nil { + http.Error(w, fmt.Sprintf("failed to create client: %v", err), http.StatusInternalServerError) + return + } + ks.Clients = targetClients + + // Ping server before recreating cache in case client was created but server is still unreachable. + // This avoids getting stuck in the cache.WaithForCacheSync() call that polls indefinitely and does not error out immediately. + err = client.ServerCheck(targetClients) + if err != nil { + http.Error(w, fmt.Sprintf("failed to connect: %v", err), http.StatusInternalServerError) + return + } + + ctx, cancel := context.WithCancel(context.Background()) + ks.Cancel = cancel + targetCache, err := ks.createCache(ctx, targetClients) + if err != nil { + http.Error(w, fmt.Sprintf("failed to create cache: %v", err), http.StatusInternalServerError) + ks.Cancel() + return + } + ks.Cache = targetCache + // informer the client that the switch was successful + ks.Status <- "switched" + // In case switch occurs during reconnection attempts to previous cluster, we need to break out of the reconnection loop + // this has to be done before updating the K8sSession.CurrentCtx + lastStatus = "switched" + ks.ready = true + + slog.Info("Successfully switched cluster", "context", targetCluster.Context, "cluster", targetCluster.Name) + + if _, err := w.Write([]byte("Cluster switched successfully")); err != nil { + http.Error(w, "Failed to write data", http.StatusInternalServerError) + } + } +} diff --git a/src/pkg/k8s/session/session_test.go b/src/pkg/k8s/session/session_test.go index 5f1c04926..8b82366a6 100644 --- a/src/pkg/k8s/session/session_test.go +++ b/src/pkg/k8s/session/session_test.go @@ -12,20 +12,21 @@ import ( "github.com/defenseunicorns/uds-runtime/src/pkg/api/resources" "github.com/defenseunicorns/uds-runtime/src/pkg/k8s/client" + "github.com/stretchr/testify/require" "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/clientcmd" ) func TestHandleReconnection(t *testing.T) { os.Setenv("CONNECTION_RETRY_MS", "100") defer os.Unsetenv("CONNECTION_RETRY_MS") - // Mock GetCurrentContext to return the same context and cluster as the original - client.GetCurrentContext = func() (string, string, error) { - return "original-context", "original-cluster", nil + client.ServerCheck = func(k8sClient *client.Clients) error { + return nil } - createClientMock := func() (*client.Clients, error) { + createClientMock := func(overrides *clientcmd.ConfigOverrides) (*client.Clients, error) { return &client.Clients{Clientset: &kubernetes.Clientset{}}, nil } @@ -62,12 +63,7 @@ func TestHandleReconnectionCreateClientError(t *testing.T) { os.Setenv("CONNECTION_RETRY_MS", "100") defer os.Unsetenv("CONNECTION_RETRY_MS") - // Mock GetCurrentContext to return the same context and cluster as the original - client.GetCurrentContext = func() (string, string, error) { - return "original-context", "original-cluster", nil - } - - createClientMock := func() (*client.Clients, error) { + createClientMock := func(overrides *clientcmd.ConfigOverrides) (*client.Clients, error) { return nil, fmt.Errorf("failed to create client") } @@ -101,12 +97,11 @@ func TestHandleReconnectionCreateCacheError(t *testing.T) { os.Setenv("CONNECTION_RETRY_MS", "100") defer os.Unsetenv("CONNECTION_RETRY_MS") - // Mock GetCurrentContext to return the same context and cluster as the original - client.GetCurrentContext = func() (string, string, error) { - return "original-context", "original-cluster", nil + client.ServerCheck = func(k8sClient *client.Clients) error { + return nil } - createClientMock := func() (*client.Clients, error) { + createClientMock := func(overrides *clientcmd.ConfigOverrides) (*client.Clients, error) { return &client.Clients{Clientset: &kubernetes.Clientset{}}, nil } @@ -135,16 +130,15 @@ func TestHandleReconnectionCreateCacheError(t *testing.T) { require.Nil(t, k8sSession.Cache.Pods) } -func TestHandleReconnectionContextChanged(t *testing.T) { +func TestHandleReconnectionServerCheckError(t *testing.T) { os.Setenv("CONNECTION_RETRY_MS", "100") defer os.Unsetenv("CONNECTION_RETRY_MS") - // Mock GetCurrentContext to return a different context and cluster - client.GetCurrentContext = func() (string, string, error) { - return "new-context", "new-cluster", nil + client.ServerCheck = func(k8sClient *client.Clients) error { + return fmt.Errorf("failed to check server") } - createClientMock := func() (*client.Clients, error) { + createClientMock := func(overrides *clientcmd.ConfigOverrides) (*client.Clients, error) { return &client.Clients{Clientset: &kubernetes.Clientset{}}, nil } @@ -168,7 +162,7 @@ func TestHandleReconnectionContextChanged(t *testing.T) { // Wait for the reconnection logic to complete time.Sleep(200 * time.Millisecond) - // Verify that the K8sResources struct was not updated since the context/cluster has changed + // Verify that the K8sResources struct was not updated since server check failed require.Nil(t, k8sSession.Clients.Clientset) require.Nil(t, k8sSession.Cache.Pods) } diff --git a/src/pkg/stream/common.go b/src/pkg/stream/common.go index 345e2fec5..90ce25ec3 100644 --- a/src/pkg/stream/common.go +++ b/src/pkg/stream/common.go @@ -16,6 +16,7 @@ import ( corev1 "k8s.io/api/core/v1" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/clientcmd" ) type Reader interface { @@ -52,7 +53,7 @@ func NewStream(writer io.Writer, reader Reader, namespace string) *Stream { func (s *Stream) Start(ctx context.Context) error { // Create a new client if one is not provided (usually for testing) if s.Client == nil { - c, err := client.NewClient() + c, err := client.New(&clientcmd.ConfigOverrides{}) if err != nil { return fmt.Errorf("unable to connect to the cluster: %v", err) } diff --git a/src/test/e2e/api_test.go b/src/test/e2e/api_test.go index 228de84f6..17682b1a4 100644 --- a/src/test/e2e/api_test.go +++ b/src/test/e2e/api_test.go @@ -316,6 +316,43 @@ func TestClusterHealth(t *testing.T) { }) } +func TestGetClusters(t *testing.T) { + r, err := setup() + require.NoError(t, err) + + t.Run("list available clusters", func(t *testing.T) { + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/api/v1/clusters", nil) + r.ServeHTTP(rr, req) + require.Equal(t, http.StatusOK, rr.Code) + require.Contains(t, rr.Body.String(), "k3d-runtime") + }) +} + +func TestSwitchCLuster(t *testing.T) { + r, err := setup() + require.NoError(t, err) + t.Run("switch cluster", func(t *testing.T) { + + // request /cluster-check so that client is listening for ks.status so next request does not block on the channel + rr1 := httptest.NewRecorder() + req1 := httptest.NewRequest("GET", "/cluster-check", nil) + + // Start serving the request + go func() { + r.ServeHTTP(rr1, req1) + }() + + body := strings.NewReader(`{"cluster": {"name": "k3d-runtime-2", "context": "k3d-runtime-2", "selected": false }}`) + rr2 := httptest.NewRecorder() + req2 := httptest.NewRequest("POST", "/api/v1/cluster", body) + + r.ServeHTTP(rr2, req2) + + require.Equal(t, http.StatusOK, rr2.Code) + }) +} + func TestSwagger(t *testing.T) { r, err := setup() require.NoError(t, err) diff --git a/tasks/build.yaml b/tasks/build.yaml index cf6a22265..7483e7c92 100644 --- a/tasks/build.yaml +++ b/tasks/build.yaml @@ -10,6 +10,8 @@ variables: - name: DIR description: "directory of the zarf.yaml" default: . + - name: FLAVOR + default: unicorn tasks: - name: all @@ -79,17 +81,26 @@ tasks: - name: build-zarf-packages description: "build the uds runtime zarf packages (multi-arch)" actions: - - cmd: ./uds zarf p create --set REF=${REF} -a amd64 -o build --confirm + - cmd: ./uds zarf p create --set REF=${REF} -a amd64 --flavor ${FLAVOR} -o build --confirm dir: ${DIR} - - cmd: ./uds zarf p create --set REF=${REF} -a arm64 -o build --confirm + - cmd: | + # dont build arm64 for registry1 since IB images are only amd64 + if [ "${FLAVOR}" != "registry1" ]; then + ./uds zarf p create --set REF=${REF} -a arm64 --flavor ${FLAVOR} -o build --confirm + fi dir: ${DIR} + - name: publish-zarf-packages description: "publish uds runtime zarf packages (multi-arch)" actions: - cmd: ./uds zarf p publish build/zarf-package-uds-runtime-amd64-${REF}.tar.zst oci://ghcr.io/defenseunicorns/packages/uds dir: ${DIR} - - cmd: ./uds zarf p publish build/zarf-package-uds-runtime-arm64-${REF}.tar.zst oci://ghcr.io/defenseunicorns/packages/uds + - cmd: | + # dont publish arm64 for registry1 since IB images are only amd64 + if [ "${FLAVOR}" != "registry1" ]; then + ./uds zarf p publish build/zarf-package-uds-runtime-arm64-${REF}.tar.zst oci://ghcr.io/defenseunicorns/packages/uds + fi dir: ${DIR} - name: smoke-img-pkg-amd diff --git a/tasks/setup.yaml b/tasks/setup.yaml index 1345bb6fd..033b0465c 100644 --- a/tasks/setup.yaml +++ b/tasks/setup.yaml @@ -24,9 +24,13 @@ tasks: - name: k3d description: "start a plain k3d cluster" + inputs: + CLUSTER_NAME: + description: "Name of the k3d cluster" + default: "runtime" actions: - cmd: | - k3d cluster delete runtime && k3d cluster create runtime --k3s-arg "--disable=traefik@server:*" --k3s-arg "--disable=servicelb@server:*" + k3d cluster delete $INPUT_CLUSTER_NAME && k3d cluster create $INPUT_CLUSTER_NAME --k3s-arg "--disable=traefik@server:*" --k3s-arg "--disable=servicelb@server:*" - name: golangci description: "Install golangci-lint to GOPATH using install.sh" diff --git a/tasks/test.yaml b/tasks/test.yaml index 690ec6956..1b85fc1e4 100644 --- a/tasks/test.yaml +++ b/tasks/test.yaml @@ -7,6 +7,15 @@ includes: - core-utils: https://raw.githubusercontent.com/defenseunicorns/uds-core/refs/tags/v0.29.1/tasks/utils.yaml - common-setup: https://raw.githubusercontent.com/defenseunicorns/uds-common/refs/tags/v0.13.1/tasks/setup.yaml +variables: + - name: REF + description: "reference for the runtime image and zarf package" + # x-release-please-start-version + default: 0.8.0 + # x-release-please-end + - name: FLAVOR + default: unicorn + tasks: - name: deploy-runtime-cluster description: deploy cluster specifically for testing UDS Runtime @@ -78,7 +87,7 @@ tasks: - cmd: npm run test:e2e dir: ui - - name: e2e-reconnect + - name: e2e-connections description: "run end-to-end tests for cluster disconnect and reconnect" actions: - task: build:ui @@ -86,7 +95,10 @@ tasks: dir: ui - task: build:api - task: setup:k3d - - cmd: npm run test:reconnect + - task: setup:k3d + with: + CLUSTER_NAME: runtime-2 + - cmd: npm run test:connections dir: ui - name: e2e-in-cluster @@ -127,6 +139,11 @@ tasks: - description: "build ui since embedded in main.go" task: build:ui - task: setup:k3d + with: + CLUSTER_NAME: runtime-2 + description: "create an additional test cluster so there are 2 in the kubeconfig" + - task: setup:k3d + description: "create runtime after runtime-2 so it is default current context when test runs" - task: deploy-load - cmd: npm ci && npm run load:api dir: hack/load-test @@ -141,6 +158,18 @@ tasks: - task: setup:slim-cluster - cmd: uds zarf package deploy build/smoke/zarf-package-uds-runtime-amd64-test.tar.zst --confirm dir: hack/test + - task: smoke-tests + + - name: smoke-flavor + description: "run smoke tests against runtime flavors in the cluster (only runs with amd64 arch due to registry1 limitations)" + actions: + - task: setup:slim-cluster + - cmd: uds zarf package deploy oci://ghcr.io/defenseunicorns/packages/uds/uds-runtime:${REF}-${FLAVOR} --confirm + - task: smoke-tests + + - name: smoke-tests + description: "run smoke tests against nightly runtime" + actions: - description: Validate Runtime Pod wait: cluster: diff --git a/ui/package.json b/ui/package.json index 7a1e19776..b029ed062 100644 --- a/ui/package.json +++ b/ui/package.json @@ -13,11 +13,12 @@ "lint": "prettier --check . && eslint .", "format": "prettier --write .", "test:local-auth": "TEST_CFG=localAuth playwright test", - "test:reconnect": "TEST_CFG=reconnect playwright test", + "test:connections": "TEST_CFG=connections playwright test", "test:e2e": "TEST_CFG=default playwright test", "test:e2e-in-cluster": "TEST_CFG=inCluster playwright test", "test:install": "playwright install", - "test:unit": "vitest run" + "test:unit": "vitest run", + "test:unit-watch": "vitest watch" }, "devDependencies": { "@ianvs/prettier-plugin-sort-imports": "^4.3.1", diff --git a/ui/playwright.config.ts b/ui/playwright.config.ts index 0fbab79b5..d9542530f 100644 --- a/ui/playwright.config.ts +++ b/ui/playwright.config.ts @@ -28,15 +28,15 @@ const defaultConfig: PlaywrightTestConfig = { /* Run tests in files in parallel */ fullyParallel: true, retries: process.env.CI ? 2 : 1, - testMatch: /^(?!.*local-auth|.*reconnect|.*in-cluster)(.+\.)?(test|spec)\.[jt]s$/, + testMatch: /^(?!.*local-auth|.*connections|.*in-cluster)(.+\.)?(test|spec)\.[jt]s$/, use: { baseURL: `${protocol}://${host}:${port}/`, }, } // For testing reconnecting to the cluster -const reconnect: PlaywrightTestConfig = { - name: 'reconnect', +const connections: PlaywrightTestConfig = { + name: 'connections', webServer: { command: '../build/uds-runtime', url: `${protocol}://${host}:${port}`, @@ -47,7 +47,7 @@ const reconnect: PlaywrightTestConfig = { testDir: 'tests', fullyParallel: false, retries: process.env.CI ? 2 : 1, - testMatch: 'reconnect.spec.ts', + testMatch: 'connections.spec.ts', use: { baseURL: `https://runtime-local.uds.dev:${port}/`, }, @@ -75,7 +75,7 @@ const inCluster: PlaywrightTestConfig = { /* Run tests in files in parallel */ fullyParallel: true, retries: process.env.CI ? 2 : 1, - testMatch: /^(?!.*local-auth|.*reconnect)(.+\.)?(test|spec)\.[jt]s$/, + testMatch: /^(?!.*local-auth|.*connections)(.+\.)?(test|spec)\.[jt]s$/, use: { baseURL: `${protocol}://runtime.admin.uds.dev/`, storageState: './tests/state.json', @@ -87,7 +87,7 @@ const configs = { default: defaultConfig, localAuth, inCluster, - reconnect, + connections, } export default defineConfig({ ...configs[TEST_CONFIG] }) diff --git a/ui/src/lib/components/ClusterOverviewWidgets/Applications/component.svelte b/ui/src/lib/components/ClusterOverviewWidgets/Applications/component.svelte index f19a9811a..97eb6b49e 100644 --- a/ui/src/lib/components/ClusterOverviewWidgets/Applications/component.svelte +++ b/ui/src/lib/components/ClusterOverviewWidgets/Applications/component.svelte @@ -12,20 +12,22 @@ $: { transformedPackagesList = [] - udsPackages.forEach((service) => { - let name = service.metadata.name - let endpoint = service.status.endpoints ? service.status.endpoints[0] : '' + if (udsPackages) { + udsPackages.forEach((service) => { + let name = service.metadata.name + let endpoint = service.status && service.status.endpoints ? service.status.endpoints[0] : '' - if (endpoint) { - transformedPackagesList = [ - ...transformedPackagesList, - { - name, - endpoint, - }, - ] - } - }) + if (endpoint) { + transformedPackagesList = [ + ...transformedPackagesList, + { + name, + endpoint, + }, + ] + } + }) + } } function openLink(endpoint: string) { diff --git a/ui/src/lib/components/ClusterOverviewWidgets/CoreServices/component.svelte b/ui/src/lib/components/ClusterOverviewWidgets/CoreServices/component.svelte index 934965f9d..0f6360878 100644 --- a/ui/src/lib/components/ClusterOverviewWidgets/CoreServices/component.svelte +++ b/ui/src/lib/components/ClusterOverviewWidgets/CoreServices/component.svelte @@ -38,20 +38,22 @@ .filter((pod: V1Pod) => pod?.metadata?.name?.match(/^istiod/)) .filter((pod) => pod.status && pod.status?.phase === 'Running').length === 1 - coreServices.forEach((service) => { - let name = coreServicesMapping[service.metadata.name] - let status = service.status ? service.status.phase : 'Pending' + if (coreServices) { + coreServices.forEach((service) => { + let name = coreServicesMapping[service.metadata.name] + let status = service.status ? service.status.phase : 'Pending' - if (Object.keys(coreServicesMapping).includes(service.metadata.name)) { - transformedCoreServiceList = [ - ...transformedCoreServiceList, - { - name, - status, - }, - ] - } - }) + if (Object.keys(coreServicesMapping).includes(service.metadata.name)) { + transformedCoreServiceList = [ + ...transformedCoreServiceList, + { + name, + status, + }, + ] + } + }) + } // If we have pepr uds core pods then we have a Policy Engine & Operator in the cluster if (hasPolicyEngineOperator) { diff --git a/ui/src/lib/features/k8s/cluster-overview/component.svelte b/ui/src/lib/features/k8s/cluster-overview/component.svelte index 431a5140c..60a6a563e 100644 --- a/ui/src/lib/features/k8s/cluster-overview/component.svelte +++ b/ui/src/lib/features/k8s/cluster-overview/component.svelte @@ -36,115 +36,109 @@ Timestamp: new Date().toISOString(), }, historicalUsage: [], + packagesData: [], + podsData: [], } - let cpuPercentage = 0 - let memoryPercentage = 0 - let gbUsed = 0 - let gbCapacity = 0 - let cpuUsed = 0 - let formattedCpuCapacity = 0 - let onMessageCount = 0 - let myChart: Chart - const description = resourceDescriptions['Events'] let udsPackages: ClusterOverviewUDSPackageType[] = [] let pods: V1Pod[] = [] + const description = resourceDescriptions['Events'] + + let myChart: Chart | null = null + let canvasElement: HTMLCanvasElement + let metricsServerAvailable = true let metricsServerNewlyAvailable = false + let previousMetricsServerState = metricsServerAvailable - onMount(() => { - let ctx = document.getElementById('chartjs-el') as HTMLCanvasElement - const overviewPath: string = '/api/v1/monitor/cluster-overview' - const udsPackagesPath: string = - '/api/v1/resources/configs/uds-packages?fields=.metadata.name,.status.phase,.status.endpoints' - const podsPath: string = '/api/v1/resources/workloads/pods?fields=.metadata.name,.metadata.namespace,.status.phase' + $: metricsServerAvailable = !(clusterData.currentUsage.CPU === -1 && clusterData.currentUsage.Memory === -1) + $: { + metricsServerNewlyAvailable = !previousMetricsServerState && metricsServerAvailable + previousMetricsServerState = metricsServerAvailable + } - const overview = new EventSource(overviewPath) - const udsPackagesEvent = new EventSource(udsPackagesPath) - const podsEvent = new EventSource(podsPath) + $: cpuPercentage = calculatePercentage(clusterData.currentUsage.CPU, clusterData.cpuCapacity) + $: memoryPercentage = calculatePercentage(clusterData.currentUsage.Memory, clusterData.memoryCapacity) + $: gbUsed = mebibytesToGigabytes(clusterData.currentUsage.Memory) + $: gbCapacity = mebibytesToGigabytes(clusterData.memoryCapacity) + $: cpuUsed = millicoresToCores(clusterData.currentUsage.CPU) + $: formattedCpuCapacity = millicoresToCores(clusterData.cpuCapacity) + + $: if (canvasElement && !myChart) { + myChart = new Chart(canvasElement, { + type: 'line', + data: getChartData(metricsServerAvailable), + options: getChartOptions(metricsServerAvailable), + }) + } - udsPackagesEvent.onmessage = (event) => { - let response = JSON.parse(event.data) as ClusterOverviewUDSPackageType[] + $: if (myChart && clusterData.historicalUsage) { + if (metricsServerNewlyAvailable) { + myChart.data = getChartData(metricsServerAvailable) + myChart.options = getChartOptions(metricsServerAvailable) + } - // If we have an error i.e. response.error === 'crd not found', set to an empty array - if (Array.isArray(response)) { - udsPackages = response - } else { - udsPackages = [] - } + myChart.data.labels = clusterData.historicalUsage.map((point) => [formatTime(point.Timestamp)]) + + if (metricsServerAvailable) { + myChart.data.datasets[0].data = clusterData.historicalUsage.map((point) => point.Memory / (1024 * 1024 * 1024)) + myChart.data.datasets[1].data = clusterData.historicalUsage.map((point) => point.CPU / 1000) + } else { + myChart.data = getChartData(false) + myChart.options = getChartOptions(false) } - podsEvent.onmessage = (event) => { - pods = JSON.parse(event.data) as V1Pod[] + myChart.update() + } + + function updateClusterData(newData: Partial) { + clusterData = { + ...clusterData, + ...newData, } + } - overview.onmessage = (event) => { - clusterData = JSON.parse(event.data) as ClusterData - - if (clusterData && Object.keys(clusterData).length > 0) { - const { cpuCapacity, currentUsage, historicalUsage, memoryCapacity } = clusterData - let { CPU, Memory } = currentUsage - - if (CPU === -1 && Memory === -1) { - metricsServerAvailable = false - } else { - if (!metricsServerAvailable) { - metricsServerNewlyAvailable = true - } - metricsServerAvailable = true - } - - cpuPercentage = calculatePercentage(CPU, cpuCapacity) - memoryPercentage = calculatePercentage(Memory, memoryCapacity) - gbUsed = mebibytesToGigabytes(Memory) - gbCapacity = mebibytesToGigabytes(memoryCapacity) - cpuUsed = millicoresToCores(CPU) - formattedCpuCapacity = millicoresToCores(cpuCapacity) - - if (onMessageCount === 0) { - myChart = new Chart(ctx, { - type: 'line', - data: getChartData(metricsServerAvailable), - options: getChartOptions(metricsServerAvailable), - }) - } - - // handle case when metrics server becomes available while page is up - if (metricsServerNewlyAvailable) { - metricsServerNewlyAvailable = false - myChart.data = getChartData(metricsServerAvailable) - myChart.options = getChartOptions(metricsServerAvailable) - } - - // on each message manually update the graph - myChart.data.labels = historicalUsage.map((point) => [formatTime(point.Timestamp)]) - - // If metrics server is not available, dont update the graph - if (metricsServerAvailable) { - myChart.data.datasets[0].data = historicalUsage.map((point) => point.Memory / (1024 * 1024 * 1024)) - myChart.data.datasets[1].data = historicalUsage.map((point) => point.CPU / 1000) - } else { - // handle case where metrics server was available and now is not - myChart.data = getChartData(false) - myChart.options = getChartOptions(false) - } - myChart.update() - onMessageCount++ + onMount(() => { + const overviewPath: string = '/api/v1/monitor/cluster-overview' + const overview = new EventSource(overviewPath) + + overview.addEventListener('usageDataUpdate', function (event) { + const newData = JSON.parse(event.data) as ClusterData + if (newData && Object.keys(newData).length > 0) { + updateClusterData({ + totalNodes: newData.totalNodes, + totalPods: newData.totalPods, + cpuCapacity: newData.cpuCapacity, + memoryCapacity: newData.memoryCapacity, + currentUsage: newData.currentUsage, + historicalUsage: newData.historicalUsage, + }) } - } + }) + + overview.addEventListener('packagesDataUpdate', function (event) { + const newData = JSON.parse(event.data) as ClusterData + udsPackages = [...newData.packagesData] + updateClusterData({ packagesData: newData.packagesData }) + }) + + overview.addEventListener('podsDataUpdate', function (event) { + const newData = JSON.parse(event.data) as ClusterData + pods = [...newData.podsData] + updateClusterData({ podsData: newData.podsData }) + }) Chart.register({}) return () => { - onMessageCount = 0 overview.close() - udsPackagesEvent.close() - podsEvent.close() - myChart.destroy() + if (myChart) { + myChart.destroy() + myChart = null + } } }) - // Chart.js settings Chart.defaults.datasets.line.tension = 0.4 @@ -212,7 +206,7 @@
- +
diff --git a/ui/src/lib/features/k8s/types.ts b/ui/src/lib/features/k8s/types.ts index 4a7892d46..c6494a449 100644 --- a/ui/src/lib/features/k8s/types.ts +++ b/ui/src/lib/features/k8s/types.ts @@ -102,4 +102,22 @@ export type ClusterData = { Memory: number Timestamp: string }[] + packagesData: { + metadata: { + name: string + } + status: { + phase: UDSPackageStatus + endpoints: string[] + } + }[] + podsData: { + metadata: { + name: string + namespace: string + } + status: { + phase: string + } + }[] } diff --git a/ui/src/lib/features/navigation/index.ts b/ui/src/lib/features/navigation/index.ts index 4d9cac86a..50d1c59d9 100644 --- a/ui/src/lib/features/navigation/index.ts +++ b/ui/src/lib/features/navigation/index.ts @@ -1,8 +1,10 @@ // Copyright 2024 Defense Unicorns // SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial +export { default as ClusterMenu } from './navbar/clustermenu/component.svelte' +export { default as LoadingCluster } from './navbar/clustermenu/loading.svelte' export { default as Navbar } from './navbar/component.svelte' -export { default as Sidebar } from './sidebar/component.svelte' export { default as UserMenu } from './navbar/usermenu/component.svelte' +export { default as Sidebar } from './sidebar/component.svelte' export { isSidebarExpanded } from './store' diff --git a/ui/src/lib/features/navigation/navbar/clustermenu/component.svelte b/ui/src/lib/features/navigation/navbar/clustermenu/component.svelte new file mode 100644 index 000000000..71f64b619 --- /dev/null +++ b/ui/src/lib/features/navigation/navbar/clustermenu/component.svelte @@ -0,0 +1,105 @@ + + + + + +
+ + +
diff --git a/ui/src/lib/features/navigation/navbar/clustermenu/component.test.ts b/ui/src/lib/features/navigation/navbar/clustermenu/component.test.ts new file mode 100644 index 000000000..f2eebee6b --- /dev/null +++ b/ui/src/lib/features/navigation/navbar/clustermenu/component.test.ts @@ -0,0 +1,162 @@ +import { fireEvent, render, waitFor } from '@testing-library/svelte' +import { vi } from 'vitest' + +import '@testing-library/jest-dom' + +import { goto } from '$app/navigation' + +import ClustersComponent from './component.svelte' +import { clusters, loadingCluster, type ClusterInfo } from './store' + +vi.mock('./store', async (importActual) => { + const actual: Record = await importActual() + return { + ...actual, + loadingCluster: { + set: vi.fn(), + subscribe: vi.fn(), + update: vi.fn(), + }, + } +}) + +vi.mock('$app/navigation', () => ({ + goto: vi.fn(), +})) + +const mockClusters: ClusterInfo[] = [ + { name: 'Cluster 1', context: 'ctx-1', selected: true }, + { name: 'Cluster 2', context: 'ctx-2', selected: false }, +] + +describe('ClustersComponent', () => { + const fetchMock = vi.fn() + global.fetch = fetchMock + + clusters.set(mockClusters) + + const cluster1 = `${mockClusters[0].context}.${mockClusters[0].name}` + const cluster2 = `${mockClusters[1].context}.${mockClusters[1].name}` + + beforeEach(() => { + vi.resetAllMocks() + vi.useFakeTimers() + + fetchMock.mockResolvedValueOnce({ + json: async () => mockClusters, + ok: true, + }) + }) + + afterAll(() => { + vi.restoreAllMocks() + vi.useRealTimers() + }) + + test('renders the selected cluster', async () => { + const { getByText, getByRole } = render(ClustersComponent) + expect(getByText(cluster1)).toBeInTheDocument() + + const dropdown = getByRole('list') + expect(dropdown.classList.contains('hidden')).toBe(true) + }) + + test('renders the dropdown with all clusters', async () => { + const { getByRole } = render(ClustersComponent) + + const button = getByRole('button', { name: cluster1 }) + await fireEvent.click(button) + + expect(fetchMock).toHaveBeenCalledWith('/api/v1/clusters', { + method: 'GET', + }) + }) + + test('changes the selected cluster on click', async () => { + const { getByText, getByRole } = render(ClustersComponent) + + const button = getByRole('button', { name: cluster1 }) + await fireEvent.click(button) + + fetchMock.mockResolvedValueOnce({ ok: true }).mockResolvedValueOnce({ + json: async () => mockClusters, + ok: true, + }) + + await fireEvent.click(getByText(cluster2)) + vi.advanceTimersByTime(3000) + + expect(loadingCluster.set).toHaveBeenCalledTimes(2) + + expect(loadingCluster.set).toHaveBeenNthCalledWith(1, { loading: true, cluster: mockClusters[1] }) + expect(loadingCluster.set).toHaveBeenCalledWith({ loading: false, cluster: mockClusters[1] }) + + // We made POST request to change the cluster + expect(fetchMock).toHaveBeenNthCalledWith(2, '/api/v1/cluster', { + method: 'POST', + body: JSON.stringify({ cluster: mockClusters[1] }), + }) + + // Then got updated cluster list since selected has changed + expect(fetchMock).toHaveBeenLastCalledWith('/api/v1/clusters', { + method: 'GET', + }) + + await waitFor(() => expect(goto).toHaveBeenCalledWith('/')) + }) + + test('timeout is reached and fetch response has not returned', async () => { + const { getByText, getByRole } = render(ClustersComponent) + + const button = getByRole('button', { name: cluster1 }) + await fireEvent.click(button) + + vi.clearAllMocks() + + fetchMock + .mockImplementationOnce(() => { + return new Promise((resolve) => { + setTimeout(() => { + resolve({ ok: true }) + }, 5000) + }) + }) + .mockImplementationOnce(() => { + return Promise.resolve({ + json: async () => mockClusters, + ok: true, + }) + }) + + await fireEvent.click(getByText(cluster2)) + expect(loadingCluster.set).toHaveBeenNthCalledWith(1, { loading: true, cluster: mockClusters[1] }) + + vi.advanceTimersByTime(5000) + vi.runOnlyPendingTimersAsync() + await waitFor(() => { + expect(fetchMock).toHaveBeenLastCalledWith('/api/v1/clusters', { + method: 'GET', + }) + expect(loadingCluster.set).toHaveBeenCalledWith({ loading: false, cluster: mockClusters[1] }) + expect(goto).toHaveBeenCalledWith('/') + }) + }) + + test('handles fetch error', async () => { + const { getByText, getByRole } = render(ClustersComponent) + vi.useRealTimers() + const button = getByRole('button', { name: cluster1 }) + await fireEvent.click(button) + + fetchMock + .mockResolvedValueOnce({ ok: false, text: () => Promise.resolve('Error') }) + .mockResolvedValueOnce({ ok: true, json: async () => mockClusters }) + + await fireEvent.click(getByText(cluster2)) + await waitFor(() => { + expect(loadingCluster.set).toHaveBeenCalledTimes(1) + expect(goto).not.toHaveBeenCalled() + expect(loadingCluster.update).toHaveBeenCalled() + }) + }) +}) diff --git a/ui/src/lib/features/navigation/navbar/clustermenu/loading.svelte b/ui/src/lib/features/navigation/navbar/clustermenu/loading.svelte new file mode 100644 index 000000000..a3d8385f1 --- /dev/null +++ b/ui/src/lib/features/navigation/navbar/clustermenu/loading.svelte @@ -0,0 +1,41 @@ + + +
+ {#if error} +
+

{cluster.context}.{cluster.name}

+ +

{error}

+
+ {:else} +
+

Connecting to cluster

+
+ +
+

{cluster.context}.{cluster.name}

+
+ {/if} +
diff --git a/ui/src/lib/features/navigation/navbar/clustermenu/store.ts b/ui/src/lib/features/navigation/navbar/clustermenu/store.ts new file mode 100644 index 000000000..35ffb914f --- /dev/null +++ b/ui/src/lib/features/navigation/navbar/clustermenu/store.ts @@ -0,0 +1,20 @@ +import { writable, type Writable } from 'svelte/store' + +export type ClusterInfo = { + name: string + context: string + selected: boolean +} + +type Loading = { + loading: boolean + cluster: ClusterInfo + err?: string +} + +export const clusters: Writable = writable([]) + +export const loadingCluster: Writable = writable({ + loading: false, + cluster: { name: '', context: '', selected: false }, +}) diff --git a/ui/src/lib/features/navigation/navbar/component.svelte b/ui/src/lib/features/navigation/navbar/component.svelte index 446c35deb..7e3978ea4 100644 --- a/ui/src/lib/features/navigation/navbar/component.svelte +++ b/ui/src/lib/features/navigation/navbar/component.svelte @@ -2,12 +2,16 @@