Commit b15442b1 authored by Muhammad Umair Khan's avatar Muhammad Umair Khan
Browse files

Resolve Issue #36: Implement App Catalog and merge dynamic and static applications in DAI

This commit introduces a new experimental Dynamic Application Catalog feature in the frontend (Admin Console) and backend (virt-engine), allowing users to onboard new .tgz applications dynamically. It ensures full compliance with the MEC 016 GS specification by allowing the Device Application Interface (DAI) to track both dynamically onboarded apps and statically deployed scenario applications.

Key features and changes:
- Added a frontend Application Catalog page to the Admin Console for uploading .tgz application packages.
- Implemented an onboard_app handler in meep-virt-engine to persist onboarded application metadata in a dedicated Persistent Volume (/data/app-catalog/app_catalog.json).
- Updated meepctl to properly inject NGINX authentication annotations for the virt-engine endpoints, securing the onboard API.
- Re-architected meep-dai to retrieve dynamic applications from virt-engine upon initialization using an internal FQDN to resolve cross-namespace DNS requests.
- Merged active scenario processes (NodeTypeEdgeApp) with dynamically onboarded applications directly in the DAI Service Boundary Interface (SBI) to ensure the GET /app_list endpoint returns a comprehensive list of all applications running in the MEC Sandbox.
- Resolved a runtime panic in meep-dai caused by missing AppLocation objects by gracefully handling nil pointers during ApplicationList construction.
- Corrected Kubernetes Ingress templates and values for meep-virt-engine to ensure proper routing and prevent 404 Not Found errors.
parent a79cc4fb
Loading
Loading
Loading
Loading
+43 −0
Original line number Diff line number Diff line
{{- if .Values.ingress.enabled -}}
{{- $serviceName := include "meep-virt-engine.fullname" . -}}
{{- $servicePort := .Values.service.port -}}
{{- $path := .Values.ingress.path -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: {{ template "meep-virt-engine.fullname" . }}
  labels:
    app: {{ template "meep-virt-engine.name" . }}
    chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
    release: {{ .Release.Name }}
    heritage: {{ .Release.Service }}
{{- if .Values.ingress.labels }}
{{ toYaml .Values.ingress.labels | indent 4 }}
{{- end }}
  annotations:
    {{- range $key, $value := .Values.ingress.annotations }}
      {{ $key }}: {{ $value | quote }}
    {{- end }}
spec:
  rules:
    {{- range .Values.ingress.hosts }}
    - http:
        paths:
          {{- range $path := .paths }}
          - path: {{ $path }}
            pathType: ImplementationSpecific
            backend:
              service:
                name: {{ $serviceName }}
                port:
                  number: {{ $servicePort }}
          {{- end -}}
      {{- if .name }}
      host: {{ .name }}
      {{- end }}
    {{- end -}}
  {{- if .Values.ingress.tls }}
  tls:
{{ toYaml .Values.ingress.tls | indent 4 }}
  {{- end -}}
{{- end -}}
+12 −0
Original line number Diff line number Diff line
@@ -69,3 +69,15 @@ codecov:
  location: "<WORKDIR>/codecov/meep-virt-engine"

meepOrigin: core

ingress:
  enabled: true
  hosts:
    - name: ''
      paths:
        - /virt-engine
  annotations:
    kubernetes.io/ingress.class: nginx
    # nginx.ingress.kubernetes.io/auth-url: <-- set by 'meepctl deploy' when auth enabled
  labels: {}
  tls:
+30 −0
Original line number Diff line number Diff line
{
  "appList": [
    {
      "appDId": "dummy-chart",
      "appName": "test",
      "appProvider": "test",
      "appSoftVersion": "1.0.0",
      "appDVersion": "1.0.0",
      "appDescription": "A mock application for DAI testing",
      "appLocation": [
        {
          "countryCode": "zone1-edge1"
        }
      ]
    },
    {
      "appDId": "meep-demo-app",
      "appName": "demo-app",
      "appProvider": "InterDigital",
      "appSoftVersion": "1.0.0",
      "appDVersion": "1.0.0",
      "appDescription": "Demo Application",
      "appLocation": [
        {
          "countryCode": "zone1-edge1"
        }
      ]
    }
  ]
}
 No newline at end of file
+214 −8
Original line number Diff line number Diff line
@@ -24,19 +24,22 @@
package sbi

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"net/http"
	"strconv"
	"sync"

	//"time"

	//dataModel "github.com/InterDigitalInc/AdvantEDGE/go-packages/meep-data-model"
	tm "github.com/InterDigitalInc/AdvantEDGE/go-packages/meep-dai-mgr"
	dataModel "github.com/InterDigitalInc/AdvantEDGE/go-packages/meep-data-model"
	gc "github.com/InterDigitalInc/AdvantEDGE/go-packages/meep-gis-cache"
	log "github.com/InterDigitalInc/AdvantEDGE/go-packages/meep-logger"
	met "github.com/InterDigitalInc/AdvantEDGE/go-packages/meep-metrics"
	mod "github.com/InterDigitalInc/AdvantEDGE/go-packages/meep-model"
	mq "github.com/InterDigitalInc/AdvantEDGE/go-packages/meep-mq"
	scc "github.com/InterDigitalInc/AdvantEDGE/go-packages/meep-sandbox-ctrl-client"
	sam "github.com/InterDigitalInc/AdvantEDGE/go-packages/meep-swagger-api-mgr"
)

@@ -84,6 +87,8 @@ type DaiSbi struct {
	updateScenarioNameCB func(string)
	cleanUpCB            func()
	mutex                sync.Mutex
	appIpCache           map[string]string // Cache to track IP changes for webhooks
	sbxCtrlClient        *scc.APIClient
}

var sbi *DaiSbi = nil
@@ -100,6 +105,7 @@ func Init(cfg SbiCfg) (err error) {
	sbi.hostUrl = cfg.HostUrl
	sbi.mepName = cfg.LocationName
	sbi.scenarioName = ""
	sbi.appIpCache = make(map[string]string)
	sbi.updateAppInfoCB = cfg.AppInfoList
	sbi.updateScenarioNameCB = cfg.ScenarioNameCb
	sbi.cleanUpCB = cfg.CleanUpCb
@@ -135,6 +141,15 @@ func Init(cfg SbiCfg) (err error) {
	}
	log.Info("Swagger API Manager created")

	// Create Sandbox Controller client
	sbxCtrlClientCfg := scc.NewConfiguration()
	sbxCtrlClientCfg.BasePath = "http://meep-sandbox-ctrl/sandbox-ctrl/v1"
	sbi.sbxCtrlClient = scc.NewAPIClient(sbxCtrlClientCfg)
	if sbi.sbxCtrlClient == nil {
		return errors.New("Failed to create Sandbox Controller REST API client")
	}
	log.Info("Sandbox Controller REST API client created")

	// Create new active scenario model
	modelCfg := mod.ModelCfg{
		Name:      "activeScenario",
@@ -285,6 +300,48 @@ func processActiveScenarioUpdate() {

	scenarioName := sbi.activeModel.GetScenarioName()

	// Track IP changes for deployed apps
	if scenarioName != "" {
		processes := sbi.activeModel.GetProcesses(nil)
		for _, proc := range processes.Processes {
			if proc.Type_ == mod.NodeTypeEdgeApp {
				// Construct deterministic DNS name as IP reference for the pod
				ip := "https://" + proc.Name + "." + scenarioName + ".svc.cluster.local"

				// Check for IP changes to fire webhooks
				if oldIp, ok := sbi.appIpCache[proc.Name]; !ok || oldIp != ip {
					if appContext, err := sbi.daiMgr.GetAppContextRecord(proc.Name); err == nil && appContext.CallbackReference != nil && *appContext.CallbackReference != "" {
						log.Info("IP assigned/changed for ", proc.Name, " to ", ip, ". Firing webhook to ", *appContext.CallbackReference)

						// Update the reference URI in the app context
						if len(appContext.AppInfo.UserAppInstanceInfo) > 0 {
							appContext.AppInfo.UserAppInstanceInfo[0].ReferenceURI = tm.Uri(ip)
							_ = sbi.daiMgr.PutAppContext(*appContext)
						}

						// Fire Webhook Notification asynchronously
						go func(cb string, ctx tm.AppContext) {
							jsonBody, _ := json.Marshal(ctx)
							req, err := http.NewRequest("POST", cb, bytes.NewBuffer(jsonBody))
							if err == nil {
								req.Header.Set("Content-Type", "application/json")
								client := &http.Client{}
								resp, err := client.Do(req)
								if err != nil {
									log.Error("Failed to send webhook to ", cb, ": ", err.Error())
								} else {
									log.Info("Webhook sent to ", cb, " - Status: ", resp.StatusCode)
									_ = resp.Body.Close()
								}
							}
						}(string(*appContext.CallbackReference), *appContext)
					}
				}
				sbi.appIpCache[proc.Name] = ip
			}
		}
	}

	// Connect to Metric Store
	sbi.updateScenarioNameCB(scenarioName)

@@ -507,15 +564,53 @@ func filterExcludeServiceConts(serviceConts []string, filteredAppListSbi *map[st

func GetAllListAppList() (appListSbi *map[string]*tm.AppInfo, err error) {

	// Get list of application
	// 1. Get dynamic applications from App Catalog
	appInfoList, err := sbi.daiMgr.GetAllAppInfoRecord()
	if err != nil {
		log.Error(err.Error())
		return nil, err
		appInfoList = make(map[string]*tm.AppInfo)
	}
	appListSbi = &appInfoList

	//sbi.updateAppInfoCB(appListSbi)
	// 2. Get static applications from active scenario
	scenarioName := sbi.activeModel.GetScenarioName()
	if scenarioName != "" {
		processes := sbi.activeModel.GetProcesses(nil)
		for _, proc := range processes.Processes {
			if proc.Type_ == mod.NodeTypeEdgeApp {
				// Avoid overwriting dynamic apps if ID matches
				if _, exists := appInfoList[proc.Id]; !exists {
					appInfo := new(tm.AppInfo)
					appInfo.AppDId = proc.Id
					appInfo.AppName = proc.Name

					// Default values for static scenario applications
					appInfo.AppProvider = "AdvantEDGE"
					if provider, ok := proc.Meta["provider"]; ok && provider != "" {
						appInfo.AppProvider = provider
					}

					appInfo.AppSoftVersion = "1.0.0"
					if version, ok := proc.Meta["version"]; ok && version != "" {
						appInfo.AppSoftVersion = version
					}

					appInfo.AppDVersion = "1.0.0"
					if version, ok := proc.Meta["version"]; ok && version != "" {
						appInfo.AppDVersion = version
					}

					appInfo.AppDescription = "Static scenario application"
					if description, ok := proc.Meta["description"]; ok && description != "" {
						appInfo.AppDescription = description
					}

					appInfoList[proc.Id] = appInfo
				}
			}
		}
	}

	appListSbi = &appInfoList
	return appListSbi, nil
}

@@ -527,6 +622,66 @@ func CreateAppContext(appContextSbi *tm.AppContext) (appContextSbi_ *tm.AppConte
		return nil, err
	}

	// Deploy via virt-engine by adding the application to the active scenario
	plList := sbi.activeModel.GetPhysicalLocations(nil)
	var parentPl string
	for _, pl := range plList.PhysicalLocations {
		if pl.Type_ == mod.NodeTypeEdge {
			parentPl = pl.Name
			break
		}
	}

	if parentPl != "" {
		proc := new(dataModel.Process)
		proc.Id = appContextSbi_.ContextId
		proc.Name = appContextSbi_.ContextId
		proc.Type_ = mod.NodeTypeEdgeApp
		proc.IsExternal = false
		proc.UserChartLocation = appContextSbi.AppInfo.AppDId
		proc.UserChartGroup = ""
		proc.NetChar = &dataModel.NetworkCharacteristics{}

		var node dataModel.ScenarioNode
		node.Type_ = mod.NodeTypeEdgeApp
		node.Parent = parentPl

		// Convert Process to SCC Process using JSON
		var sccProcess scc.Process
		procBytes, _ := json.Marshal(proc)
		_ = json.Unmarshal(procBytes, &sccProcess)

		nodeData := new(scc.NodeDataUnion)
		nodeData.Process = &sccProcess
		sccNode := scc.ScenarioNode{
			Type_:         mod.NodeTypeEdgeApp,
			Parent:        parentPl,
			NodeDataUnion: nodeData,
		}

		event := scc.Event{
			Type_: "SCENARIO-UPDATE",
			EventScenarioUpdate: &scc.EventScenarioUpdate{
				Action: "ADD",
				Node:   &sccNode,
			},
		}

		_, err = sbi.sbxCtrlClient.EventsApi.SendEvent(context.TODO(), event.Type_, event)
		if err != nil {
			log.Error("Failed to add scenario node for application: ", err)
			_ = sbi.daiMgr.DeleteAppContext(appContextSbi_.ContextId)
			return nil, err
		}

		log.Info("Successfully triggered asynchronous application instantiation via meep-virt-engine for context: ", appContextSbi_.ContextId)
		// Asynchronous workflow: the 201 Created is returned immediately.
		// The IP and ReferenceURI will be populated when processActiveScenarioUpdate detects the pod is running,
		// at which point a Webhook Callback will be sent to the device.
	} else {
		log.Warn("No physical locations available to deploy application")
	}

	return appContextSbi_, nil
}

@@ -538,6 +693,21 @@ func DeleteAppContext(contextId string) (err error) {
		return err
	}

	// Terminate via virt-engine by removing the application from the active scenario
	var node dataModel.ScenarioNode
	node.Type_ = mod.NodeTypeEdgeApp
	node.NodeDataUnion = &dataModel.NodeDataUnion{
		Process: &dataModel.Process{
			Name: contextId,
		},
	}
	err = sbi.activeModel.RemoveScenarioNode(&node, nil)
	if err != nil {
		log.Error("Failed to remove scenario node for application: ", err)
	} else {
		log.Info("Successfully triggered application termination via MEO for context: ", contextId)
	}

	return nil
}

@@ -549,9 +719,45 @@ func PutAppContext(appContextSbi tm.AppContext) (err error) {
		return err
	}

	// Trigger orchestrated migration if appLocation is updated
	if len(appContextSbi.AppInfo.UserAppInstanceInfo) > 0 {
		appLocation := appContextSbi.AppInfo.UserAppInstanceInfo[0].AppLocation
		if appLocation != nil && appLocation.CountryCode != nil {
			// In a real system, we'd map Civic/Area to a physical node. Here we use CountryCode directly as the node name for testing
			targetPl := *appLocation.CountryCode

			node := sbi.activeModel.GetNode(appContextSbi.ContextId)
			if node != nil {
				if proc, ok := node.(*dataModel.Process); ok {
					var scenarioNode dataModel.ScenarioNode
					scenarioNode.Type_ = mod.NodeTypeEdgeApp
					scenarioNode.Parent = targetPl
					scenarioNode.NodeDataUnion = &dataModel.NodeDataUnion{
						Process: proc,
					}

					log.Info("Triggering application migration to: ", targetPl)
					err = sbi.activeModel.ModifyScenarioNode(&scenarioNode, nil)
					if err != nil {
						log.Error("Failed to migrate application: ", err)
					}
				}
			}
		}
	}

	return nil
}

func GetAppContext(contextId string) (appContextSbi *tm.AppContext, err error) {
	appContextSbi, err = sbi.daiMgr.GetAppContextRecord(contextId)
	if err != nil {
		log.Error(err.Error())
		return nil, err
	}
	return appContextSbi, nil
}

func PosApplicationLocationAvailability(applicationLocationAvailabilitySbi *tm.ApplicationLocationAvailability) (applicationLocationAvailability_ *tm.ApplicationLocationAvailability, err error) {

	// Retrieve the AppInfo data for the specified application
+0 −32
Original line number Diff line number Diff line
/*
 * Copyright (c) 2024  The AdvantEDGE Authors
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *    http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 *
 * AdvantEDGE Device application interface
 *
 * Device application interface is AdvantEDGE's implementation of [ETSI GS MEC016 Device application interface](https://www.etsi.org/deliver/etsi_gs/MEC/001_099/016/03.01.01_60/gs_mec016v030101p.pdf) <p>[Copyright (c) ETSI 2017](https://forge.etsi.org/etsi-forge-copyright-notice.txt) <p>**Micro-service**<br>[meep-dai](https://github.com/InterDigitalInc/AdvantEDGE/tree/master/go-apps/meep-dai) <p>**Type & Usage**<br>Edge Service used by edge applications that want to get information about radio conditions in the network <p>**Note**<br>AdvantEDGE supports a selected subset of DAI API endpoints (see below) and a subset of subscription types.
 *
 * API version: 2.2.1
 * Contact: AdvantEDGE@InterDigital.com
 * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git)
 */
package server

import (
	"net/http"
)

func IndividualSubscriptionDELETE(w http.ResponseWriter, r *http.Request) {
	notImplemented(w, r)
}
Loading