diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1bfe9bc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +.vscode/ +apos-build/ +badges/ +data/ +node-modules/ +public/uploads/ +.dockerignore +.env +.eslintignore +.gitignore +deploy-test-count +docker-compose.yaml +dockerfile +force-deploy +local.example.js \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e0e9b8b --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +.nox/ diff --git a/.yamllint b/.yamllint new file mode 100644 index 0000000..2f00cb6 --- /dev/null +++ b/.yamllint @@ -0,0 +1,4 @@ +extends: default +rules: + document-start: disable + line-length: disable diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6eea4e9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +# Use an official Node runtime as a parent image (Alpine for smaller footprint) +FROM node:lts-alpine3.15 + +WORKDIR /srv/www/apostrophe + +RUN chown -R node: /srv/www/apostrophe +USER node + +COPY --chown=node package*.json /srv/www/apostrophe/ + +ENV NODE_ENV=production +RUN npm ci + +COPY --chown=node . /srv/www/apostrophe/ + +RUN ./scripts/build-assets.sh + +EXPOSE 3000 + +ENV APOS_MONGODB_URI="" +ENV ACTIVEMQ_HOST="" +ENV ACTIVEMQ_PORT="" +ENV ACTIVEMQ_USERNAME="" +ENV ACTIVEMQ_PASSWORD="" + +# Command to run the app +CMD [ "node", "app.js" ] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d0a1fa1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at https://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/app.js b/app.js new file mode 100644 index 0000000..56f1fc0 --- /dev/null +++ b/app.js @@ -0,0 +1,14 @@ +require('apostrophe')({ + shortName: 'NebulOuS', + modules: { + "application": {}, + "mathparser": {}, + "kubevela": {}, + "swagger": {}, + "userapi": {}, + "resources": {}, + "platforms": {}, + "policies": {} + } +}); + diff --git a/charts/nebulous-gui-controller/.helmignore b/charts/nebulous-gui-controller/.helmignore new file mode 100644 index 0000000..0e8a0eb --- /dev/null +++ b/charts/nebulous-gui-controller/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/charts/nebulous-gui-controller/Chart.yaml b/charts/nebulous-gui-controller/Chart.yaml new file mode 100644 index 0000000..5510512 --- /dev/null +++ b/charts/nebulous-gui-controller/Chart.yaml @@ -0,0 +1,24 @@ +apiVersion: v2 +name: nebulous-gui-controller +description: A Helm chart for Kubernetes + +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful utilities or functions for the chart developer. They're included as +# a dependency of application charts to inject those utilities and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +# Versions are expected to follow Semantic Versioning (https://semver.org/) +version: 0.1.0 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application. Versions are not expected to +# follow Semantic Versioning. They should reflect the version the application is using. +# It is recommended to use it with quotes. +appVersion: "latest" diff --git a/charts/nebulous-gui-controller/templates/NOTES.txt b/charts/nebulous-gui-controller/templates/NOTES.txt new file mode 100644 index 0000000..cd92629 --- /dev/null +++ b/charts/nebulous-gui-controller/templates/NOTES.txt @@ -0,0 +1,22 @@ +1. Get the application URL by running these commands: +{{- if .Values.ingress.enabled }} +{{- range $host := .Values.ingress.hosts }} + {{- range .paths }} + http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }} + {{- end }} +{{- end }} +{{- else if contains "NodePort" .Values.service.type }} + export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "nebulous-gui-controller.fullname" . }}) + export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") + echo http://$NODE_IP:$NODE_PORT +{{- else if contains "LoadBalancer" .Values.service.type }} + NOTE: It may take a few minutes for the LoadBalancer IP to be available. + You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "nebulous-gui-controller.fullname" . }}' + export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "nebulous-gui-controller.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") + echo http://$SERVICE_IP:{{ .Values.service.port }} +{{- else if contains "ClusterIP" .Values.service.type }} + export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "nebulous-gui-controller.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") + export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}") + echo "Visit http://127.0.0.1:8080 to use your application" + kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT +{{- end }} diff --git a/charts/nebulous-gui-controller/templates/_helpers.tpl b/charts/nebulous-gui-controller/templates/_helpers.tpl new file mode 100644 index 0000000..eda9029 --- /dev/null +++ b/charts/nebulous-gui-controller/templates/_helpers.tpl @@ -0,0 +1,62 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "nebulous-gui-controller.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "nebulous-gui-controller.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "nebulous-gui-controller.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "nebulous-gui-controller.labels" -}} +helm.sh/chart: {{ include "nebulous-gui-controller.chart" . }} +{{ include "nebulous-gui-controller.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "nebulous-gui-controller.selectorLabels" -}} +app.kubernetes.io/name: {{ include "nebulous-gui-controller.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "nebulous-gui-controller.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "nebulous-gui-controller.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/charts/nebulous-gui-controller/templates/deployment.yaml b/charts/nebulous-gui-controller/templates/deployment.yaml new file mode 100644 index 0000000..01cd76f --- /dev/null +++ b/charts/nebulous-gui-controller/templates/deployment.yaml @@ -0,0 +1,61 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "nebulous-gui-controller.fullname" . }} + labels: + {{- include "nebulous-gui-controller.labels" . | nindent 4 }} +spec: + {{- if not .Values.autoscaling.enabled }} + replicas: {{ .Values.replicaCount }} + {{- end }} + selector: + matchLabels: + {{- include "nebulous-gui-controller.selectorLabels" . | nindent 6 }} + template: + metadata: + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "nebulous-gui-controller.selectorLabels" . | nindent 8 }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "nebulous-gui-controller.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: {{ .Chart.Name }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: 8080 + protocol: TCP + livenessProbe: + httpGet: + path: / + port: http + readinessProbe: + httpGet: + path: / + port: http + resources: + {{- toYaml .Values.resources | nindent 12 }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/charts/nebulous-gui-controller/templates/hpa.yaml b/charts/nebulous-gui-controller/templates/hpa.yaml new file mode 100644 index 0000000..ab4ea5d --- /dev/null +++ b/charts/nebulous-gui-controller/templates/hpa.yaml @@ -0,0 +1,28 @@ +{{- if .Values.autoscaling.enabled }} +apiVersion: autoscaling/v2beta1 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "nebulous-gui-controller.fullname" . }} + labels: + {{- include "nebulous-gui-controller.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "nebulous-gui-controller.fullname" . }} + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + metrics: + {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + targetAverageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} + {{- end }} + {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + targetAverageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} + {{- end }} +{{- end }} diff --git a/charts/nebulous-gui-controller/templates/ingress.yaml b/charts/nebulous-gui-controller/templates/ingress.yaml new file mode 100644 index 0000000..e2865ab --- /dev/null +++ b/charts/nebulous-gui-controller/templates/ingress.yaml @@ -0,0 +1,61 @@ +{{- if .Values.ingress.enabled -}} +{{- $fullName := include "nebulous-gui-controller.fullname" . -}} +{{- $svcPort := .Values.service.port -}} +{{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }} + {{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }} + {{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}} + {{- end }} +{{- end }} +{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1 +{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1beta1 +{{- else -}} +apiVersion: extensions/v1beta1 +{{- end }} +kind: Ingress +metadata: + name: {{ $fullName }} + labels: + {{- include "nebulous-gui-controller.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }} + ingressClassName: {{ .Values.ingress.className }} + {{- end }} + {{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + {{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }} + pathType: {{ .pathType }} + {{- end }} + backend: + {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} + service: + name: {{ $fullName }} + port: + number: {{ $svcPort }} + {{- else }} + serviceName: {{ $fullName }} + servicePort: {{ $svcPort }} + {{- end }} + {{- end }} + {{- end }} +{{- end }} diff --git a/charts/nebulous-gui-controller/templates/service.yaml b/charts/nebulous-gui-controller/templates/service.yaml new file mode 100644 index 0000000..d765368 --- /dev/null +++ b/charts/nebulous-gui-controller/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "nebulous-gui-controller.fullname" . }} + labels: + {{- include "nebulous-gui-controller.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "nebulous-gui-controller.selectorLabels" . | nindent 4 }} diff --git a/charts/nebulous-gui-controller/templates/serviceaccount.yaml b/charts/nebulous-gui-controller/templates/serviceaccount.yaml new file mode 100644 index 0000000..1b96dde --- /dev/null +++ b/charts/nebulous-gui-controller/templates/serviceaccount.yaml @@ -0,0 +1,12 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "nebulous-gui-controller.serviceAccountName" . }} + labels: + {{- include "nebulous-gui-controller.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/charts/nebulous-gui-controller/values.yaml b/charts/nebulous-gui-controller/values.yaml new file mode 100644 index 0000000..b544fe7 --- /dev/null +++ b/charts/nebulous-gui-controller/values.yaml @@ -0,0 +1,82 @@ +# Default values for nebulous-gui-controller. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +replicaCount: 1 + +image: + repository: "quay.io/nebulous/gui-controller-java-spring-boot-demo" + pullPolicy: IfNotPresent + # Overrides the image tag whose default is the chart appVersion. + tag: "" + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + # Specifies whether a service account should be created + create: true + # Annotations to add to the service account + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: "" + +podAnnotations: {} + +podSecurityContext: {} + # fsGroup: 2000 + +securityContext: {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 + +service: + type: ClusterIP + port: 80 + +ingress: + enabled: false + className: "" + annotations: {} + # kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: "true" + hosts: + - host: chart-example.local + paths: + - path: / + pathType: ImplementationSpecific + tls: [] + # - secretName: chart-example-tls + # hosts: + # - chart-example.local + +resources: {} + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + +autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 100 + targetCPUUtilizationPercentage: 80 + # targetMemoryUtilizationPercentage: 80 + +nodeSelector: {} + +tolerations: [] + +affinity: {} diff --git a/java-spring-boot-demo/.gitignore b/java-spring-boot-demo/.gitignore new file mode 100644 index 0000000..549e00a --- /dev/null +++ b/java-spring-boot-demo/.gitignore @@ -0,0 +1,33 @@ +HELP.md +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/java-spring-boot-demo/Dockerfile b/java-spring-boot-demo/Dockerfile new file mode 100644 index 0000000..427e30e --- /dev/null +++ b/java-spring-boot-demo/Dockerfile @@ -0,0 +1,15 @@ +# +# Build stage +# +FROM docker.io/library/maven:3.9.2-eclipse-temurin-17 AS build +COPY src /home/app/src +COPY pom.xml /home/app +RUN mvn -f /home/app/pom.xml clean package + +# +# Package stage +# +FROM docker.io/library/eclipse-temurin:17-jre +COPY --from=build /home/app/target/demo-0.0.1-SNAPSHOT.jar /usr/local/lib/demo.jar +EXPOSE 8080 +ENTRYPOINT ["java","-jar","/usr/local/lib/demo.jar"] diff --git a/java-spring-boot-demo/pom.xml b/java-spring-boot-demo/pom.xml new file mode 100644 index 0000000..76e0f0e --- /dev/null +++ b/java-spring-boot-demo/pom.xml @@ -0,0 +1,42 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.1.0 + + + com.example + demo + 0.0.1-SNAPSHOT + demo + Demo project for Spring Boot + + 17 + + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/java-spring-boot-demo/src/main/java/com/example/demo/DemoApplication.java b/java-spring-boot-demo/src/main/java/com/example/demo/DemoApplication.java new file mode 100644 index 0000000..094d95b --- /dev/null +++ b/java-spring-boot-demo/src/main/java/com/example/demo/DemoApplication.java @@ -0,0 +1,13 @@ +package com.example.demo; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class DemoApplication { + + public static void main(String[] args) { + SpringApplication.run(DemoApplication.class, args); + } + +} diff --git a/java-spring-boot-demo/src/main/java/com/example/demo/DemoController.java b/java-spring-boot-demo/src/main/java/com/example/demo/DemoController.java new file mode 100644 index 0000000..61a5075 --- /dev/null +++ b/java-spring-boot-demo/src/main/java/com/example/demo/DemoController.java @@ -0,0 +1,14 @@ +package com.example.demo; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class DemoController { + + @RequestMapping("/") + public Object root() { + return null; + } + +} diff --git a/java-spring-boot-demo/src/main/resources/application.properties b/java-spring-boot-demo/src/main/resources/application.properties new file mode 100644 index 0000000..e69de29 diff --git a/java-spring-boot-demo/src/test/java/com/example/demo/DemoApplicationTests.java b/java-spring-boot-demo/src/test/java/com/example/demo/DemoApplicationTests.java new file mode 100644 index 0000000..eaa9969 --- /dev/null +++ b/java-spring-boot-demo/src/test/java/com/example/demo/DemoApplicationTests.java @@ -0,0 +1,13 @@ +package com.example.demo; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class DemoApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/lib/area.js b/lib/area.js new file mode 100644 index 0000000..00e7e97 --- /dev/null +++ b/lib/area.js @@ -0,0 +1,43 @@ +module.exports = { + '@apostrophecms/video': {}, + '@apostrophecms/html': {}, + '@apostrophecms/rich-text': { + toolbar: [ + 'styles', + '|', + 'bold', + 'italic', + 'strike', + 'link', + '|', + 'bulletList', + 'orderedList', + '|', + 'blockquote', + 'codeBlock', + '|', + 'horizontalRule', + '|', + 'undo', + 'redo' + ], + styles: [ + { + tag: 'p', + label: 'Paragraph (P)' + }, + { + tag: 'h3', + label: 'Heading 3 (H3)' + }, + { + tag: 'h4', + label: 'Heading 4 (H4)' + } + ], + insert: [ + 'table', + 'image' + ] + } +}; diff --git a/lib/exn.js b/lib/exn.js new file mode 100644 index 0000000..a86c1ca --- /dev/null +++ b/lib/exn.js @@ -0,0 +1,261 @@ + + + +const connection_options={ + 'port': process.env.ACTIVEMQ_PORT, + 'host': process.env.ACTIVEMQ_HOST, + 'username': process.env.ACTIVEMQ_USERNAME, + 'password': process.env.ACTIVEMQ_PASSWORD, + 'reconnect': true +} + + +if(!connection_options.port || !connection_options.host) { + console.error("No connection option provided for EXN skipping asynchronous messaging") + return +} + + +const container= require('rhea'); +let connection; +let sender_sal_nodecandidate_get; +let sender_sal_cloud_get; +let sender_sal_cloud_post; +let sender_sal_cloud_delete; +let sender_sal_node_post; + +let sender_ui_application_new; +let sender_ui_application_updated; +let sender_ui_application_deploy; +let sender_ui_application_dsl_json; +let sender_ui_application_dsl_metric; + +let sender_ui_policies_rule_upsert; +let sender_ui_policies_model_upsert; + + +const correlations = {} + +container.on('message', (context)=>{ + + // console.log("Received ",context.message) + if(context.message.correlation_id in correlations){ + if(context.message.body.metaData['status'] >= 400){ + correlations[context.message.correlation_id]['reject'](context.message.body['message']) + }else{ + correlations[context.message.correlation_id]['resolve'](context.message.body) + } + } +}) + + +container.on('connection_open', function (context) { + + console.log("Connected ",context.container.id); + context.connection.open_receiver('topic://eu.nebulouscloud.exn.sal.cloud.get.reply') + context.connection.open_receiver('topic://eu.nebulouscloud.exn.sal.cloud.post.reply') + context.connection.open_receiver('topic://eu.nebulouscloud.exn.sal.cloud.delete.reply') + context.connection.open_receiver('topic://eu.nebulouscloud.exn.sal.nodecandidate.get.reply') + context.connection.open_receiver('topic://eu.nebulouscloud.exn.sal.node.post.reply') + + sender_sal_nodecandidate_get = context.connection.open_sender('topic://eu.nebulouscloud.exn.sal.nodecandidate.get'); + sender_sal_cloud_get = context.connection.open_sender('topic://eu.nebulouscloud.exn.sal.cloud.get'); + sender_sal_cloud_post = context.connection.open_sender('topic://eu.nebulouscloud.exn.sal.cloud.post'); + sender_sal_cloud_delete = context.connection.open_sender('topic://eu.nebulouscloud.exn.sal.cloud.delete'); + sender_sal_node_post = context.connection.open_sender('topic://eu.nebulouscloud.exn.sal.node.post'); + + sender_ui_application_new = context.connection.open_sender('topic://eu.nebulouscloud.ui.application.new'); + sender_ui_application_updated = context.connection.open_sender('topic://eu.nebulouscloud.ui.application.updated'); + sender_ui_application_deploy = context.connection.open_sender('topic://eu.nebulouscloud.ui.application.deploy'); + sender_ui_application_dsl_json = context.connection.open_sender('topic://eu.nebulouscloud.ui.dsl.generic'); + sender_ui_application_dsl_metric = context.connection.open_sender('topic://eu.nebulouscloud.ui.dsl.metric_model'); + + sender_ui_policies_rule_upsert = context.connection.open_sender('topic://eu.nebulouscloud.ui.policies.rule.upsert'); + sender_ui_policies_model_upsert = context.connection.open_sender('topic://eu.nebulouscloud.ui.policies.model.upsert'); + +}); + + + +connection = container.connect(); + +const {v4: uuidv4} = require("uuid"); + + +module.exports = { + sender_ui_application_new:(uuid) => { + return new Promise((resolve,reject) =>{ + const correlation_id = uuidv4() + correlations[correlation_id] = { + 'resolve':resolve, + 'reject':reject, + }; + const message = { + to: sender_ui_deploy_application_new.options.target.address, + correlation_id: correlation_id, + body:{ + uuid: uuid + } + } + console.log("Send ", message) + sender_ui_deploy_application_new.send(message) + + }) + }, + application_dsl:(uuid,json,yaml) => { + return new Promise((resolve,reject) =>{ + const correlation_id = uuidv4() + correlations[correlation_id] = { + 'resolve':resolve, + 'reject':reject, + }; + console.log("Sending ", sender_ui_application_dsl_json.options.target.address, uuid,json) + const message = { + to: sender_ui_application_dsl_json.options.target.address, + correlation_id: correlation_id, + body:json + } + sender_ui_application_dsl_json.send(message) + + console.log("Sending ", sender_ui_application_dsl_metric.options.target.address, uuid,json) + const metrci_message = { + to: sender_ui_application_dsl_metric.options.target.address, + correlation_id: correlation_id, + body:{ + 'yaml': yaml + } + } + sender_ui_application_dsl_metric.send(message) + + + + }) + }, + application_updated:(uuid) => { + return new Promise((resolve,reject) =>{ + const correlation_id = uuidv4() + correlations[correlation_id] = { + 'resolve':resolve, + 'reject':reject, + }; + const message = { + to: sender_ui_deploy_application_new.options.target.address, + correlation_id: correlation_id, + body:{ + uuid: uuid + } + } + console.log("Send ", message) + sender_ui_deploy_application_new.send(message) + + }) + }, + register_cloud:( uuid, user ,secret ) =>{ + return new Promise((resolve,reject)=>{ + + const correlation_id = uuidv4() + correlations[correlation_id] = { + 'resolve':resolve, + 'reject':reject, + }; + + const message = { + to: sender_sal_cloud_post.options.target.address, + correlation_id: correlation_id, + body:{ + metaData: { + userId: "admin" + }, + body: JSON.stringify([{ + "cloudId": uuid, + "cloudProviderName": "aws-ec2", + "cloudType": "PUBLIC", + "securityGroup": null, + "subnet": null, + "sshCredentials": { + "username": null, + "keyPairName": "mkl", + "privateKey": null + }, + "endpoint": null, + "scope": { + "prefix": null, + "value": null + }, + "identityVersion": null, + "defaultNetwork": null, + "credentials": { + "user": user, + "secret": secret, + "domain": null + }, + "blacklist": null + }]) + } + } + console.log("Send ", message) + sender_sal_cloud_post.send(message) + }) + }, + deploy_application: (uuid) => { + + + }, + get_cloud_candidates: () => { + return new Promise((resolve,reject)=> { + + const correlation_id = uuidv4() + correlations[correlation_id] = { + 'resolve': resolve, + 'reject': reject, + }; + + const message = { + to: sender_sal_nodecandidate_get.options.target.address, + correlation_id: correlation_id, + body: {} + } + sender_sal_nodecandidate_get.send(message) + }) + + }, + publish_policies:(policies) =>{ + return new Promise((resolve,reject)=> { + + const body = JSON.parse(policies) + body.forEach((b)=>{ + + const correlation_id = uuidv4() + const rule = { + to: sender_ui_policies_rule_upsert.options.target.address, + correlation_id: correlation_id, + body: [{ + "name": b['name'], + "policyItem": b['policyItem'] + }] + } + + const model = { + to: sender_ui_policies_model_upsert.options.target.address, + correlation_id: correlation_id, + body: [{ + "name": b['name'], + "enabled": true, + "modelText": b['model'] + }] + } + + sender_ui_policies_model_upsert.send(model) + sender_ui_policies_rule_upsert.send(rule) + + }) + + resolve() + + + }) + + } + + +} \ No newline at end of file diff --git a/lib/kubevela.js b/lib/kubevela.js new file mode 100644 index 0000000..ec3acae --- /dev/null +++ b/lib/kubevela.js @@ -0,0 +1,55 @@ +const slugify = require("slugify"); +const mathutils = require("./math"); +const _ = require("lodash"); + +module.exports = { + json: (doc) =>{ + let object = _.clone(doc) + object['variables'] = _.map(doc['variables'], (v)=>{ + + return { + 'key': slugify(v['name'].replaceAll('/','_'),'_'), + 'path': '/'+v['name'], + 'type': 'float', + 'meaning': v['name'].split('/').pop(), + 'value' :{ + "lower_bound": v['lowerValue'], + "higher_bound": v['higherValue'], + } + } + }) + object['sloViolations'] = JSON.parse(doc['sloViolations']) + object['metrics'] = _.map(doc['metrics'], (v)=>{ + + if(v['type'] === 'composite'){ + v['arguments'] = mathutils.extractVariableNames( + mathutils.extractFromEquation(v['formula'])) + } + + return v + }) + + object["utilityFunctions"] = _.map(doc['utilityFunctions'], (v)=>{ + return { + "name": v['functionName'], + "type": v['functionType'], + "expression":{ + "formula":v["functionExpression"], + "variables": _.map(v["functionExpressionVariables"], (k)=>{ + return { + "name":k['nameVariable'], + "value": slugify(k['valueVariable'].replaceAll('/','_'),'_') + } + }) + } + } + }) + + var protected_variables = ["_id","type",,"metaType","organization","_edit","_publish"] + _.each(protected_variables, (p)=>{ + delete object[p] + }) + + return object + } +} \ No newline at end of file diff --git a/lib/math.js b/lib/math.js new file mode 100644 index 0000000..5e7bb56 --- /dev/null +++ b/lib/math.js @@ -0,0 +1,33 @@ +const math = require('mathjs'); + + +module.exports = { + extractFromEquation: (equation)=>{ + equation = equation || ''; + const lowerCaseEquation = equation.toLowerCase(); + return math.parse(lowerCaseEquation); + }, + extractVariableNames: (mathNode) => { + let variableNames = new Set(); + + function traverse(node) { + if (node.type === 'SymbolNode') { + variableNames.add(node.name); + } + + for (const key in node.args) { + traverse(node.args[key]); + } + + if (node.content) { + traverse(node.content); + } + } + + traverse(mathNode); + + return Array.from(variableNames); + } + + +} \ No newline at end of file diff --git a/lib/metric_model.js b/lib/metric_model.js new file mode 100644 index 0000000..6470a8b --- /dev/null +++ b/lib/metric_model.js @@ -0,0 +1,149 @@ +const slugify = require("slugify"); +const _ = require("lodash"); +const yaml = require('yaml'); + +module.exports = { + yaml: (doc) => { + let object = _.clone(doc) + + const protectedVariables = ["_id", "type", "metaType", "organization", "_edit", "_publish", "variables", "utilityFunctions", "resources", "parameters",]; + protectedVariables.forEach(p => { + delete object[p]; + }); + + if (object.templates) { + object.templates = object.templates.map(v => { + + return { + + id: v.id, + type: v.type, + range: [v.minValue, v.maxValue], + unit: v.unit + } + }); + } + + object.metrics_comp = []; + object.metrics_global = []; + + if (object.metrics) { + object.metrics.forEach(v => { + let metricsDetail = {}; + + if (v.type === 'composite') { + const componentNames = v.components.map(component => component.componentName).join(', '); + + let windowDetail = {}; + + if (v.isWindowInput && v.input.type && v.input.interval && v.input.unit) { + windowDetail.type = v.input.type; + windowDetail.size = `${v.input.interval} ${v.input.unit}`; + } + if (v.isWindowOutput && v.output.type && v.output.interval && v.output.unit) { + windowDetail.output = `${v.output.type} ${v.output.interval} ${v.output.unit}`; + } + + metricsDetail = { + name: v.name, + type: v.type, + template: componentNames, + window: windowDetail + }; + + } else if (v.type === 'raw') { + + let windowDetailRaw = {}; + + if (v.isWindowInputRaw && v.inputRaw.type && v.inputRaw.interval && v.inputRaw.unit) { + windowDetailRaw.type = v.inputRaw.type; + windowDetailRaw.size = `${v.inputRaw.interval} ${v.inputRaw.unit}`; + } + if (v.isWindowOutputRaw && v.outputRaw.type && v.outputRaw.interval && v.outputRaw.unit) { + windowDetailRaw.output = `${v.outputRaw.type} ${v.outputRaw.interval} ${v.outputRaw.unit}`; + } + metricsDetail = { + name: v.name, + type: v.type, + sensor: { + type: v.sensor + }, + window: windowDetailRaw + }; + } + + const metric = { + metrics: metricsDetail + }; + + if (v.type === 'composite' && v.components.length < 2) { + object.metrics_global.push(metric); + } else if (v.type === 'composite' && v.components.length >= 2) { + object.metrics_comp.push(metric); + } else if (v.type === 'raw') { + object.metrics_global.push(metric); + } + }); + } + + + + + + if (object.sloViolations) { + const processSloViolations = (violations) => { + const buildConstraint = (v, parentCondition = '') => { + let constraint = ''; + if (!v.isComposite) { + constraint = `${v.metricName} ${v.operator} ${v.value}`; + } else { + const childConstraints = v.children.map(child => buildConstraint(child, v.condition)).join(` ${v.condition} `); + + if (v.not) { + constraint = `NOT (${childConstraints})`; + } else { + constraint = `(${childConstraints})`; + } + } + return constraint; + }; + + const combinedConstraint = buildConstraint(violations); + + const requirement = { + name: 'Combined SLO', + type: 'slo', + constraint: combinedConstraint + }; + + return [requirement]; + }; + + object.sloViolations = processSloViolations(JSON.parse(doc['sloViolations'])); + } + + const yamlDoc = { + apiVersion: "nebulous/v1", + kind: "MetricModel", + metadata: { + name: object.uuid, + labels: { + app: object.title, + } + }, + common: object.templates, + spec: { + components: object.metrics_comp + }, + scopes: [ + { + name: "app-wide-scope", + requirements: object.sloViolations, + components: object.metrics_global, + } + ] + }; + + return yamlDoc; + } +}; diff --git a/modules/@apostrophecms/admin-bar/index.js b/modules/@apostrophecms/admin-bar/index.js new file mode 100644 index 0000000..4554b77 --- /dev/null +++ b/modules/@apostrophecms/admin-bar/index.js @@ -0,0 +1,16 @@ +module.exports = { + options: { + groups: [ + { + name: 'media', + label: 'Media', + items: [ + '@apostrophecms/image', + '@apostrophecms/file', + '@apostrophecms/image-tag', + '@apostrophecms/file-tag' + ] + } + ] + } +}; \ No newline at end of file diff --git a/modules/@apostrophecms/express/index.js b/modules/@apostrophecms/express/index.js new file mode 100644 index 0000000..0ac5567 --- /dev/null +++ b/modules/@apostrophecms/express/index.js @@ -0,0 +1,8 @@ +module.exports = { + options: { + session: { + // If this still says `undefined`, set a real secret! + secret: 'abcxyz' + } + } +}; diff --git a/modules/@apostrophecms/home-page/index.js b/modules/@apostrophecms/home-page/index.js new file mode 100644 index 0000000..2c439f1 --- /dev/null +++ b/modules/@apostrophecms/home-page/index.js @@ -0,0 +1,6 @@ +module.exports = { + options: { + label: '' + }, + fields: {} +}; diff --git a/modules/@apostrophecms/home-page/views/page.html b/modules/@apostrophecms/home-page/views/page.html new file mode 100644 index 0000000..c9280dc --- /dev/null +++ b/modules/@apostrophecms/home-page/views/page.html @@ -0,0 +1,9 @@ +{# + This is an example home page template. It inherits and extends a layout template + that lives in the top-level views/ folder for convenience +#} + +{% extends "layout.html" %} + +{% block main %} +{% endblock %} diff --git a/modules/@apostrophecms/settings/index.js b/modules/@apostrophecms/settings/index.js new file mode 100644 index 0000000..b8e594b --- /dev/null +++ b/modules/@apostrophecms/settings/index.js @@ -0,0 +1,35 @@ +module.exports = { + options: { + subforms: { + displayname: { + fields: [ 'title' ], + reload: true + }, + changePassword: { + fields: [ 'password' ] + }, + fullname: { + label: 'Full Name', + fields: [ 'firstname', 'lastname' ], + preview: '{{ firstname }} {{lastname}}' + }, + organization: { + label: 'Organization', + type: 'string', + fields: [ 'organization' ] + }, + uuid: { + label: 'UUID', + type: 'string', + fields: [ 'uuid' ] + } + }, + + groups: { + account: { + label: 'Account', + subforms: [ 'displayname', 'fullname', 'changePassword', 'organization', 'uuid' ] + } + } + } +}; diff --git a/modules/@apostrophecms/user/index.js b/modules/@apostrophecms/user/index.js new file mode 100644 index 0000000..4246f9e --- /dev/null +++ b/modules/@apostrophecms/user/index.js @@ -0,0 +1,31 @@ +module.exports = { + fields: { + add: { + firstname: { + type: 'string', + label: 'First Name' + }, + lastname: { + type: 'string', + label: 'Last Name' + }, + organization: { + type: 'string', + label: 'Organization', + required: true, + group: 'basics' + }, + uuid: { + type: 'string', + label: 'UUID', + required: true + }, + }, + group: { + basics: { + label: 'Basics', + fields: ['firstname', 'lastname','organization', 'uuid'] + } + } + } +}; \ No newline at end of file diff --git a/modules/application/index.js b/modules/application/index.js new file mode 100644 index 0000000..0211aec --- /dev/null +++ b/modules/application/index.js @@ -0,0 +1,1204 @@ +const { v4: uuidv4 } = require('uuid'); +const Joi = require('joi'); +const yaml = require('yaml'); +const slugify = require('slugify'); +const mathutils = require('../../lib/math'); +const metric_model = require('../../lib/metric_model'); +const kubevela = require('../../lib/kubevela') +const exn = require('../../lib/exn') +const _=require('lodash') + +const container = require('rhea'); +let connection; +let application_new_sender; +let application_update_sender; +let application_dsl_generic; +let application_dsl_metric; +const projection = { + title: 1, + uuid: 1, + status: 1, + organization: 1, + content: 1, + variables: 1, + environmentVariables: 1, + resources: 1, + parameters: 1, + templates: 1, + metrics: 1, + sloViolations: 1, + utilityFunctions: 1 +}; + + +module.exports = { + extend: '@apostrophecms/piece-type', + options: { + label: 'Application', + }, + fields: { + add: { + uuid: { + type: 'string', + label: 'UUID' + }, + status: { + type: 'string', + label: 'Status', + def: 'draft' + }, + content: { + type: 'string', + textarea: true, + label: 'Content (YAML format)' + }, + variables: { + type: 'array', + label: 'Variables', + fields: { + add: { + name: { + type: 'string', + label: 'Name' + }, + fullPath: { + type: 'string', + label: 'Full Path' + }, + lowerValue: { + type: 'float', + label: 'Lower Value', + }, + higherValue: { + type: 'float', + label: 'Higher Value', + } + } + } + }, + environmentVariables: { + type: 'array', + label: 'Environmental Variables', + fields: { + add: { + name: { + type: 'string', + label: 'Name' + }, + value: { + type: 'string', + label: 'Value', + }, + secret: { + type: 'boolean', + label: 'Secret', + def: false + } + } + } + }, + resources: { + type: 'array', + label: 'Application Resources', + fields: { + add: { + uuid: { + type: 'string', + label: 'Resource UUID', + }, + title: { + type: 'string', + label: 'Resource Title' + }, + platform: { + type: 'string', + label: 'Platform Name', + }, + enabled: { + type: 'boolean', + label: 'Enabled', + def: false + } + } + } + }, + parameters: { + type: 'array', + label: 'Parameters', + fields: { + add: { + name: { + type: 'string', + label: 'Name' + }, + template: { + type: 'string', + label: 'Template' + } + } + } + }, + templates: { + type: 'array', + label: 'Templates', + fields: { + add: { + id: { + type: 'string', + label: 'ID', + }, + type: { + type: 'select', + label: 'Type', + choices: [ + { label: 'Integer', value: 'int' }, + { label: 'Double', value: 'double' } + ] + }, + minValue: { + type: 'integer', + label: 'Minimum Value', + }, + maxValue: { + type: 'integer', + label: 'Maximum Value', + }, + unit: { + type: 'string', + label: 'Unit', + } + } + } + }, + metrics: { + type: 'array', + label: 'Metrics', + fields: { + add: { + + type: { + type: 'select', + label: 'Type', + choices: [ + {label: 'Composite', value: 'composite'}, + {label: 'Raw', value: 'raw'} + ] + }, + level: { + type: 'select', + label: 'Level', + choices: [ + { label: 'Global', value: 'Global' }, + { label: 'Components', value: 'Components' } + ], + def: 'global' + }, + components: { + type: 'array', + label: 'Components', + if: { + level: 'Components' + }, + fields: { + add: { + componentName: { + type: 'string', + label: 'Component Name' + } + } + } + }, + name: { + type: 'string', + label: 'Name', + if: { + type: ['composite', 'raw'] + } + }, + formula: { + type: 'string', + label: 'Formula', + textarea: true, + if: { + type: 'composite' + } + }, + isWindowInput: { + type: 'boolean', + label: 'Window Input', + if: { + type: 'composite' + } + }, + input: { + type: 'object', + label: 'Input', + fields: { + add: { + type: { + type: 'select', + label: 'Type Input', + choices: [ + {label: 'All', value: 'all'}, + {label: 'Sliding', value: 'sliding'} + ], + }, + interval: { + type: 'integer', + label: 'Interval', + }, + unit: { + type: 'select', + label: 'Unit', + choices: [ + {label: 'Ms', value: 'ms'}, + {label: 'Sec', value: 'sec'}, + {label: 'Min', value: 'min'}, + {label: 'Hour', value: 'hour'}, + {label: 'Day', value: 'day'} + ], + }, + }, + }, + if: { + isWindowInput: true + } + }, + isWindowOutput: { + type: 'boolean', + label: 'Window Output', + if: { + type: 'composite' + } + }, + output: { + type: 'object', + label: 'Output', + fields: { + add: { + type: { + type: 'select', + choices: [ + {label: 'All', value: 'all'}, + {label: 'Sliding', value: 'sliding'} + ], + }, + interval: { + type: 'integer', + label: 'Interval' + }, + unit: { + type: 'select', + label: 'Unit', + choices: [ + {label: 'Ms', value: 'ms'}, + {label: 'Sec', value: 'sec'}, + {label: 'Min', value: 'min'}, + {label: 'Hour', value: 'hour'}, + {label: 'Day', value: 'day'} + ], + } + } + }, + if: { + isWindowOutput: true + } + }, + sensor: { + type: 'string', + label: 'Sensor', + if: { + type: 'raw' + } + }, + config: { + type: 'array', + label: 'Config', + if: { + type: 'raw' + }, + fields: { + add: { + name: { + type: 'string', + label: 'Name' + }, + value: { + type: 'string', + label: 'Value' + } + } + } + }, + isWindowInputRaw: { + type: 'boolean', + label: 'Window Input', + if: { + type: 'raw' + } + }, + inputRaw: { + type: 'object', + label: 'Input', + fields: { + add: { + type: { + type: 'select', + label: 'Type Input', + choices: [ + {label: 'All', value: 'all'}, + {label: 'Sliding', value: 'sliding'} + ], + }, + interval: { + type: 'integer', + label: 'Interval', + }, + unit: { + type: 'select', + label: 'Unit', + choices: [ + {label: 'Ms', value: 'ms'}, + {label: 'Sec', value: 'sec'}, + {label: 'Min', value: 'min'}, + {label: 'Hour', value: 'hour'}, + {label: 'Day', value: 'day'} + ], + }, + }, + }, + if: { + isWindowInputRaw: true + } + }, + isWindowOutputRaw: { + type: 'boolean', + label: 'Window Output', + if: { + type: 'raw' + } + }, + outputRaw: { + type: 'object', + label: 'Output', + fields: { + add: { + type: { + type: 'select', + choices: [ + {label: 'All', value: 'all'}, + {label: 'Sliding', value: 'sliding'} + ], + }, + interval: { + type: 'integer', + label: 'Interval' + }, + unit: { + type: 'select', + label: 'Unit', + choices: [ + {label: 'Ms', value: 'ms'}, + {label: 'Sec', value: 'sec'}, + {label: 'Min', value: 'min'}, + {label: 'Hour', value: 'hour'}, + {label: 'Day', value: 'day'} + ], + } + } + }, + if: { + isWindowOutputRaw: true + } + }, + } + } + }, + sloViolations: { + type: 'string', + label: 'SLO', + textarea: true, + }, + utilityFunctions: { + type: 'array', + label: 'Utility Functions', + fields: { + add: { + functionName: { + type: 'string', + label: 'Function Name' + }, + functionType: { + type: 'select', + label: 'Function Type', + choices: [ + {label: 'Maximize', value: 'maximize'}, + {label: 'Constant', value: 'constant'} + ] + }, + functionExpression: { + type: 'string', + label: 'Function Expression', + textarea: true + }, + functionExpressionVariables: { + type: 'array', + label: 'Expression Variables', + fields: { + add: { + nameVariable: { + type: 'string', + label: 'Name Variable Value' + }, + valueVariable: { + type: 'string', + label: 'Expression Variable Value' + } + } + } + }, + } + } + } + }, + group: { + basics: { + label: 'Details', + fields: ['title', 'uuid','status', 'content', 'variables','environmentVariables'] + }, + resources: { + label: 'Resources', + fields: ['resources'] + }, + templates: { + label: 'Templates', + fields: ['templates'] + }, + parameters: { + label: 'Parameters', + fields: ['parameters'] + }, + metricsGroup: { + label: 'Metrics', + fields: ['metrics', 'sloViolations'] + }, + expressionEditor: { + label: 'Expression Editor', + fields: ['utilityFunctions'] + } + } + }, + + handlers(self) { + return { + // 'apostrophe:ready': { + // async setUpActiveMq() { + // console.log("Set up rhea", + // self.options.amqp_host, self.options.amqp_port); + // + // container.on('connection_open', function (context) { + // application_new_sender = context.connection.open_sender('topic://eu.nebulouscloud.ui.application.new'); + // application_update_sender = context.connection.open_sender('topic://eu.nebulouscloud.ui.application.update'); + // application_dsl_generic = context.connection.open_sender('topic://eu.nebulouscloud.ui.application.dsl.generic'); + // application_dsl_metric = context.connection.open_sender('topic://eu.nebulouscloud.ui.application.dsl.metric_model'); + // }); + // + // connection = container.connect({ + // 'host': self.options.amqp_host, + // 'port': self.options.amqp_port, + // 'reconnect':true, + // 'username':'admin', + // 'password':'admin' + // }); + // + // } + // }, + beforeInsert: { + async generateUuid(req, doc, options) { + if (!doc.uuid) { + doc.uuid = uuidv4(); + } + if (req.user && req.user.organization) { + doc.organization = req.user.organization; + } + } + }, + // afterInsert:{ + // async postMessages(req,doc,option){ + // console.log("Application created " + doc.uuid) + // + // //produce application.json + // + // + // //product metric model + // + // //post to activemq + // application_new_sender.send({ + // "body":{"uuid":doc.uuid}, + // }); + // application_dsl_generic.send({body:{}}); + // + // } + // }, + // afterSave: { + // async processAppAfterSave(req, doc, options) { + // try { + // console.log("UUID:", doc.uuid); + // const applicationData = await self.getApplicationData(doc.uuid); + // return applicationData; + // } catch (error) { + // console.error('Error', error); + // } + // } + // } + afterDeploy:{ + async deployApplication(req,doc,options){ + console.log("After deployment",doc.uuid) + if(connection){ + application_update_sender.send({ + "body":{"uuid":doc.uuid}, + "message_annotations":{ + "subject":doc.uuid + } + }); + application_dsl_generic.send({body:{}, "message_annotations":{ + "subject":doc.uuid + }}); + } + } + }, + afterUpdate:{ + async postMessages(req,doc,option){ + console.log("After update", doc.uuid); + + //produce application.json + + + //product metric model + + //post to activemq + + // eu.nebulouscloud.ui.application.new + + // eu.nebulouscloud.ui.application.updated + if(connection){ + application_update_sender.send({ + "body":{"uuid":doc.uuid}, + "message_annotations":{ + "subject":doc.uuid + } + }); + application_dsl_generic.send({body:{}, "message_annotations":{ + "subject":doc.uuid + }}); + } + } + } + }; + }, + methods(self) { + const contentSchema = Joi.string().custom((value, helpers) => { + try { + yaml.parse(value); + return value; + } catch (err) { + return helpers.error('string.yaml'); + } + }).messages({ + 'string.yaml': "Content must be in valid YAML format.", + }); + + const variableSchema = Joi.object({ + name: Joi.string().trim().required().messages({ + 'string.empty': "Please enter a name.", + 'any.required': "Name is a required field." + }), + lowerValue: Joi.number().required().messages({ + 'number.base': "Lower value must be a valid number.", + 'any.required': "Lower value is a required field." + }), + higherValue: Joi.number().min(Joi.ref('lowerValue')).required().messages({ + 'number.base': "Higher value must be a valid number.", + 'number.min': "Higher value must be greater than or equal to the lower value.", + 'any.required': "Higher value is a required field." + }) + }).unknown(); + + const resourcesSchema = Joi.object({ + title: Joi.string().trim().required().messages({ + 'string.empty': 'Resource Title cannot be empty.', + 'any.required': 'Resource Title is a required field.' + }), + uuid: Joi.string().trim().required().messages({ + 'string.empty': 'Resource UUID cannot be empty.', + 'any.required': 'Resource UUID is a required field.' + }), + platform: Joi.string().required().messages({ + 'any.only': 'Resource Platform must be one of AWS, AZURE, GCP, BYON.', + 'string.empty': 'Resource Platform cannot be empty.', + 'any.required': 'Resource Platform is a required field.' + }), + enabled: Joi.boolean().messages({ + 'boolean.base': 'Enabled must be a boolean value.' + }) + }).unknown(); + + const parameterSchema = Joi.object({ + name: Joi.string().trim().required().messages({ + 'string.empty': "Name cannot be empty.", + 'any.required': "Name is a required field." + }), + template: Joi.string().trim().required().messages({ + 'string.empty': "Template cannot be empty.", + 'any.required': "Template is a required field." + }) + }).unknown(); + + const templateSchema = Joi.object({ + id: Joi.string().trim().required().messages({ + 'string.empty': "ID cannot be empty.", + 'any.required': "ID is a required field." + }), + type: Joi.string().valid('int', 'double').required().messages({ + 'string.base': 'Type must be a string.', + 'any.required': 'Type is required.', + 'any.only': 'Type must be either "Integer" or "Double".' + }), + minValue: Joi.number().integer().messages({ + 'number.base': 'Minimum Value must be a number.', + 'number.integer': 'Minimum Value must be an integer.' + }), + maxValue: Joi.number().integer().messages({ + 'number.base': 'Maximum Value must be a number.', + 'number.integer': 'Maximum Value must be an integer.' + }), + unit: Joi.string().trim().messages({ + 'string.base': 'Unit must be a string.' + }) + }).unknown(); + + // const metricSchema = Joi.object({ + // type: Joi.string().valid('composite', 'raw').required().messages({ + // 'any.required': 'Metric type is required.', + // 'string.valid': 'Metric type must be either "composite" or "raw".' + // }), + // level: Joi.string().required().messages({ + // 'string.base': 'Level must be a string.', + // 'any.required': 'Level is required.', + // 'any.only': 'Level must be either "Global" or "Components".' + // }), + // components: Joi.when('level', { + // is: 'components', + // then: Joi.array().items(Joi.object({ + // componentName: Joi.string().trim().required().messages({ + // 'string.base': 'Component Name must be a string.', + // 'any.required': 'Component Name is required in components.' + // }) + // }).unknown()).required() + // }).required(), + // name: Joi.when('type', { + // is: 'composite,raw', + // then: Joi.string().trim().required().messages({ + // 'any.required': 'Name is required for composite metrics.', + // 'string.empty': 'Name cannot be empty.' + // }) + // }), + // formula: Joi.when('type', { + // is: 'composite', + // then: Joi.string().trim().required().messages({ + // 'any.required': 'Formula is required for composite metrics.', + // 'string.empty': 'Formula cannot be empty.' + // }) + // }), + // isWindowInput: Joi.when('type', { + // is: 'composite', + // then: Joi.boolean().required() + // }), + // input: Joi.when('isWindowInput', { + // is: true, + // then: Joi.object({ + // type: Joi.string().valid('all', 'sliding').required(), + // interval: Joi.number().integer().required(), + // unit: Joi.string().valid('ms', 'sec', 'min', 'hour', 'day').required() + // }).required() + // }), + // isWindowOutput: Joi.when('type', { + // is: 'composite', + // then: Joi.boolean().required() + // }), + // output: Joi.when('isWindowOutput', { + // is: true, + // then: Joi.object({ + // type: Joi.string().valid('all', 'sliding').required(), + // interval: Joi.number().integer().required(), + // unit: Joi.string().valid('ms', 'sec', 'min', 'hour', 'day').required() + // }).required() + // }), + // sensor: Joi.when('type', { + // is: 'raw', + // then: Joi.string().trim().required() + // }), + // config: Joi.when('type', { + // is: 'raw', + // then: Joi.array().items( + // Joi.object({ + // name: Joi.string().trim().required().messages({ + // 'string.base': 'Name must be a string.', + // 'any.required': 'Name is required in config for raw type.' + // }), + // value: Joi.string().trim().required().messages({ + // 'string.base': 'Value must be a string.', + // 'any.required': 'Value is required in config for raw type.' + // }), + // }).unknown(), + // ).required() + // }).messages({ + // 'any.required': 'Config is required for raw type.' + // }), + // isWindowInputRaw: Joi.when('type', { + // is: 'raw', + // then: Joi.boolean().required() + // }), + // inputRaw: Joi.when('isWindowInputRaw', { + // is: true, + // then: Joi.object({ + // type: Joi.string().valid('all', 'sliding').required(), + // interval: Joi.number().integer().required(), + // unit: Joi.string().valid('ms', 'sec', 'min', 'hour', 'day').required() + // }).required() + // }), + // isWindowOutputRaw: Joi.when('type', { + // is: 'raw', + // then: Joi.boolean().required() + // }), + // outputRaw: Joi.when('isWindowOutputRaw', { + // is: true, + // then: Joi.object({ + // type: Joi.string().valid('all', 'sliding').required(), + // interval: Joi.number().integer().required(), + // unit: Joi.string().valid('ms', 'sec', 'min', 'hour', 'day').required() + // }).required() + // }) + // }).unknown(); + + const utilityFunctionSchema = Joi.object({ + functionName: Joi.string().trim().required().messages({ + 'string.base': 'Function Name must be a string.', + 'any.required': 'Function Name is required.' + }), + functionType: Joi.string().valid('maximize', 'constant').insensitive().required().messages({ + 'string.base': 'Function Type must be a string.', + 'any.required': 'Function Type is required.', + 'any.only': 'Function Type must be either "Maximize" or "Constant".' + }), + functionExpression: Joi.string().trim().required().messages({ + 'string.base': 'Function Expression must be a string.', + 'any.required': 'Function Expression is required.' + }), + functionExpressionVariables: Joi.array().items( + Joi.object({ + nameVariable: Joi.string().trim().required().messages({ + 'string.base': 'Name Variable Value must be a string.', + 'any.required': 'Name Variable Value is required.' + }), + valueVariable: Joi.string().trim().required().messages({ + 'string.base': 'Expression Variable Value must be a string.', + 'any.required': 'Expression Variable Value is required.' + }) + }).unknown().required() + ).messages({ + 'array.base': 'Expression Variables must be an array.' + }) + }).unknown(); + + return { + isValidStateTransition(currentState, newState) { + const validTransitions = { + 'draft': ['valid'], + 'valid': ['deploying'], + 'deploying': ['running'], + 'running': ['draft'] + }; + + if (validTransitions[currentState].indexOf(newState) === -1) { + return false; + } + + return true; + }, + validateDocument(doc) { + let errorResponses = []; + + const validateArray = (dataArray, schema, arrayName) => { + if (Array.isArray(dataArray)) { + dataArray.forEach((item, index) => { + const { error } = schema.validate(item); + if (error) { + error.details.forEach(detail => { + let message = detail.message.replace(/\"/g, ""); + errorResponses.push({ + path: `${arrayName}[${index}].${detail.path.join('.')}`, + index: index.toString(), + key: detail.path[detail.path.length - 1], + message: message + }); + }); + } + }); + } + }; + const validateField = (data, schema, fieldName) => { + const { error } = schema.validate(data); + if (error) { + error.details.forEach(detail => { + let message = detail.message.replace(/\"/g, ""); + errorResponses.push({ + path: `${fieldName}.${detail.path.join('.')}`, + message: message + }); + }); + } + }; + + validateField(doc.content, contentSchema, 'content'); + validateArray(doc.variables, variableSchema, 'variables'); + validateArray(doc.resources, resourcesSchema, 'resources'); + validateArray(doc.parameters, parameterSchema, 'parameters'); + validateArray(doc.templates, templateSchema, 'templates'); + //validateArray(doc.metrics, metricSchema, 'metrics'); + validateArray(doc.utilityFunctions, utilityFunctionSchema, 'utilityFunctions'); + + if (errorResponses.length > 0) { + throw self.apos.error('required', 'Validation failed', {error: errorResponses}); + } + }, + async getApplicationData(uuid) { + try { + + const application = await self.apos.doc.db.findOne({ uuid: uuid }); + if (!application) { + throw self.apos.error('notfound', 'Application not found', { uuid }); + } + + + const data = { + application: { + name: application.title, + uuid: application.uuid + }, + kubvela: { + original: application.content, + variables: application.variables.map(variable => ({ + key: variable.name, + value: variable.isConstant ? { + lower: variable.value, + upper: false + } : { + lower: variable.lowerValue, + upper: variable.higherValue + }, + meaining: '...', + type: 'float', + is_constant: variable.isConstant + })) + }, + cloud_providers: application.resources.map(resource => ({ + type: resource.platform, + sal_key: resource.uuid + })), + metrics: application.metrics.map(metric => ({ + type: metric.type, + key: metric.nameResult, + name: metric.name, + formula: metric.type === 'composite' ? metric.formula : undefined, + window: metric.type === 'composite' && metric.isWindow ? { + input: metric.input ? { + type: metric.input.type, + interval: metric.input.interval, + unit: metric.input.unit + } : {}, + output: metric.output ? { + type: metric.output.type, + interval: metric.output.interval, + unit: metric.output.unit + } : {} + } : undefined, + sensor: metric.type === 'raw' ? metric.sensor : undefined, + config: metric.type === 'raw' ? metric.config.map(c => ({ + name: c.name, + value: c.value + })) : undefined + })), + slo: JSON.parse(application.sloViolations), + utility_functions: application.utilityFunctions.map(func => ({ + key: func.functionName, + name: func.functionName, + type: func.functionType, + formula: func.functionExpression, + mapping: func.functionExpressionVariables.reduce((map, variable) => { + map[variable.nameVariable] = variable.valueVariable; + return map; + }, {}) + })) + }; + + return data; + } catch (error) { + throw self.apos.error('notfound', 'Application not found', {uuid}); + } + } + }; + }, + apiRoutes(self) { + return { + post: { + async validate (req) { + if (!self.apos.permission.can(req, 'edit')) { + throw self.apos.error('forbidden', 'Insufficient permissions'); + } + + const doc = req.body; + let errorResponses = self.validateDocument(doc) || []; + if (errorResponses.length > 0) { + throw self.apos.error('required', 'Validation failed', { error: errorResponses }); + } + }, + async ':uuid/uuid/deploy' (req) { + + const uuid = req.params.uuid; + + // let errorResponses = self.validateDocument(updateData, true) || []; + // if (errorResponses.length > 0) { + // throw self.apos.error('required', 'Validation failed', { error: errorResponses }); + // } + const currentUser = req.user; + const adminOrganization = currentUser.organization; + + const existingApp = await self.apos.doc.db.findOne({ uuid: uuid , organization:adminOrganization }); + if (!existingApp) { + throw self.apos.error('notfound', 'Application not found'); + } + + try { + + const updatedApp = await self.find(req,{ uuid: uuid , organization:adminOrganization }).project(projection).toArray(); + const result = await exn.application_dsl(uuid, + kubevela.json(updatedApp.pop()), + "" + ) + //TODO refactor to use apostrophe CMS ORM + await self.apos.doc.db.updateOne( + { uuid: uuid }, + { $set: {'status':'deploying'} } + ); + if(updatedApp.length > 0 ){ + await self.emit('afterDeploy', req, updatedApp[0]); + } + return { status: 'deployed', message: 'Application deployed successfully', updatedResource: updatedApp }; + + } catch (error) { + throw self.apos.error(error.name, error.message); + } + + } + + }, + get: { + async all(req) { + if (!self.apos.permission.can(req, 'view')) { + throw self.apos.error('forbidden', 'Insufficient permissions'); + } + + const currentUser = req.user; + const adminOrganization = currentUser.organization; + + try { + const filters = {}; + filters.organization = adminOrganization; + + + const docs = await self.find(req, filters).project(projection).toArray(); + return docs; + } catch (error) { + throw self.apos.error(error.name, error.message); + } + }, + async ':uuid/uuid'(req) { + const uuid = req.params.uuid; + + if (!self.apos.permission.can(req, 'view')) { + throw self.apos.error('forbidden', 'Insufficient permissions'); + } + + const currentUser = req.user; + const adminOrganization = currentUser.organization; + + try { + const doc = await self.find(req, { uuid: uuid , organization:adminOrganization}).project(projection).toObject(); + if (!doc) { + throw self.apos.error('notfound', 'Application not found'); + } + + if (doc.organization !== adminOrganization) { + throw self.apos.error('forbidden', 'Access denied'); + } + + return doc; + } catch (error) { + throw self.apos.error(error.name, error.message); + } + }, + async ':uuid/json'(req) { + const uuid = req.params.uuid; + + if (!self.apos.permission.can(req, 'view')) { + throw self.apos.error('forbidden', 'Insufficient permissions'); + } + const currentUser = req.user; + const adminOrganization = currentUser.organization; + + try { + const doc = await self.find(req, { uuid: uuid , organization:adminOrganization}).project(projection).toObject(); + if (!doc) { + throw self.apos.error('notfound', 'Application not found'); + } + + if (doc.organization !== adminOrganization) { + throw self.apos.error('forbidden', 'Access denied'); + } + + let json_output = kubevela.json(doc) + req.res.type('application/json'); + req.res.setHeader('Content-Disposition', `attachment; filename="${uuid}.json"`); + return json_output; + + } catch (error) { + throw self.apos.error(error.name, error.message); + } + }, + async ':uuid/yaml'(req) { + const uuid = req.params.uuid; + + if (!self.apos.permission.can(req, 'view')) { + throw self.apos.error('forbidden', 'Insufficient permissions'); + } + const currentUser = req.user; + const adminOrganization = currentUser.organization; + + try { + const doc = await self.find(req, { uuid: uuid, organization: adminOrganization }).project(projection).toObject(); + if (!doc) { + throw self.apos.error('notfound', 'Application not found'); + } + + if (doc.organization !== adminOrganization) { + throw self.apos.error('forbidden', 'Access denied'); + } + + req.res.type('application/yaml'); + req.res.setHeader('Content-Disposition', `attachment; filename="${uuid}.yaml"`); + + //const yamlContent = yaml.stringify(doc); + const yamlContent = metric_model.yaml(doc); + const test = yaml.stringify(yamlContent); + + + return test; + + } catch (error) { + throw self.apos.error(error.name, error.message); + } + } + }, + delete: { + async ':uuid/uuid'(req) { + const uuid = req.params.uuid; + const currentUser = req.user; + const adminOrganization = currentUser.organization; + + if (!uuid) { + throw self.apos.error('invalid', 'UUID is required'); + } + + const doc = await self.find(req, { uuid: uuid , organization:adminOrganization }).toObject(); + if (!doc) { + throw self.apos.error('notfound', 'Application not found'); + } + + if (!self.apos.permission.can(req, 'delete')) { + throw self.apos.error('forbidden', 'You do not have permission to perform this action'); + } + + //Validation of the state of Application + // if (doc.status === 'deploying' || doc.status === 'running') { + // throw self.apos.error('forbidden', 'Application cannot be deleted while deploying or running'); + // } + + try { + const docs = await self.apos.db.collection('aposDocs').find({ uuid: uuid, organization:adminOrganization }).toArray(); + + if (!docs || docs.length === 0) { + throw self.apos.error('notfound', 'Document not found'); + } + + if (docs[0].organization !== adminOrganization) { + throw self.apos.error('forbidden', 'Access denied'); + } + + for (const doc of docs) { + await self.apos.db.collection('aposDocs').deleteOne({ _id: doc._id }); + } + + return { status: 'success', message: 'Application deleted successfully' }; + } catch (error) { + throw self.apos.error(error.name, error.message); + } + } + }, + patch: { + async ':uuid/uuid'(req) { + const uuid = req.params.uuid; + const updateData = req.body; + + // let errorResponses = self.validateDocument(updateData, true) || []; + // if (errorResponses.length > 0) { + // throw self.apos.error('required', 'Validation failed', { error: errorResponses }); + // } + + const currentUser = req.user; + const adminOrganization = currentUser.organization; + + const existingApp = await self.apos.doc.db.findOne({ uuid: uuid , organization:adminOrganization }); + if (!existingApp) { + throw self.apos.error('notfound', 'Application not found'); + } + const currentState = existingApp.status; + const newState = updateData.status; + + //Validation of the state of Application + // if (!self.isValidStateTransition(currentState, newState)) { + // throw self.apos.error('invalid', 'Invalid state transition'); + // } + + + try { + await self.apos.doc.db.updateOne( + { uuid: uuid }, + { $set: updateData } + ); + + //TODO refactor to use apostrophe CMS ORM + const updatedApp = await self.find(req,{ uuid: uuid , organization:adminOrganization }).project(projection).toArray(); + if(updatedApp.length > 0 ){ + await self.emit('afterUpdate', req, updatedApp[0]); + } + return { status: 'success', message: 'Application partially updated successfully', updatedResource: updatedApp }; + } catch (error) { + throw self.apos.error(error.name, error.message); + } + } + }, + }; + } +}; + diff --git a/modules/kubevela/index.js b/modules/kubevela/index.js new file mode 100644 index 0000000..48a75e0 --- /dev/null +++ b/modules/kubevela/index.js @@ -0,0 +1,127 @@ +const yaml = require('yaml'); +const flat=require( 'flat'); +const _= require('lodash') +module.exports = { + apiRoutes(self) { + return { + post: { + async keys(req) { + try { + const { content } = req.body; + if (!content) { + return []; + } + + const query = req.query.q; + const yamlData = yaml.parse(content); + + if (!yamlData) { + throw self.apos.error('invalid', 'Invalid YAML data.') + } + + const flattenKeys = flat.flatten(yamlData,{'delimiter':'/'}) + if (!flattenKeys || flattenKeys.length === 0) { + return []; + } + return _.map(flattenKeys, (k,v)=>{ + return { + 'value': v, + 'label': formatLabel(v,'/') + } + }) + } catch (error) { + console.error('Error processing YAML:', error.message); + throw error; + } + }, + async 'components'(req) { + try { + const { content } = req.body; + if (!content) { + return []; + } + + const yamlData = yaml.parse(content); + + if (!yamlData || !yamlData.spec || !yamlData.spec.components) { + return [] + } + + const components = yamlData.spec.components; + const componentNames = components + .filter(component => component.name) + .map(component => ({ + value: component.name, + label: component.name + })); + + return componentNames; + } catch (error) { + console.error('Error processing YAML:', error.message); + throw error; + } + } + + }, + }; + }, +}; + +function formatLabel(key,delimiter='.') { + const specComponentsPropertiesPrefix = 'spec/components/properties'; + if (key.startsWith(specComponentsPropertiesPrefix)) { + key = key.substring(specComponentsPropertiesPrefix.length + 1); // +1 for the dot after the prefix + } + const parts = key.split(delimiter); + let ret = key + if (parts.length > 4) { + + const firstThree = parts.slice(0, 3).join(delimiter); + const lastOne = parts[parts.length - 1]; + const dots = '.'.repeat(parts.length - 4); // Repeat '.' for the number of parts - 4 + ret = `${firstThree}[${dots}]${lastOne}`; + } + + return ret; +} + + + +function findKeys(obj, query, currentPath = []) { + const keys = []; + for (const [key, value] of Object.entries(obj)) { + const newPath = [...currentPath, key]; + const currentKey = newPath.join('.'); + + // if (currentKey.startsWith(query)) { + // keys.push(currentKey); + // } + + if (currentKey.startsWith(query)) { + const adjustedKey = query.startsWith('spec.') ? currentKey.substring(5) : currentKey; + keys.push(adjustedKey); + } + + if (typeof value === 'object' && value !== null) { + if (Array.isArray(value)) { + value.forEach(item => { + if (typeof item === 'object') { + keys.push(...findKeys(item, query, newPath)); + } + }); + } else { + keys.push(...findKeys(value, query, newPath)); + } + } + } + return filterRedundantKeys(keys); +} + + +function filterRedundantKeys(keys) { + return keys.filter((key, index, self) => { + return !self.some((otherKey) => { + return otherKey.startsWith(key + '.') && otherKey !== key; + }); + }); +} \ No newline at end of file diff --git a/modules/mathparser/index.js b/modules/mathparser/index.js new file mode 100644 index 0000000..88c9de9 --- /dev/null +++ b/modules/mathparser/index.js @@ -0,0 +1,22 @@ +var mathutils = require('../../lib/math' +) +module.exports = { + apiRoutes(self) { + return { + post: { + async expression(req) { + try { + let parsedEquation = mathutils.extractFromEquation(req.body.equation) + const variableNames = mathutils.extractVariableNames(parsedEquation); + const uppercaseVariableNames = variableNames.map(name => name.toUpperCase()); + return { + variables: uppercaseVariableNames, + }; + } catch (error) { + throw error; + } + }, + }, + }; + }, +}; diff --git a/modules/platforms/index.js b/modules/platforms/index.js new file mode 100644 index 0000000..b62b536 --- /dev/null +++ b/modules/platforms/index.js @@ -0,0 +1,52 @@ +const {v4: uuidv4} = require("uuid"); +module.exports = { + extend: '@apostrophecms/piece-type', + options: { + label: 'Platform', + }, + + fields: { + add: { + uuid: { + type: 'string', + label: 'UUID', + required: false + } + }, + group: { + basics: { + label: 'Basics', + fields: ['uuid'] + } + } + }, + handlers(self) { + return { + beforeSave: { + async generateUuid(req, doc) { + if (!doc.uuid) { + doc.uuid = uuidv4(); + } + } + } + } + }, + apiRoutes(self) { + return { + get: { + async all(req) { + const projection = { + title: 1, + uuid: 1, + }; + try { + const platforms = await self.find(req).project(projection).toArray(); + return platforms; + } catch (error) { + throw self.apos.error('notfound', 'Platforms not found'); + } + } + } + } + } +}; \ No newline at end of file diff --git a/modules/policies/index.js b/modules/policies/index.js new file mode 100644 index 0000000..a5d79d1 --- /dev/null +++ b/modules/policies/index.js @@ -0,0 +1,18 @@ +const exn = require('../../lib/exn') + + +module.exports = { + apiRoutes(self) { + return { + post: { + async publish(req) { + try { + await exn.publish_policies(req.body.policies) + } catch (error) { + throw error; + } + }, + }, + }; + }, +}; diff --git a/modules/resources/index.js b/modules/resources/index.js new file mode 100644 index 0000000..9c721fb --- /dev/null +++ b/modules/resources/index.js @@ -0,0 +1,308 @@ +const { v4: uuidv4 } = require('uuid'); +const Joi = require('joi'); +const exn = require('../../lib/exn'); +const _ = require('lodash'); + +const projection = { + title: 1, + uuid: 1, + organization: 1, + platform: 1, + appId: 1, + appSecret: 1 +}; +const resourcesSchema = Joi.object({ + appId: Joi.string().required().messages({ + 'string.empty': 'App ID is required.', + 'any.required': 'App ID is a required field.' + }), + appSecret: Joi.string().required().messages({ + 'string.empty': 'App Secret is required.', + 'any.required': 'App Secret is a required field.' + }), + platform: Joi.string().required().messages({ + 'string.empty': 'Platform is required.', + 'any.required': 'Platform is a required field.' + }), +}).unknown().options({ abortEarly: false }); + +module.exports = { + extend: '@apostrophecms/piece-type', + options: { + label: 'Resource', + }, + fields: { + add: { + uuid: { + type: 'string', + label: 'UUID', + readOnly: true + }, + platform: { + type: 'string', + label: 'Platform' + }, + appId: { + type: 'string', + label: 'App ID', + }, + appSecret: { + type: 'string', + label: 'App Secret', + } + }, + group: { + basics: { + label: 'Details', + fields: ['title', 'uuid', 'platform', 'appId', 'appSecret'] + } + } + }, + handlers(self) { + async function generateUuid(doc) { + if (!doc.uuid) { + doc.uuid = uuidv4(); + } + } + async function assignOrganization(req, doc) { + if (req.user.role === "admin" && req.user.organization) { + doc.organization = req.user.organization; + } + } + return { + beforeInsert: { + + async handler(req, doc) { + if (!(req.user.role === "admin")){ + throw self.apos.error('forbidden', 'Editors are not allowed to create resources'); + } + await generateUuid(doc); + try{ + + if(doc.aposMode === 'published'){ + const message = await exn.register_cloud( + doc.uuid, + doc.appId, + doc.appSecret, + ) + console.log("Registered ",message); + + } + await self.updateWithPlatformInfo(doc); + await assignOrganization(req, doc); + + }catch(e){ + throw self.apos.error('invalid', 'Unknown Error '+e); + } + } + }, + + + beforeSave: { + async handler(req, doc, options) { + try { + self.validateDocument(doc); + } catch (error) { + if (error.name === 'required' && error.error && error.error.length > 0) { + const formattedErrors = error.error.map(err => { + return { field: err.path, message: err.message }; + }); + throw self.apos.error('invalid', 'Validation failed', { errors: formattedErrors }); + } else { + throw error; + } + } + } + } + } + }, + + methods(self) { + return { + async updateWithPlatformInfo(doc) { + if (doc.platform && !doc.platformUpdated) { + const platformPiece = await self.apos.doc.db.findOne({ + type: 'platforms', + uuid: doc.platform + }); + + if (platformPiece) { + doc.platform = platformPiece.title; + doc.platformUpdated = true; + } else { + throw self.apos.error('notfound', 'Platform not found'); + } + } + }, + + validateDocument(doc) { + const validateField = (data, schema) => { + const {error} = schema.validate(data); + if (error) { + const formattedErrors = error.details.map(detail => ({ + path: detail.path.join('.'), + message: detail.message.replace(/\"/g, "") + })); + throw self.apos.error('required', 'Validation failed', {error: formattedErrors}); + } + }; + validateField(doc, resourcesSchema); + } + } + }, + apiRoutes(self) { + return { + get: { + async all(req) { + + const currentUser = req.user; + const adminOrganization = currentUser.organization; + try { + const filters = { + organization: adminOrganization + }; + const resources = await self.find(req, filters).project(projection).toArray(); + return resources; + } catch (error) { + throw self.apos.error('notfound', 'Resource not found'); + } + }, + async ':uuid/uuid'(req) { + const uuid = req.params.uuid; + + if (!( req.user.organization)) { + throw self.apos.error('forbidden', 'You do not have permission to perform this action'); + } + const currentUser = req.user; + const adminOrganization = currentUser.organization; + + try { + const doc = await self.find(req, { uuid: uuid , organization:adminOrganization}).project(projection).toObject(); + if (!doc) { + throw self.apos.error('notfound', 'Resource not found'); + } + + return doc; + } catch (error) { + throw self.apos.error(error.name, error.message); + } + }, + async 'candidates'(req) { + + if (!( req.user.organization)) { + throw self.apos.error('forbidden', 'You do not have permission to perform this action'); + } + + try { + + const message = await exn.get_cloud_candidates() + return _.map(JSON.parse(message.body), (r)=>{ + return { + id: r.nodeId, + region: r.location.name, + instanceType: r.hardware.name, + virtualCores: r.hardware.cores, + memory: r.hardware.ram + } + }) + + + } catch (error) { + console.error(error) + throw self.apos.error(500, error); + } + } + }, + delete: { + async ':uuid/uuid'(req) { + const uuid = req.params.uuid; + + if (!uuid) { + throw self.apos.error('invalid', 'UUID is required'); + } + if (!(req.user.role === "admin" && req.user.organization)) { + throw self.apos.error('forbidden', 'You do not have permission to perform this action'); + } + + const currentUser = req.user; + const adminOrganization = currentUser.organization; + + try { + const filters = { + uuid: uuid, + organization: adminOrganization + }; + + //Νo refactor here because we need both docs. + const docs = await self.apos.db.collection('aposDocs').find({ uuid: uuid, organization:adminOrganization }).toArray(); + + if (!docs || docs.length === 0) { + throw self.apos.error('notfound', 'Resource not found'); + } + + for (const doc of docs) { + if (doc.organization !== adminOrganization) { + throw self.apos.error('forbidden', 'Access denied'); + } + + await self.apos.db.collection('aposDocs').deleteOne({ uuid: doc.uuid }); + } + + return { status: 'success', message: 'Resource deleted successfully' }; + } catch (error) { + throw self.apos.error(error.name, error.message); + } + } + }, + patch: { + async ':uuid/uuid'(req) { + const uuid = req.params.uuid; + const updateData = req.body; + + if (!(req.user.role === "admin" && req.user.organization)) { + throw self.apos.error('forbidden', 'You do not have permission to perform this action'); + } + self.validateDocument(updateData); + + const adminOrganization = req.user.organization; + + try { + const filters = { + uuid: uuid, + organization: adminOrganization + }; + const resourcesToUpdate = await self.find(req, filters).project(projection).toArray(); + + if (!resourcesToUpdate || resourcesToUpdate.length === 0) { + throw self.apos.error('notfound', 'Resource not found'); + } + + const doc = resourcesToUpdate[0]; + + + if ('platform' in updateData) { + let docToUpdate = { ...doc, ...updateData }; + await self.updateWithPlatformInfo(docToUpdate); + + await self.apos.doc.db.updateOne( + { uuid: uuid }, + { $set: docToUpdate } + ); + } else { + await self.apos.doc.db.updateOne( + { uuid: uuid }, + { $set: updateData } + ); + } + + const resourceUpdated = await self.find(req, filters).project(projection).toArray(); + + return resourceUpdated; + } catch (error) { + throw self.apos.error(error.name, error.message); + } + } + } + } + } +}; diff --git a/modules/swagger/index.js b/modules/swagger/index.js new file mode 100644 index 0000000..3c68054 --- /dev/null +++ b/modules/swagger/index.js @@ -0,0 +1,27 @@ +const swaggerJSDoc = require('swagger-jsdoc'); +const swaggerUi = require('swagger-ui-express'); +const path = require('path'); + +module.exports = { + init(self) { + const swaggerDefinition = { + openapi: '3.0.0', + info: { + title: 'NebulOus API', + version: '1.0.0', + description: 'Documentation for NebulOus API', + } + }; + + const swaggerOptions = { + swaggerDefinition, + apis: [ + path.join(__dirname, 'swagger.yml'), + './modules/**/*.js', + ], + }; + const swaggerSpec = swaggerJSDoc(swaggerOptions); + + self.apos.app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec)); + } +}; diff --git a/modules/swagger/swagger.yml b/modules/swagger/swagger.yml new file mode 100644 index 0000000..ea9f4c5 --- /dev/null +++ b/modules/swagger/swagger.yml @@ -0,0 +1,759 @@ +swagger: '2.0' +info: + version: '1.0.0' + title: 'ApostropheCMS API' + description: API for managing various functionalities in ApostropheCMS, including user authentication, application management, and mathematical expression parsing. +tags: + - name: Authentication + description: Endpoints related to user authentication + - name: Application Management + description: Endpoints for managing Applications + - name: Math Parser + description: Endpoints for parsing mathematical expressions + - name: YAML Parser + description: Endpoints for processing YAML content and finding keys + - name: Resources + description: Endpoints for managing Resources +paths: + /api/v1/@apostrophecms/login/login: + post: + tags: + - Authentication + summary: Login to obtain a bearer token + description: Authenticates user credentials and provides a bearer token. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - username + - password + properties: + username: + type: string + password: + type: string + responses: + 200: + description: Successfully authenticated + content: + application/json: + schema: + type: object + properties: + token: + type: string + example: 'random123Token456xyz' + /api/v1/application/: + get: + tags: + - Application Management + summary: Retrieve All Applications + description: Retrieves a list of all applications. + responses: + 200: + description: Successfully retrieved list of applications. + 500: + description: Server error. + post: + tags: + - Application Management + summary: Create a New Application + description: Creates a new application. + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: uuid + in: query + required: false + schema: + type: string + - name: content + in: query + required: false + schema: + type: string + - name: variables + in: query + required: false + schema: + type: object + properties: + name: + type: string + lowerValue: + type: number + format: float + higherValue: + type: number + format: float + - name: constants + in: query + required: false + schema: + type: object + properties: + name: + type: string + number: + type: number + format: float + - name: Providers + in: query + required: false + schema: + type: object + properties: + name: + type: string + platform: + type: string + enabled: + type: boolean + - name: metrics + in: query + required: false + schema: + type: object + properties: + type: + type: string + enum: + - composite + - raw + nameResult: + type: string + isWindowResult: + type: string + outputResult: + type: string + nameComposite: + type: string + formula: + type: string + isWindow: + type: boolean + isWindowType: + type: string + enum: + - all + - sliding + interval: + type: integer + unit: + type: string + enum: + - ms + - sec + - min + - hour + - day + outputType: + type: string + enum: + - all + - sliding + outputInterval: + type: integer + outputUnit: + type: string + enum: + - ms + - sec + - min + - hour + - day + nameRaw: + type: string + sensor: + type: string + config: + type: array + items: + type: object + properties: + config1: + type: string + config2: + type: string + - name: sloViolations + in: query + required: false + schema: + type: object + properties: + LogicalOperator: + type: string + label: Logical Operator + enum: + - and + - or + - not + Name: + type: string + label: Name + Operator: + type: string + label: Operator + enum: + - ">" + - "<" + - "<=" + - ">=" + - "==" + - "!==" + Value: + type: integer + label: Value + SubExpressionsLogicalOperator: + type: string + label: Logical Operator (Sub Expressions) + enum: + - and + - or + - not + SubExpressionsName: + type: string + label: Name (Sub Expressions) + SubExpressionsOperator: + type: string + label: Operator (Sub Expressions) + enum: + - ">" + - "<" + - "<=" + - ">=" + - "==" + - "!==" + SubExpressionsValue: + type: integer + label: Value (Sub Expressions) + - name: utilityFunctions + in: query + required: false + schema: + type: object + properties: + functionName: + type: string + label: Function Name + functionType: + type: string + label: Function Type + enum: + - maximize + - constant + functionDetails: + type: string + label: Function Details + functionExpression: + type: string + label: Function Expression + functionExpressionVariables: + type: array + items: + type: object + properties: + nameVariable: + type: string + valueVariable: + type: string + + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + uuid: + type: string + readOnly: true + content: + type: string + variables: + type: array + items: + type: object + properties: + name: + type: string + firstNumber: + type: number + format: float + secondNumber: + type: number + format: float + constants: + type: array + items: + type: object + properties: + name: + type: string + number: + type: number + format: float + providers: + type: array + items: + type: object + properties: + name: + type: string + platform: + type: string + enabled: + type: boolean + metrics: + type: array + items: + type: object + properties: + type: + type: string + enum: + - composite + - raw + nameResult: + type: string + isWindowResult: + type: string + outputResult: + type: string + nameComposite: + type: string + formula: + type: string + isWindow: + type: boolean + isWindowType: + type: string + enum: + - all + - sliding + interval: + type: integer + unit: + type: string + enum: + - ms + - sec + - min + - hour + - day + output: + type: object + properties: + typeOutput: + type: string + enum: + - all + - sliding + intervalOutput: + type: integer + unitOoutput: + type: string + enum: + - ms + - sec + - min + - hour + - day + nameRaw: + type: string + sensor: + type: string + config: + type: array + items: + type: object + properties: + config1: + type: string + config2: + type: string + sloViolations: + type: array + items: + type: object + properties: + logicalOperator: + type: string + enum: + - and + - or + - not + name: + type: string + operator: + type: string + enum: + - ">" + - "<" + - "<=" + - ">=" + - "==" + - "!==" + value: + type: integer + subExpressions: + type: array + items: + type: object + properties: + logicalOperator: + type: string + enum: + - and + - or + - not + name: + type: string + operator: + type: string + enum: + - ">" + - "<" + - "<=" + - ">=" + - "==" + - "!==" + value: + type: integer + utilityFunctions: + type: array + items: + type: object + properties: + functionName: + type: string + label: Function Name + functionType: + type: string + label: Function Type + enum: + - maximize + - constant + functionDetails: + type: string + label: Function Details + textarea: true + functionExpression: + type: string + label: Function Expression + textarea: true + functionExpressionVariables: + type: array + items: + type: object + properties: + nameVariable: + type: string + valueVariable: + type: string + responses: + 201: + description: Successfully created a new application. + 400: + description: Invalid data format. + 500: + description: Server error. + /api/v1/application/{uuid}: + get: + tags: + - Application Management + summary: Retrieve a Specific Application + description: Retrieves a specific application by its UUID. + parameters: + - name: uuid + in: path + required: true + schema: + type: string + responses: + 200: + description: Successfully retrieved the application. + 404: + description: Application not found. + 500: + description: Server error. + delete: + tags: + - Application Management + summary: Delete a Specific Application + description: Deletes a specific application by its UUID. + parameters: + - name: UUID + in: path + required: true + schema: + type: string + responses: + 200: + description: Successfully deleted the application. + 404: + description: Application not found. + 500: + description: Server error. + + + /api/v1/mathparser/expression: + post: + tags: + - Math Parser + summary: Parses a mathematical equation and extracts variable names. + description: Receives a mathematical equation in string format and returns the names of variables used in the equation. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + equation: + type: string + description: The mathematical equation to be parsed. + example: "x^2 + y - 3" + responses: + 200: + description: A list of variable names found in the equation. + content: + application/json: + schema: + type: object + properties: + variables: + type: array + items: + type: string + description: Names of variables in the equation. + 400: + description: Error message if the equation is missing or invalid. + 500: + description: Internal server error. + /api/v1/kubevela/keys: + post: + tags: + - YAML Parser + summary: Processes YAML content and finds keys based on an optional query. + description: | + This endpoint accepts a string of YAML content and an optional query string. + It processes the YAML to extract a flat list of keys that match the query. + The query filters keys based on the provided string, returning only those that start with the query. + It returns a list of complete key paths without array indices. + parameters: + - in: query + name: q + required: false + description: Optional query string to filter the keys. It should be a dot-separated path prefix to filter the keys in the YAML content. + schema: + type: string + example: "spec.comp" + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - content + properties: + content: + type: string + description: YAML content in string format. + example: | + apiVersion: core.oam.dev/v1beta1 + kind: Application + metadata: + name: velaux + namespace: vela-system + spec: + components: + - name: namespace + type: k8s-objects + properties: + objects: + - apiVersion: v1 + kind: Namespace + metadata: + name: my-namespace + responses: + 200: + description: Successfully processed the YAML content and returns a list of matching keys. + content: + application/json: + schema: + type: object + properties: + keys: + type: array + items: + type: string + description: List of keys found in the YAML content matching the query. Keys are complete paths without array indices. + 400: + description: Bad request, returned when YAML content is not provided or is invalid. + 404: + description: Not found, returned when no matching keys are found based on the provided query. + 500: + description: Internal server error. + /api/v1/userapi/all: + get: + tags: + - Authentication + summary: Get All Users + description: Retrieve a list of all users. + responses: + 200: + description: Successful operation. Returns a list of users. + 403: + description: Insufficient permissions. + 500: + description: Server error. + + /api/v1/userapi/create-user: + post: + tags: + - Authentication + summary: Create a User + description: Create a new user. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + username: + type: string + password: + type: string + email: + type: string + required: + - username + - password + - email + responses: + 200: + description: User created successfully. + 400: + description: Invalid or missing required fields. + 403: + description: Insufficient permissions. + 500: + description: Server error. + /api/v1/userapi/{id}: + delete: + tags: + - Authentication + summary: Delete a User + description: Delete a user by their ID. + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + 200: + description: User deleted successfully. + 403: + description: Insufficient permissions. + 404: + description: User not found. + 500: + description: Server error. + /api/v1/resources: + get: + tags: + - Resources + summary: Get all Resources + description: Retrieve a list of all Resources. + responses: + 200: + description: Array of Resources. + 500: + description: Server error. + + post: + tags: + - Resources + summary: Create a new Resource + description: Add a new Resource to the system. + parameters: + - in: body + name: Resources + description: Resources object + required: true + schema: + $ref: '#/definitions/Resources' + responses: + 200: + description: Resource created successfully. + 400: + description: Invalid input. + 500: + description: Server error. + + /api/v1/resources/{uuid}: + get: + tags: + - Resources + summary: Get a specific Resource + description: Retrieve a specific Resource by their UUID. + parameters: + - name: uuid + in: path + required: true + schema: + type: string + responses: + 200: + description: Resource details. + 404: + description: Resource not found. + 500: + description: Server error. + + delete: + tags: + - Resources + summary: Delete a Resource + description: Delete a specific Resource by their UUID. + parameters: + - name: uuid + in: path + required: true + schema: + type: string + responses: + 200: + description: Resource deleted successfully. + 404: + description: Resource not found. + 500: + description: Server error. + +definitions: + Resources: + type: object + required: + - title + - platform + - appId + - appSecret + properties: + title: + type: string + platform: + type: string + appId: + type: string + appSecret: + type: string diff --git a/modules/userapi/index.js b/modules/userapi/index.js new file mode 100644 index 0000000..b73679e --- /dev/null +++ b/modules/userapi/index.js @@ -0,0 +1,285 @@ +const { v4: uuidv4 } = require('uuid'); + +module.exports = { + handlers(self) { + return { + beforeInsert: { + async generateUuid(req, doc, options) { + if (!doc.uuid) { + doc.uuid = uuidv4(); + } + } + }, + } + }, + apiRoutes(self) { + return { + get: { + async all(req) { + if (!self.apos.permission.can(req, 'admin')) { + throw self.apos.error('forbidden', 'Insufficient permissions'); + } + const currentUser = req.user; + const adminOrganization = currentUser.organization; + const filters = {}; + + filters.createdBy = currentUser._id; + if (adminOrganization) { + filters.organization = adminOrganization; + } + + try { + const users = await self.apos.user.find(req, filters).toArray(); + return users; + } catch (error) { + throw self.apos.error(error.name, error.message); + } + }, + async 'me'(req) { + const currentUser = req.user; + if (!currentUser) { + throw self.apos.error('forbidden', 'You must be logged in to access this information'); + } + + try { + const user = await self.apos.user.find(req, { uuid: currentUser.uuid }).toObject(); + + if (!user) { + throw self.apos.error('notfound', 'User not found'); + } + + return user; + } catch (error) { + throw self.apos.error(error.name, error.message); + } + }, + async ':uuid/uuid'(req) { + if (!self.apos.permission.can(req, 'admin')) { + throw self.apos.error('forbidden', 'Insufficient permissions'); + } + + const userId = req.params.uuid; + if (!userId) { + throw self.apos.error('invalid', 'User UUID is required'); + } + + const currentUser = req.user; + const adminOrganization = currentUser.organization; + + try { + const user = await self.apos.user.find(req, { uuid: userId, organization:adminOrganization }).toObject(); + + if (!user) { + throw self.apos.error('notfound', 'User not found'); + } + + if (user.organization !== adminOrganization) { + throw self.apos.error('forbidden', 'Access denied'); + } + + return user; + } catch (error) { + throw self.apos.error(error.name, error.message); + } + }, + }, + post: { + async 'create-user'(req) { + + if (!self.apos.permission.can(req, 'admin')) { + throw self.apos.error('forbidden', 'Insufficient permissions'); + } + + const currentUser = req.user; + const adminOrganization = currentUser.organization; + + const {username, password, email, firstname, lastname} = req.body; + if (!username || !password || !email) { + throw self.apos.error('invalid', 'Missing required fields'); + } + + try { + const username = req.body.username; + const userData = { + username: username, + title: username, + password: password, + email: email, + firstname: firstname, + lastname: lastname, + uuid: uuidv4(), + role: 'editor', + slug: `user-${username}`, + createdBy: currentUser._id, + organization: adminOrganization, + }; + + const user = await self.apos.user.insert(req, userData); + + return user; + } catch (error) { + throw self.apos.error('invalid', error.message); + } + } + }, + delete: { + async ':uuid/uuid'(req) { + const userId = req.params.uuid; + + if (!userId) { + throw self.apos.error('invalid', 'User UUID is required'); + } + + const currentUser = req.user; + const adminOrganization = currentUser.organization; + const userToDelete = await self.apos.user.find(req, {uuid: userId, organization:adminOrganization}).toObject(); + + if (!userToDelete) { + throw self.apos.error('notfound', 'User not found'); + } + + if (userToDelete.organization !== adminOrganization) { + throw self.apos.error('forbidden', 'Access denied'); + } + + if (!self.apos.permission.can(req, 'admin') && userToDelete.createdBy !== currentUser.uuid) { + throw self.apos.error('forbidden', 'You do not have permission to perform this action.'); + } + + try { + await self.apos.doc.db.deleteOne({uuid: userId}); + + if (self.apos.db.collection('aposUsersSafe')) { + await self.apos.db.collection('aposUsersSafe').deleteOne({uuid: userId}); + } + + return {message: 'User deleted successfully'}; + } catch (error) { + throw self.apos.error(error.name, error.message); + } + } + }, + patch: { + async 'profile-update'(req) { + const currentUser = req.user; + const updateData = req.body; + + try { + const updateFields = { ...updateData }; + delete updateFields.password; + + if (updateFields.username) { + updateFields.title = updateFields.username; + updateFields.slug = `user-${updateFields.username}`; + + await self.apos.doc.db.updateOne( + {uuid: currentUser.uuid}, + { + $set: { + username: updateFields.username, + title: updateFields.username, + slug: `user-${updateFields.username}` + } + } + ); + } + // Update other fields in aposDocs + if (Object.keys(updateFields).length > 0) { + await self.apos.doc.db.updateOne( + { uuid: currentUser.uuid }, + { $set: updateFields } + ); + } + + await self.apos.db.collection('aposUserSafe').updateOne( + { _id: currentUser._id }, + { $set: { username: updateData.username } } + ); + + if (updateData.password) { + const newPassword = updateData.password; + + const hashedPassword = await self.apos.user.hashPassword(newPassword); + + await self.apos.db.collection('aposUserSafe').updateOne( + { _id: currentUser._id }, + { $set: { password: hashedPassword } } + ); + } + + const updatedUser = await self.apos.user.find(req, { uuid: currentUser.uuid }).toObject(); + + return updatedUser; + } catch (error) { + throw self.apos.error(error.name, error.message); + } + }, + async ':uuid/uuid'(req) { + const userId = req.params.uuid; + const updateData = req.body; + + if (!self.apos.permission.can(req, 'admin')) { + throw self.apos.error('forbidden', 'Insufficient permissions'); + } + + const currentUser = req.user; + const adminOrganization = currentUser.organization; + + try { + + const updateFields = { ...updateData }; + delete updateFields.password; + + const userToUpdate = await self.apos.user.find(req, { uuid: userId, organization:adminOrganization}).toObject(); + if (!userToUpdate) { + throw self.apos.error('notfound', 'User not found'); + } + if (userToUpdate.organization !== adminOrganization) { + throw self.apos.error('forbidden', 'You can only update users within your organization'); + } + + if (updateFields.username) { + updateFields.title = updateFields.username; + updateFields.slug = `user-${updateFields.username}`; + + await self.apos.doc.db.updateOne( + { uuid: userId }, + { $set: { username: updateFields.username, title: updateFields.username, slug: `user-${updateFields.username}` } } + ); + + await self.apos.db.collection('aposUserSafe').updateOne( + { _id: userToUpdate._id }, + { $set: { username: updateFields.username } } + ); + } + + if (Object.keys(updateFields).length > 0) { + await self.apos.doc.db.updateOne( + { uuid: userId }, + { $set: updateFields } + ); + } + if (updateData.password) { + const newPassword = updateData.password; + + const hashedPassword = await self.apos.user.hashPassword(newPassword); + + await self.apos.db.collection('aposUserSafe').updateOne( + { _id: userToUpdate._id }, + { $set: { password: hashedPassword } } + ); + } + + const updatedUser = await self.apos.user.find(req, { uuid: userId }).toObject(); + + return updatedUser; + + } catch (error) { + throw self.apos.error(error.name, error.message); + } + } + } + + }; + } +}; \ No newline at end of file diff --git a/noxfile.py b/noxfile.py new file mode 100644 index 0000000..04fe980 --- /dev/null +++ b/noxfile.py @@ -0,0 +1,36 @@ +import nox + +nox.options.error_on_external_run = True +nox.options.reuse_existing_virtualenvs = True + +YAML_PATHS = [ + "./zuul.d/", +] + +PYTHON_PATHS = [ + "./noxfile.py", +] + + +@nox.session +def black(session): + session.install("black") + session.run("black", *PYTHON_PATHS) + + +@nox.session +def isort(session): + session.install("isort") + session.run("isort", "--profile=black", *PYTHON_PATHS) + + +@nox.session +def linters(session): + session.install("yamllint") + session.install("black") + session.install("isort") + session.install("flake8") + session.run("yamllint", *YAML_PATHS) + session.run("black", "--check", *PYTHON_PATHS) + session.run("isort", "--profile=black", "--check", *PYTHON_PATHS) + session.run("flake8", *PYTHON_PATHS) diff --git a/package.json b/package.json new file mode 100644 index 0000000..04aa088 --- /dev/null +++ b/package.json @@ -0,0 +1,61 @@ +{ + "name": "apos-app", + "version": "1.0.0", + "description": "Apostrophe 3 Essential Starter Kit Site", + "main": "app.js", + "scripts": { + "start": "node app", + "dev": "nodemon", + "build": "bash ./scripts/heroku-release-tasks", + "serve": "NODE_ENV=production node app", + "release": "npm install && npm run build && node app @apostrophecms/migration:migrate" + }, + "nodemonConfig": { + "delay": 1000, + "verbose": true, + "watch": [ + "./app.js", + "./modules/**/*", + "./lib/**/*.js", + "./views/**/*.html" + ], + "ignoreRoot": [ + ".git" + ], + "ignore": [ + "**/ui/apos/", + "**/ui/src/", + "**/ui/public/", + "locales/*.json", + "public/uploads/", + "public/apos-frontend/*.js", + "data/" + ], + "ext": "json, js, html, scss, vue" + }, + "repository": { + "type": "git", + "url": "https://github.com/apostrophecms/starter-kit-essentials" + }, + "author": "Apostrophe Technologies, Inc.", + "license": "MIT", + "dependencies": { + "@johmun/vue-tags-input": "^2.1.0", + "apostrophe": "^3.61.1", + "flat": "^5.0.2", + "joi": "^17.11.0", + "mathjs": "^12.2.1", + "normalize.css": "^8.0.1", + "rhea": "^3.0.2", + "slugify": "^1.6.6", + "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^5.0.0", + "uuid": "^9.0.1", + "yaml": "^2.3.4" + }, + "devDependencies": { + "eslint": "^8.0.0", + "eslint-config-apostrophe": "^4.0.0", + "nodemon": "^3.0.1" + } +} diff --git a/public/css/master-anon-clrghj9c60000fzwdfdwkrwpn.less b/public/css/master-anon-clrghj9c60000fzwdfdwkrwpn.less new file mode 100644 index 0000000..eb50441 --- /dev/null +++ b/public/css/master-anon-clrghj9c60000fzwdfdwkrwpn.less @@ -0,0 +1,10 @@ +@import '../modules/apostrophe-assets/css/vendor/jquery-ui.less'; +@import '../modules/apostrophe-assets/css/vendor/pikaday.less'; +@import '../modules/apostrophe-login/css/always.less'; +@import '../modules/apostrophe-ui/css/always.less'; +@import '../modules/apostrophe-ui/css/vendor/font-awesome/font-awesome.less'; +@import '../modules/apostrophe-modal/css/user.less'; +@import '../modules/apostrophe-oembed/css/always.less'; +@import '../modules/apostrophe-areas/css/user.less'; +@import '../modules/apostrophe-pieces-widgets/css/always.less'; +@import '../modules/apostrophe-images-widgets/css/always.less'; \ No newline at end of file diff --git a/public/css/master-user-clrghj9c60000fzwdfdwkrwpn.less b/public/css/master-user-clrghj9c60000fzwdfdwkrwpn.less new file mode 100644 index 0000000..05881b3 --- /dev/null +++ b/public/css/master-user-clrghj9c60000fzwdfdwkrwpn.less @@ -0,0 +1,30 @@ +@import '../modules/apostrophe-assets/css/vendor/jquery-ui.less'; +@import '../modules/apostrophe-assets/css/vendor/pikaday.less'; +@import '../modules/apostrophe-assets/css/vendor/cropper.less'; +@import '../modules/apostrophe-assets/css/vendor/spectrum.less'; +@import '../modules/apostrophe-login/css/always.less'; +@import '../modules/apostrophe-login/css/user.less'; +@import '../modules/apostrophe-notifications/css/user.less'; +@import '../modules/apostrophe-ui/css/always.less'; +@import '../modules/apostrophe-ui/css/vendor/font-awesome/font-awesome.less'; +@import '../modules/apostrophe-ui/css/user.less'; +@import '../modules/apostrophe-schemas/css/user.less'; +@import '../modules/apostrophe-jobs/css/user.less'; +@import '../modules/apostrophe-versions/css/user.less'; +@import '../modules/apostrophe-tags/css/user.less'; +@import '../modules/apostrophe-modal/css/user.less'; +@import '../modules/apostrophe-attachments/css/user.less'; +@import '../modules/apostrophe-oembed/css/always.less'; +@import '../modules/apostrophe-pager/css/user.less'; +@import '../modules/apostrophe-doc-type-manager/css/chooser.less'; +@import '../modules/apostrophe-pieces/css/manager.less'; +@import '../modules/apostrophe-polymorphic-manager/css/polymorphic-manager.less'; +@import '../modules/apostrophe-pages/css/jqtree.less'; +@import '../modules/apostrophe-pages/css/user.less'; +@import '../modules/apostrophe-areas/css/user.less'; +@import '../modules/apostrophe-rich-text-widgets/css/user.less'; +@import '../modules/apostrophe-video-fields/css/user.less'; +@import '../modules/apostrophe-images/css/user.less'; +@import '../modules/apostrophe-images-widgets/css/user.less'; +@import '../modules/apostrophe-pieces-widgets/css/always.less'; +@import '../modules/apostrophe-images-widgets/css/always.less'; \ No newline at end of file diff --git a/public/images/logo.png b/public/images/logo.png new file mode 100644 index 0000000..68ddd4f Binary files /dev/null and b/public/images/logo.png differ diff --git a/scripts/build-assets.sh b/scripts/build-assets.sh new file mode 100755 index 0000000..c84e53c --- /dev/null +++ b/scripts/build-assets.sh @@ -0,0 +1,7 @@ +#!/bin/sh + +export APOS_RELEASE_ID=`cat /dev/urandom |env LC_CTYPE=C tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1` + +echo $APOS_RELEASE_ID > ./release-id + +node app @apostrophecms/asset:build \ No newline at end of file diff --git a/views/layout.html b/views/layout.html new file mode 100644 index 0000000..5d2a914 --- /dev/null +++ b/views/layout.html @@ -0,0 +1,27 @@ +{# Automatically extends the right outer layout and also handles AJAX siutations #} +{% extends data.outerLayout %} + +{% set title = data.piece.title or data.page.title %} +{% block title %} + {{ title }} + {% if not title %} + {{ apos.log('Looks like you forgot to override the title block in a template that does not have access to an Apostrophe page or piece.') }} + {% endif %} +{% endblock %} + +{% block beforeMain %} + +{% endblock %} + +{% block main %} + {# + Usually, your page templates in the @apostrophecms/pages module will override + this block. It is safe to assume this is where your page-specific content + should go. + #} +{% endblock %} + +{% block afterMain %} + + +{% endblock %} diff --git a/zuul.d/jobs.yaml b/zuul.d/jobs.yaml new file mode 100644 index 0000000..15f15a6 --- /dev/null +++ b/zuul.d/jobs.yaml @@ -0,0 +1,72 @@ +- job: + name: nebulous-gui-controller-build-container-images + parent: nebulous-build-container-images + dependencies: + - name: opendev-buildset-registry + soft: false + provides: + - nebulous-gui-controller-container-images + description: Build the container images. + files: &image_files + - ^java-spring-boot-demo/ + vars: &image_vars + promote_container_image_job: nebulous-gui-controller-upload-container-images + container_images: + - context: java-spring-boot-demo + registry: quay.io + repository: quay.io/nebulous/gui-controller-java-spring-boot-demo + namespace: nebulous + repo_shortname: gui-controller-java-spring-boot-demo + repo_description: "" + +- job: + name: nebulous-gui-controller-upload-container-images + parent: nebulous-upload-container-images + dependencies: + - name: opendev-buildset-registry + soft: false + provides: + - nebulous-gui-controller-container-images + description: Build and upload the container images. + files: *image_files + vars: *image_vars + +- job: + name: nebulous-gui-controller-promote-container-images + parent: nebulous-promote-container-images + description: Promote previously uploaded container images. + files: *image_files + vars: *image_vars + +- job: + name: nebulous-gui-controller-hadolint + parent: nebulous-hadolint + description: Run Hadolint on Dockerfile(s). + vars: + dockerfiles: + - java-spring-boot-demo/Dockerfile + +- job: + name: nebulous-gui-controller-helm-lint + parent: nebulous-helm-lint + description: Run helm lint on Helm charts. + vars: + helm_charts: + - ./charts/nebulous-gui-controller + +- job: + name: nebulous-gui-controller-apply-helm-charts + parent: nebulous-apply-helm-charts + dependencies: + - name: opendev-buildset-registry + soft: false + - name: nebulous-gui-controller-build-container-images + soft: true + - name: nebulous-gui-controller-upload-container-images + soft: true + requires: + - nebulous-gui-controller-container-images + description: Deploy a Kubernetes cluster and apply charts. + vars: + helm_charts: + nebulous-gui-controller: ./charts/nebulous-gui-controller diff --git a/zuul.d/project.yaml b/zuul.d/project.yaml new file mode 100644 index 0000000..8a9c483 --- /dev/null +++ b/zuul.d/project.yaml @@ -0,0 +1,20 @@ +- project: + check: + jobs: + - opendev-buildset-registry + - nebulous-gui-controller-helm-lint + - nebulous-gui-controller-build-container-images + - nebulous-gui-controller-hadolint + - nebulous-platform-apply-helm-charts + - nox-linters + gate: + jobs: + - opendev-buildset-registry + - nebulous-gui-controller-helm-lint + - nebulous-gui-controller-upload-container-images + - nebulous-gui-controller-hadolint + - nebulous-platform-apply-helm-charts + - nox-linters + promote: + jobs: + - nebulous-gui-controller-promote-container-images