Move commits from `kubeflow/kubeflow`

This commit is contained in:
Mathew Wicks 2025-08-18 09:44:51 -07:00
commit 290935a205
No known key found for this signature in database
GPG Key ID: BB7E8DAF23B8B964
1041 changed files with 207048 additions and 0 deletions

3
components/common/go.mod Normal file
View File

@ -0,0 +1,3 @@
module github.com/kubeflow/kubeflow/components/common
go 1.12

View File

@ -0,0 +1,219 @@
package reconcile
import (
"context"
"reflect"
"github.com/go-logr/logr"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
apierrs "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
)
// Deployment reconciles a k8s deployment object.
func Deployment(ctx context.Context, r client.Client, deployment *appsv1.Deployment, log logr.Logger) error {
foundDeployment := &appsv1.Deployment{}
justCreated := false
if err := r.Get(ctx, types.NamespacedName{Name: deployment.Name, Namespace: deployment.Namespace}, foundDeployment); err != nil {
if apierrs.IsNotFound(err) {
log.Info("Creating Deployment", "namespace", deployment.Namespace, "name", deployment.Name)
if err := r.Create(ctx, deployment); err != nil {
log.Error(err, "unable to create deployment")
return err
}
justCreated = true
} else {
log.Error(err, "error getting deployment")
return err
}
}
if !justCreated && CopyDeploymentSetFields(deployment, foundDeployment) {
log.Info("Updating Deployment", "namespace", deployment.Namespace, "name", deployment.Name)
if err := r.Update(ctx, foundDeployment); err != nil {
log.Error(err, "unable to update deployment")
return err
}
}
return nil
}
// Service reconciles a k8s service object.
func Service(ctx context.Context, r client.Client, service *corev1.Service, log logr.Logger) error {
foundService := &corev1.Service{}
justCreated := false
if err := r.Get(ctx, types.NamespacedName{Name: service.Name, Namespace: service.Namespace}, foundService); err != nil {
if apierrs.IsNotFound(err) {
log.Info("Creating Service", "namespace", service.Namespace, "name", service.Name)
if err = r.Create(ctx, service); err != nil {
log.Error(err, "unable to create service")
return err
}
justCreated = true
} else {
log.Error(err, "error getting service")
return err
}
}
if !justCreated && CopyServiceFields(service, foundService) {
log.Info("Updating Service\n", "namespace", service.Namespace, "name", service.Name)
if err := r.Update(ctx, foundService); err != nil {
log.Error(err, "unable to update Service")
return err
}
}
return nil
}
// VirtualService reconciles an Istio virtual service object.
func VirtualService(ctx context.Context, r client.Client, virtualServiceName, namespace string, virtualservice *unstructured.Unstructured, log logr.Logger) error {
foundVirtualService := &unstructured.Unstructured{}
foundVirtualService.SetAPIVersion("networking.istio.io/v1alpha3")
foundVirtualService.SetKind("VirtualService")
justCreated := false
if err := r.Get(ctx, types.NamespacedName{Name: virtualServiceName, Namespace: namespace}, foundVirtualService); err != nil {
if apierrs.IsNotFound(err) {
log.Info("Creating virtual service", "namespace", namespace, "name", virtualServiceName)
if err := r.Create(ctx, virtualservice); err != nil {
log.Error(err, "unable to create virtual service")
return err
}
justCreated = true
} else {
log.Error(err, "error getting virtual service")
return err
}
}
if !justCreated && CopyVirtualService(virtualservice, foundVirtualService) {
log.Info("Updating virtual service", "namespace", namespace, "name", virtualServiceName)
if err := r.Update(ctx, foundVirtualService); err != nil {
log.Error(err, "unable to update virtual service")
return err
}
}
return nil
}
// Reference: https://github.com/pwittrock/kubebuilder-workshop/blob/master/pkg/util/util.go
// CopyStatefulSetFields copies the owned fields from one StatefulSet to another
// Returns true if the fields copied from don't match to.
func CopyStatefulSetFields(from, to *appsv1.StatefulSet) bool {
requireUpdate := false
for k, v := range to.Labels {
if from.Labels[k] != v {
requireUpdate = true
}
}
to.Labels = from.Labels
for k, v := range to.Annotations {
if from.Annotations[k] != v {
requireUpdate = true
}
}
to.Annotations = from.Annotations
if *from.Spec.Replicas != *to.Spec.Replicas {
*to.Spec.Replicas = *from.Spec.Replicas
requireUpdate = true
}
if !reflect.DeepEqual(to.Spec.Template.Spec, from.Spec.Template.Spec) {
requireUpdate = true
}
to.Spec.Template.Spec = from.Spec.Template.Spec
return requireUpdate
}
func CopyDeploymentSetFields(from, to *appsv1.Deployment) bool {
requireUpdate := false
for k, v := range to.Labels {
if from.Labels[k] != v {
requireUpdate = true
}
}
to.Labels = from.Labels
for k, v := range to.Annotations {
if from.Annotations[k] != v {
requireUpdate = true
}
}
to.Annotations = from.Annotations
if from.Spec.Replicas != to.Spec.Replicas {
to.Spec.Replicas = from.Spec.Replicas
requireUpdate = true
}
if !reflect.DeepEqual(to.Spec.Template.Spec, from.Spec.Template.Spec) {
requireUpdate = true
}
to.Spec.Template.Spec = from.Spec.Template.Spec
return requireUpdate
}
// CopyServiceFields copies the owned fields from one Service to another
func CopyServiceFields(from, to *corev1.Service) bool {
requireUpdate := false
for k, v := range to.Labels {
if from.Labels[k] != v {
requireUpdate = true
}
}
to.Labels = from.Labels
for k, v := range to.Annotations {
if from.Annotations[k] != v {
requireUpdate = true
}
}
to.Annotations = from.Annotations
// Don't copy the entire Spec, because we can't overwrite the clusterIp field
if !reflect.DeepEqual(to.Spec.Selector, from.Spec.Selector) {
requireUpdate = true
}
to.Spec.Selector = from.Spec.Selector
if !reflect.DeepEqual(to.Spec.Ports, from.Spec.Ports) {
requireUpdate = true
}
to.Spec.Ports = from.Spec.Ports
return requireUpdate
}
// Copy configuration related fields to another instance and returns true if there
// is a diff and thus needs to update.
func CopyVirtualService(from, to *unstructured.Unstructured) bool {
fromSpec, found, err := unstructured.NestedMap(from.Object, "spec")
if !found {
return false
}
if err != nil {
return false
}
toSpec, found, err := unstructured.NestedMap(to.Object, "spec")
if !found || err != nil {
unstructured.SetNestedMap(to.Object, fromSpec, "spec")
return true
}
requiresUpdate := !reflect.DeepEqual(fromSpec, toSpec)
if requiresUpdate {
unstructured.SetNestedMap(to.Object, fromSpec, "spec")
}
return requiresUpdate
}

View File

@ -0,0 +1,2 @@
**/node_modules/*
**/__pycache__/*

1
components/crud-web-apps/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
**/web-apps-dev

View File

@ -0,0 +1,6 @@
approvers:
- kimwnasptd
- thesuperzapper
emeritus_approvers:
- elikatsis
- StefanoFioravanzo

View File

@ -0,0 +1,170 @@
# Common code for CRUD web apps
Since our CRUD web apps like the Jupyter, Tensorboards and Volumes UIs are similarly build with Angular and Python/Flask we should factor the common code in to modules and libraries.
This directory will contain:
1. A Python package with a base backend. Each one of the mentioned apps are supposed to extend this backend.
2. An Angular library that will contain the common frontend code that these apps will be sharing
## Backend
The backend will be exposing a base backend which will be taking care of:
* Serving the Single Page Application
* Adding liveness/readiness probes
* Authentication based on the `kubeflow-userid` header
* Authorization using SubjectAccessReviews
* Uniform logging
* Exceptions handling
* Common helper functions for dates, yaml file parsing etc.
* Providing Prometheus metrics
### Supported ENV Vars
The following is a list of ENV var that can be set for any web app that is using this base app.
This is list is incomplete, we will be adding more variables in the future.
| ENV Var | Description |
| - | - |
| CSRF_SAMESITE | Controls the [SameSite value](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#SameSite) of the CSRF cookie |
| METRICS | Enable the exporting of Prometheus metrics on `/metrics` path |
### How to use
In order to use this code during the development process one could use the `-e` [flag](https://pip.pypa.io/en/stable/reference/pip_install/#install-editable) with `pip install`. For example:
```bash
# I'm currently in /components/crud-web-apps/volumes/backend
cd ../../common/backend && pip install -e .
```
This will install all the dependencies of the package and you will now be able to include code from `kubeflow.kubeflow.crud_backend` in you current Python environment.
In order to build a Docker image and use this code you coud build a wheel and then install it:
```dockerfile
### Docker
FROM python:3.7 AS backend-kubeflow-wheel
WORKDIR /src
COPY ./components/crud-web-apps/common/backend .
RUN python3 setup.py bdist_wheel
...
# Web App
FROM python:3.7
WORKDIR /package
COPY --from=backend-kubeflow-wheel /src .
RUN pip3 install .
...
```
### Metrics
The following metrics are exported:
flask_http_request_duration_seconds (Histogram)
flask_http_request_total (Counter)
flask_http_request_exceptions_total (Counter)
flask_exporter_info (Gauge)
For more information visit the [prometheus_flask_exporter](https://github.com/rycus86/prometheus_flask_exporter).
## Frontend
The common Angular library contains common code for:
* Communicating with the Central Dashboard to handle the Namespace selection
* Making http calls and handing their errors
* Surfacing errors and warnings
* Polling mechanisms
* Universal styling accross the different web apps
* Handling forms
### How to use
```bash
# build the common library
cd common/frontend/kubeflow-common-lib
npm i
npm run build
# for development link the created module to the app
cd dist/kubeflow
npm link
# navigate to the corresponding app's frontend
cd crud-web-apps/volumes/frontend
npm i
npm link kubeflow
```
### Common errors
```
NullInjectorError: StaticInjectorError(AppModule)[ApplicationRef -> NgZone]:
StaticInjectorError(Platform: core)[ApplicationRef -> NgZone]:
NullInjectorError: No provider for NgZone!
```
You also need to add `"preserveSymlinks": true` to the app's frontend `angular.json` at `projects.frontend.architect.build.options`.
### Docker
```dockerfile
# --- Build the frontend kubeflow library ---
FROM node:16 as frontend-kubeflow-lib
WORKDIR /src
COPY ./components/crud-web-apps/common/frontend/kubeflow-common-lib/package*.json ./
RUN npm install
COPY ./components/crud-web-apps/common/frontend/kubeflow-common-lib/ .
RUN npm run build
...
# --- Build the frontend ---
FROM node:16 as frontend
RUN npm install -g @angular/cli
WORKDIR /src
COPY ./components/crud-web-apps/volumes/frontend/package*.json ./
RUN npm install
COPY --from=frontend-kubeflow-lib /src/dist/kubeflow/ ./node_modules/kubeflow/
COPY ./components/crud-web-apps/volumes/frontend/ .
RUN npm run build -- --output-path=./dist/default --configuration=production
```
### Internationalization
Internationalization was implemented using [ngx-translate](https://github.com/ngx-translate/core).
This is based on the browser's language. If the browser detects a language that is not implemented in the application, it will default to English.
The i18n asset files are located under `frontend/src/assets/i18n` of each application (jupyter, volumes and tensorboard). One file is needed per language. The common project is duplicated in every asset.
The translation asset files are set in the `app.module.ts`, which should not be needed to modify.
The translation default language is set in the `app.component.ts`.
For each language added, `app.component.ts` will need to be updated.
**When a language is added:**
- Copy the en.json file and rename is to the language you want to add. As it currently is, the culture should not be included.
- Change the values to the translated ones
**When a translation is added or modified:**
- Choose an appropriate key
- Make sure to add the key in every language file
- If text is added/modified in the Common Project, it needs to be added/modified in the other applications as well.
**Testing**
To test the i18n works as expected, simply change your browser's language to whichever language you want to test.

View File

@ -0,0 +1,3 @@
kubeflow.egg-info
dist
build

View File

@ -0,0 +1,39 @@
import logging
from flask import Flask
from .authn import bp as authn_bp
from .config import BackendMode
from .csrf import bp as csrf_bp
from .errors import bp as errors_bp
from .metrics import enable_metrics
from .probes import bp as probes_bp
from .routes import bp as base_routes_bp
from .serving import bp as serving_bp
LOG_FORMAT = "%(asctime)s | %(name)s | %(levelname)s | %(message)s"
def create_app(name, static_folder, config):
logging.basicConfig(format=LOG_FORMAT, level=config.LOG_LEVEL)
log = logging.getLogger(__name__)
app = Flask(name, static_folder=static_folder)
app.config.from_object(config)
if (config.ENV == BackendMode.DEVELOPMENT.value
or config.ENV == BackendMode.DEVELOPMENT_FULL.value): # noqa: W503
log.warn("RUNNING IN DEVELOPMENT MODE")
# Register all the blueprints
app.register_blueprint(authn_bp)
app.register_blueprint(errors_bp)
app.register_blueprint(csrf_bp)
app.register_blueprint(probes_bp)
app.register_blueprint(serving_bp)
app.register_blueprint(base_routes_bp)
if config.METRICS:
enable_metrics(app)
return app

View File

@ -0,0 +1,11 @@
from .apis import * # noqa F401, F403
from .custom_resource import * # noqa F401, F403
from .namespace import * # noqa F401, F403
from .node import * # noqa F401, F403
from .notebook import * # noqa F401, F403
from .pod import * # noqa F401, F403
from .poddefault import * # noqa F401, F403
from .pvc import * # noqa F401, F403
from .secret import * # noqa F401, F403
from .storageclass import * # noqa F401, F403
from .utils import * # noqa F401, F403

View File

@ -0,0 +1,12 @@
from kubernetes import client, config
from kubernetes.config import ConfigException
try:
config.load_incluster_config()
except ConfigException:
config.load_kube_config()
# Create the Apis
v1_core = client.CoreV1Api()
custom_api = client.CustomObjectsApi()
storage_api = client.StorageV1Api()

View File

@ -0,0 +1,29 @@
from .. import authz
from . import custom_api
def create_custom_rsrc(group, version, kind, data, namespace):
authz.ensure_authorized("create", group, version, kind, namespace)
return custom_api.create_namespaced_custom_object(group, version,
namespace, kind, data)
def delete_custom_rsrc(group, version, kind, name, namespace,
policy="Foreground"):
authz.ensure_authorized("delete", group, version, kind, namespace)
return custom_api.delete_namespaced_custom_object(
group, version, namespace, kind, name, propagation_policy=policy
)
def list_custom_rsrc(group, version, kind, namespace):
authz.ensure_authorized("list", group, version, kind, namespace)
return custom_api.list_namespaced_custom_object(group, version, namespace,
kind)
def get_custom_rsrc(group, version, kind, namespace, name):
authz.ensure_authorized("get", group, version, kind, namespace)
return custom_api.get_namespaced_custom_object(group, version, namespace,
kind, name)

View File

@ -0,0 +1,12 @@
from .. import authz
from . import v1_core
def list_events(namespace, field_selector):
authz.ensure_authorized(
"list", "", "v1", "events", namespace
)
return v1_core.list_namespaced_event(
namespace=namespace, field_selector=field_selector
)

View File

@ -0,0 +1,7 @@
from .. import authz
from . import v1_core
@authz.needs_authorization("list", "core", "v1", "namespaces")
def list_namespaces():
return v1_core.list_namespace()

View File

@ -0,0 +1,5 @@
from . import v1_core
def list_nodes():
return v1_core.list_node()

View File

@ -0,0 +1,61 @@
from .. import authz
from . import custom_api, events, utils
def get_notebook(notebook, namespace):
authz.ensure_authorized(
"get", "kubeflow.org", "v1beta1", "notebooks", namespace
)
return custom_api.get_namespaced_custom_object(
"kubeflow.org", "v1beta1", namespace, "notebooks", notebook
)
def create_notebook(notebook, namespace, dry_run=False):
authz.ensure_authorized(
"create", "kubeflow.org", "v1beta1", "notebooks", namespace
)
return custom_api.create_namespaced_custom_object(
"kubeflow.org", "v1beta1", namespace, "notebooks", notebook,
dry_run="All" if dry_run else None)
def list_notebooks(namespace):
authz.ensure_authorized(
"list", "kubeflow.org", "v1beta1", "notebooks", namespace
)
return custom_api.list_namespaced_custom_object(
"kubeflow.org", "v1beta1", namespace, "notebooks"
)
def delete_notebook(notebook, namespace):
authz.ensure_authorized(
"delete", "kubeflow.org", "v1beta1", "notebooks", namespace
)
return custom_api.delete_namespaced_custom_object(
group="kubeflow.org",
version="v1beta1",
namespace=namespace,
plural="notebooks",
name=notebook,
propagation_policy="Foreground",
)
def patch_notebook(notebook, namespace, body):
authz.ensure_authorized(
"patch", "kubeflow.org", "v1beta1", "notebooks", namespace
)
return custom_api.patch_namespaced_custom_object(
"kubeflow.org", "v1beta1", namespace, "notebooks", notebook, body
)
def list_notebook_events(notebook, namespace):
field_selector = utils.events_field_selector("Notebook", notebook)
return events.list_events(namespace, field_selector)

View File

@ -0,0 +1,20 @@
from .. import authz
from . import v1_core
def list_pods(namespace, auth=True, label_selector=None):
if auth:
authz.ensure_authorized("list", "", "v1", "pods", namespace)
return v1_core.list_namespaced_pod(
namespace=namespace,
label_selector=label_selector)
def get_pod_logs(namespace, pod, container, auth=True):
if auth:
authz.ensure_authorized("get", "", "v1", "pods", namespace, "log")
return v1_core.read_namespaced_pod_log(
namespace=namespace, name=pod, container=container
)

View File

@ -0,0 +1,9 @@
from .. import authz
from . import custom_api
def list_poddefaults(namespace):
authz.ensure_authorized("list", "kubeflow.org", "v1alpha1", "poddefaults",
namespace)
return custom_api.list_namespaced_custom_object("kubeflow.org", "v1alpha1",
namespace, "poddefaults")

View File

@ -0,0 +1,49 @@
from .. import authz
from . import v1_core, utils, events
def create_pvc(pvc, namespace, dry_run=False):
authz.ensure_authorized(
"create", "", "v1", "persistentvolumeclaims", namespace
)
return v1_core.create_namespaced_persistent_volume_claim(
namespace, pvc, dry_run="All" if dry_run else None)
def delete_pvc(pvc, namespace):
authz.ensure_authorized(
"delete", "", "v1", "persistentvolumeclaims", namespace
)
return v1_core.delete_namespaced_persistent_volume_claim(pvc, namespace)
def list_pvcs(namespace):
authz.ensure_authorized(
"list", "", "v1", "persistentvolumeclaims", namespace
)
return v1_core.list_namespaced_persistent_volume_claim(namespace)
def get_pvc(pvc, namespace):
authz.ensure_authorized(
"get", "", "v1", "persistentvolumeclaims", namespace
)
return v1_core.read_namespaced_persistent_volume_claim(pvc, namespace)
def list_pvc_events(namespace, pvc_name):
field_selector = utils.events_field_selector(
"PersistentVolumeClaim", pvc_name)
return events.list_events(namespace, field_selector)
def patch_pvc(name, namespace, pvc, auth=True):
if auth:
authz.ensure_authorized("patch", "", "v1", "persistentvolumeclaims",
namespace)
return v1_core.patch_namespaced_persistent_volume_claim(name, namespace,
pvc)

View File

@ -0,0 +1,16 @@
from .. import authz
from . import v1_core
def get_secret(namespace, name, auth=True):
if auth:
authz.ensure_authorized("get", "", "v1", "secrets", namespace)
return v1_core.read_namespaced_secret(name, namespace)
def create_secret(namespace, secret, auth=True):
if auth:
authz.ensure_authorized("create", "", "v1", "secrets", namespace)
return v1_core.create_namespaced_secret(namespace, secret)

View File

@ -0,0 +1,11 @@
from . import storage_api
# @auth.needs_authorization("list", "storage.k8s.io", "v1", "storageclasses")
# NOTE(kimwnasptd): This function is only used from the backend in order to
# determine if a default StorageClass is set. Currently, the role aggregation
# does not use a ClusterRoleBinding, thus we can't currently give this
# permission to a user. The backend does not expose any endpoint that would
# allow an unauthorized user to list the storage classes using this function.
def list_storageclasses():
return storage_api.list_storage_class()

View File

@ -0,0 +1,48 @@
from flask import jsonify
from kubernetes import client
from .. import authn
def success_response(data_field=None, data=None):
user = authn.get_username()
resp = {"status": 200, "success": True, "user": user}
if data_field is None and data is None:
return jsonify(resp)
resp[data_field] = data
return jsonify(resp)
def failed_response(msg, error_code):
user = authn.get_username()
resp = {
"success": False,
"log": msg,
"status": error_code,
"user": user,
}
return resp, error_code
def events_field_selector(kind, name):
return "involvedObject.kind=%s,involvedObject.name=%s" % (kind, name)
def deserialize(json_obj, klass):
"""Convert a JSON object to a lib class object.
json_obj: The JSON object to deserialize
klass: The string name of the class i.e. V1Pod, V1Volume etc
"""
try:
return client.ApiClient()._ApiClient__deserialize(json_obj, klass)
except ValueError as e:
raise ValueError("Failed to deserialize input into '%s': %s"
% (klass, str(e)))
def serialize(obj):
"""Convert a K8s library object to JSON."""
return client.ApiClient().sanitize_for_serialization(obj)

View File

@ -0,0 +1,67 @@
import logging
from flask import Blueprint, current_app, request
from werkzeug.exceptions import Unauthorized
from . import config, settings
bp = Blueprint("authn", __name__)
log = logging.getLogger(__name__)
def get_username():
if settings.USER_HEADER not in request.headers:
log.debug("User header not present!")
username = None
else:
user = request.headers[settings.USER_HEADER]
username = user.replace(settings.USER_PREFIX, "")
log.debug("User: '%s' | Headers: '%s' '%s'",
username, settings.USER_HEADER, settings.USER_PREFIX)
return username
def no_authentication(func):
"""
This decorator will be used to disable the default authentication check
for the decorated endpoint.
"""
func.no_authentication = True
return func
@bp.before_app_request
def check_authentication():
"""
By default all the app's routes will be subject to authentication. If we
want a function to not have authentication check then we can decorate it
with the `no_authentication` decorator.
"""
if config.dev_mode_enabled():
log.debug("Skipping authentication check in development mode")
return
if settings.DISABLE_AUTH:
log.info("APP_DISABLE_AUTH set to True. Skipping authentication check")
return
# If a function was decorated with `no_authentication` then we will skip
# the authn check
if request.endpoint and getattr(
current_app.view_functions[request.endpoint],
"no_authentication",
False,
):
# when no return value is specified the designated route function will
# be called.
return
user = get_username()
if user is None:
# Return an unauthenticated response and don't call the route's
# assigned function.
raise Unauthorized("No user detected.")
else:
log.info("Handling request for user: %s", user)
return

View File

@ -0,0 +1,132 @@
import functools
import logging
from kubernetes import client
from kubernetes import config as k8s_config
from kubernetes.client.rest import ApiException
from kubernetes.config import ConfigException
from werkzeug.exceptions import Forbidden, Unauthorized
from . import authn, config, settings
log = logging.getLogger(__name__)
try:
# Load configuration inside the Pod
k8s_config.load_incluster_config()
except ConfigException:
# Load configuration for testing
k8s_config.load_kube_config()
# The API object for submitting SubjecAccessReviews
authz_api = client.AuthorizationV1Api()
def create_subject_access_review(user, verb, namespace, group, version,
resource, subresource):
"""
Create the SubjecAccessReview object which we will use to determine if the
user is authorized.
"""
return client.V1SubjectAccessReview(
spec=client.V1SubjectAccessReviewSpec(
user=user,
resource_attributes=client.V1ResourceAttributes(
group=group,
namespace=namespace,
verb=verb,
resource=resource,
version=version,
subresource=subresource,
),
)
)
def is_authorized(user, verb, group, version, resource, namespace=None,
subresource=None):
"""
Create a SubjectAccessReview to the K8s API to determine if the user is
authorized to perform a specific verb on a resource.
"""
# Skip authz check if in dev mode
if config.dev_mode_enabled():
log.debug("Skipping authorization check in development mode")
return True
# Skip authz check if admin explicitly requested it
if settings.DISABLE_AUTH:
log.info("APP_DISABLE_AUTH set to True. Skipping authorization check")
return True
if user is None:
log.warning("No user credentials were found! Make sure you have"
" correctly set the USERID_HEADER in the Web App's"
" deployment.")
raise Unauthorized(description="No user credentials were found!")
sar = create_subject_access_review(user, verb, namespace, group, version,
resource, subresource)
try:
obj = authz_api.create_subject_access_review(sar)
except ApiException as e:
log.error("Error submitting SubjecAccessReview: %s, %s", sar, e)
raise e
if obj.status is not None:
return obj.status.allowed
else:
log.error("SubjectAccessReview doesn't have status.")
return False
def generate_unauthorized_message(user, verb, group, version, resource,
subresource=None, namespace=None):
msg = "User '%s' is not authorized to %s" % (user, verb)
if group == "":
msg += " %s/%s" % (version, resource)
else:
msg += " %s/%s/%s" % (group, version, resource)
if subresource is not None:
msg += "/%s" % subresource
if namespace is not None:
msg += " in namespace '%s'" % namespace
return msg
def ensure_authorized(verb, group, version, resource, namespace=None,
subresource=None):
user = authn.get_username()
if not is_authorized(user, verb, group, version, resource,
namespace=namespace, subresource=subresource):
msg = generate_unauthorized_message(user, verb, group, version,
resource, subresource=subresource,
namespace=namespace)
raise Forbidden(description=msg)
def needs_authorization(verb, group, version, resource, namespace=None,
subresource=None):
"""
This function will serve as a decorator. It will be used to make sure that
the decorated function is authorized to perform the corresponding k8s api
verb on a specific resource.
"""
def wrapper(func):
@functools.wraps(func)
def runner(*args, **kwargs):
# Run the decorated function only if the user is authorized
ensure_authorized(verb, group, version, resource,
namespace=namespace, subresource=subresource)
return func(*args, **kwargs)
return runner
return wrapper

View File

@ -0,0 +1,66 @@
import enum
import logging
import os
from flask import current_app
log = logging.getLogger(__name__)
class BackendMode(enum.Enum):
DEVELOPMENT = "dev"
DEVELOPMENT_FULL = "development"
PRODUCTION = "prod"
PRODUCTION_FULL = "production"
def dev_mode_enabled():
env = current_app.config.get("ENV")
return (env == BackendMode.DEVELOPMENT_FULL.value or # noqa: W504
env == BackendMode.DEVELOPMENT.value)
def get_config(mode):
"""Return a config based on the selected mode."""
config_classes = {
BackendMode.DEVELOPMENT.value: DevConfig,
BackendMode.DEVELOPMENT_FULL.value: DevConfig,
BackendMode.PRODUCTION.value: ProdConfig,
BackendMode.PRODUCTION_FULL.value: ProdConfig,
}
cfg_class = config_classes.get(mode)
if not cfg_class:
raise RuntimeError("Backend mode '%s' is not implemented. Choose one"
" of %s" % (mode, list(config_classes.keys())))
return cfg_class()
class Config(object):
ENV = "generic"
DEBUG = False
STATIC_DIR = "./static/"
JSONIFY_PRETTYPRINT_REGULAR = True
LOG_LEVEL = logging.INFO
PREFIX = "/"
METRICS: bool = True
def __init__(self):
if os.environ.get("LOG_LEVEL_DEBUG", "false") == "true":
self.LOG_LEVEL = logging.DEBUG
self.METRICS = bool(os.environ.get("METRICS", True))
class DevConfig(Config):
ENV = BackendMode.DEVELOPMENT_FULL.value
DEBUG = True
LOG_LEVEL = logging.DEBUG
def __init__(self):
super()
log.warn("RUNNING IN DEVELOPMENT MODE")
class ProdConfig(Config):
ENV = BackendMode.PRODUCTION_FULL.value

View File

@ -0,0 +1,112 @@
"""
Cross Site Request Forgery Blueprint.
This module provides a Flask blueprint that implements protection against
request forgeries from other sites. Currently, it is only meant to be used with
an AJAX frontend, not with server-side rendered forms.
The module implements the following protecting measures against CSRF:
- Double Submit Cookie.
- Custom HTTP Headers.
- SameSite cookie attribute.
To elaborate, the `Double Submit Cookie` procedure looks like the following:
1. Browser requests `index.html`, which contains the compiled Javascript.
2. Backend sets the `CSRF_COOKIE` by calling `set_cookie`. If the cookie
already exists, `set_cookie` overrides it with a new one. The cookie
contains a random value.
3. Frontend (`index.html`) is loaded and starts making requests to the backend.
For every request, the frontend reads the `CSRF_COOKIE` value and adds a
`CSRF_HEADER` with the same value.
4. Backend checks that the value of `CSRF_COOKIE` matches the value of
`CSRF_HEADER`. All endpoints are checked, except the index endpoint and
endpoints with safe methods (GET, HEAD, OPTIONS, TRACE).
Custom Headers (`CSRF_HEADER`) provide an extra layer of protection, as
cross-origin requests cannot include custom headers (assuming CORS is not
misconfigured) because of the Same-Origin policy.
The SameSite cookie attribute provides another layer of protection, but may
impede usability so it is configurable. This attribute controls whether a
cookie is sent by the browser when a cross-site request is made. It defaults to
"Strict".
References:
- OWASP CSRF Mitigation:
https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html # noqa: E501
"""
import logging
import os
import secrets
from flask import Blueprint, current_app, request
from werkzeug.exceptions import Forbidden
from . import settings
bp = Blueprint("csrf", __name__)
log = logging.getLogger(__name__)
# NOTE: We can't make these configurable until we have a way to pass settings
# to the frontend in Kubeflow Web Apps (e.g., a `/settings` endpoint).
CSRF_COOKIE = "XSRF-TOKEN"
CSRF_HEADER = "X-" + CSRF_COOKIE
SAMESITE_VALUES = ["Strict", "Lax", "None"]
def set_cookie(resp):
"""
Sets a new CSRF protection cookie to the response. The backend should call
this function every time it serves the index endpoint (`index.html`), in
order to refresh the cookie.
- The frontend should be able to read this cookie: HttpOnly=False
- The cookie should only be sent with HTTPS: Secure=True
- The cookie should only live in the app's path and not in the entire
domain. Path={app.prefix}
Finally, disable caching for the endpoint that calls this function, which
should be the index endpoint.
"""
cookie = secrets.token_urlsafe(32)
secure = settings.SECURE_COOKIES
if not secure:
log.info("Not setting Secure in CSRF cookie.")
samesite = os.getenv("CSRF_SAMESITE", "Strict")
if samesite not in SAMESITE_VALUES:
samesite = "Strict"
resp.set_cookie(key=CSRF_COOKIE, value=cookie, samesite=samesite,
httponly=False, secure=secure,
path=current_app.config["PREFIX"])
# Don't cache a response that sets a CSRF cookie
no_cache = "no-cache, no-store, must-revalidate, max-age=0"
resp.headers["Cache-Control"] = no_cache
@bp.before_app_request
def check_endpoint():
safe_methods = ["GET", "HEAD", "OPTIONS", "TRACE"]
if request.method in safe_methods:
log.info("Skipping CSRF check for safe method: %s", request.method)
return
log.debug("Ensuring endpoint is CSRF protected: %s", request.path)
if CSRF_COOKIE not in request.cookies:
raise Forbidden("Could not find CSRF cookie %s in the request."
% CSRF_COOKIE)
if CSRF_HEADER not in request.headers:
raise Forbidden("Could not detect CSRF protection header %s."
% CSRF_HEADER)
header_token = request.headers[CSRF_HEADER]
cookie_token = request.cookies[CSRF_COOKIE]
if header_token != cookie_token:
raise Forbidden("CSRF check failed. Token in cookie %s doesn't match "
"token in header %s." % (CSRF_COOKIE, CSRF_HEADER))
return

View File

@ -0,0 +1,44 @@
import functools
import logging
from flask import request
from werkzeug import exceptions
log = logging.getLogger(__name__)
def request_is_json_type(func):
"""Make sure that the current request is of type JSON"""
@functools.wraps(func)
def wrapper(*args, **kwargs):
if request.content_type != "application/json":
raise exceptions.BadRequest("Request is not in JSON format.")
return func(*args, **kwargs)
return wrapper
def required_body_params(*params):
"""
Used to decorate a route that accepts json and must contain some specific
fields. If a field is not present then the server will return a 400 error.
"""
def wrapper(func):
@functools.wraps(func)
def runner(*args, **kwargs):
body = request.get_json()
for param in params:
if param not in body:
raise exceptions.BadRequest(
"Parameter '%s' is missing from the request's"
" body." % param
)
return func(*args, **kwargs)
return runner
return wrapper

View File

@ -0,0 +1,6 @@
from flask import Blueprint
bp = Blueprint("errors", __name__)
from . import handlers # noqa F401, E402
from .utils import failed_response, parse_error_message # noqa F401, E402

View File

@ -0,0 +1,41 @@
import logging
from flask import request
from kubernetes.client.rest import ApiException
from werkzeug import exceptions
from .. import api
from . import bp, utils
log = logging.getLogger(__name__)
@bp.app_errorhandler(ApiException)
def api_exception_handler(e):
"""
If the backend could not complete the k8s API call then the default handler
will catch the exception, format the error message and return an
appropriate response for the frontend
"""
ep = request.url
log.error("An error occured talking to k8s while working on %s: %s", ep, e)
if e.status == 404:
msg = "The requested resource could not be found in the API Server"
else:
msg = utils.parse_error_message(e)
return api.failed_response(msg, e.status)
@bp.app_errorhandler(exceptions.HTTPException)
def handle_http_errors(e):
log.error("HTTP Exception handled: %s", e)
return api.failed_response(e.description, e.code)
@bp.app_errorhandler(Exception)
def catch_all(e):
log.error("Caught and unhandled Exception!")
log.exception(e)
return api.failed_response("An error occured in the backend.", 500)

View File

@ -0,0 +1,22 @@
import json
def failed_response(msg, error_code):
content = {
"success": False,
"log": msg,
"status": error_code,
}
return content, error_code
def parse_error(e):
try:
return json.loads(e.body)
except (json.JSONDecodeError, KeyError, AttributeError):
return {}
def parse_error_message(e):
return parse_error(e).get("message", str(e))

View File

@ -0,0 +1,124 @@
"""
Common helper functions for handling k8s objects information
"""
import datetime as dt
import logging
import os
import re
import yaml
from flask import current_app
log = logging.getLogger(__name__)
def get_prefixed_index_html():
"""
The backend should modify the <base> element of the index.html file to
align with the configured prefix the backend is listening
"""
prefix = os.path.join("/", current_app.config["PREFIX"], "")
static_dir = current_app.config["STATIC_DIR"]
log.info("Setting the <base> to reflect the prefix: %s", prefix)
with open(os.path.join(static_dir, "index.html"), "r") as f:
index_html = f.read()
index_prefixed = re.sub(
r"\<base href=\".*\".*\>", '<base href="%s">' % prefix, index_html,
)
return index_prefixed
def load_yaml(f):
"""
f: file path
Load a yaml file and convert it to a python dict.
"""
c = None
try:
with open(f, "r") as yaml_file:
c = yaml_file.read()
except IOError:
log.error("Error opening: %s", f)
return None
try:
contents = yaml.safe_load(c)
if contents is None:
# YAML exists but is empty
return {}
else:
# YAML exists and is not empty
return contents
except yaml.YAMLError:
return None
def load_param_yaml(f, **kwargs):
"""
f: file path
Load a yaml file and convert it to a python dict. The yaml might have some
`{var}` values which the user will have to format. For this we first read
the yaml file and replace these variables and then convert the generated
string to a dict via the yaml module.
"""
c = None
try:
with open(f, "r") as yaml_file:
c = yaml_file.read().format(**kwargs)
except IOError:
log.error("Error opening: %s", f)
return None
try:
contents = yaml.safe_load(c)
if contents is None:
# YAML exists but is empty
return {}
else:
# YAML exists and is not empty
return contents
except yaml.YAMLError:
return None
def get_uptime(then):
"""
then: datetime instance | string
Return a string that informs how much time has pasted from the provided
timestamp.
"""
if isinstance(then, str):
then = dt.datetime.strptime(then, "%Y-%m-%dT%H:%M:%SZ")
now = dt.datetime.now()
diff = now - then.replace(tzinfo=None)
days = diff.days
hours = int(diff.seconds / 3600)
mins = int((diff.seconds % 3600) / 60)
age = ""
if days > 0:
if days == 1:
age = str(days) + " day"
else:
age = str(days) + " days"
else:
if hours > 0:
if hours == 1:
age = str(hours) + " hour"
else:
age = str(hours) + " hours"
else:
if mins == 0:
return "just now"
if mins == 1:
age = str(mins) + " min"
else:
age = str(mins) + " mins"
return age + " ago"

View File

@ -0,0 +1,49 @@
import logging
import sys
from flask import Flask
from prometheus_flask_exporter import PrometheusMetrics
from .authn import no_authentication
log = logging.getLogger(__name__)
def _get_backend_version() -> str:
"""Get the backend version.
The version is defined in setup.py.
"""
if sys.version_info >= (3, 8):
from importlib import metadata
else:
import importlib_metadata as metadata
return metadata.version("kubeflow")
def enable_metrics(app: Flask) -> None:
"""Enable Prometheus metrics fro backend app.
This function will enable metrics collection for all routes and expose them
at /metrics.
Default metrics are:
flask_http_request_duration_seconds (Histogram)
flask_http_request_total (Counter)
flask_http_request_exceptions_total (Counter)
flask_exporter_info (Gauge)
"""
log.info("Enabling the Prometheus metrics for %s", app.name)
backend_version = _get_backend_version()
log.debug("Backend version is %s", backend_version)
metrics = PrometheusMetrics(
app,
group_by="url_rule",
default_labels={"app": app.name},
metrics_decorator=no_authentication,
)
# add default metrics with info about app
metrics.info(
"app_info", "Application info", version=backend_version, app=app.name
)

View File

@ -0,0 +1,17 @@
from flask import Blueprint, jsonify
from . import authn
bp = Blueprint("probes", __name__)
@bp.route("/healthz/liveness")
@authn.no_authentication
def liveness():
return jsonify("alive"), 200
@bp.route("/healthz/readiness")
@authn.no_authentication
def readiness():
return jsonify("ready"), 200

View File

@ -0,0 +1,6 @@
Flask==1.1.1
Flask-API==2.0
kubernetes==10.0.1
requests==2.22.0
urllib3==1.26.18
Werkzeug==0.16.0

View File

@ -0,0 +1,5 @@
from flask import Blueprint
bp = Blueprint("base_lib_get_routes", __name__)
from . import get # noqa E402, F401

View File

@ -0,0 +1,50 @@
from .. import api
from . import bp
@bp.route("/info")
def get_info():
return api.success_response("info", {})
@bp.route("/api/namespaces")
def get_namespaces():
namespaces = api.list_namespaces()
content = [ns.metadata.name for ns in namespaces.items]
return api.success_response("namespaces", content)
@bp.route("/api/storageclasses")
def get_storageclasses():
scs = api.list_storageclasses()
content = [sc.metadata.name for sc in scs.items]
return api.success_response("storageClasses", content)
@bp.route("/api/storageclasses/default")
def get_default_storageclass():
scs = api.list_storageclasses()
for sc in scs.items:
annotations = sc.metadata.annotations
if annotations is None:
continue
# List of possible annotations
keys = [
"storageclass.kubernetes.io/is-default-class",
"storageclass.beta.kubernetes.io/is-default-class", # GKE
]
for key in keys:
default_sc_annotation = annotations.get(key, "false")
if default_sc_annotation == "true":
return api.success_response(
"defaultStorageClass", sc.metadata.name
)
# No StorageClass is default
return api.success_response("defaultStorageClass", "")

View File

@ -0,0 +1,31 @@
import logging
from flask import Blueprint, Response
from . import csrf, helpers
bp = Blueprint("serving", __name__)
log = logging.getLogger(__name__)
# Caching: We want the browser to always check if the index.html file it has
# is the latest one. Also we want this check to use the ETag header and not
# the Last-Modified since our app will be served inside a container and we
# might want to roll-back.
# The JS/CSS files will have a hash in their name and thus we will want to
# cache them for as long as possible
@bp.route("/index.html")
@bp.route("/")
@bp.route("/<path:path>")
def serve_index(path="/"):
# Serve the index file in all other cases
log.info("Serving index.html for path: %s", path)
no_cache = "no-cache, no-store, must-revalidate, max-age=0"
resp = Response(helpers.get_prefixed_index_html(), mimetype="text/html",
headers={"Cache-Control": no_cache})
csrf.set_cookie(resp)
return resp

View File

@ -0,0 +1,6 @@
import os
SECURE_COOKIES = os.getenv("APP_SECURE_COOKIES", "true").lower() == "true"
DISABLE_AUTH = os.getenv("APP_DISABLE_AUTH", "false").lower() == "true"
USER_HEADER = os.getenv("USERID_HEADER", "kubeflow-userid")
USER_PREFIX = os.getenv("USERID_PREFIX", ":")

View File

@ -0,0 +1,22 @@
class STATUS_PHASE:
"""
Different values that the status phase should have. The frontend will be
expecting only these values.
"""
READY = "ready"
WAITING = "waiting"
WARNING = "warning"
ERROR = "error"
UNINITIALIZED = "uninitialized"
UNAVAILABLE = "unavailable"
TERMINATING = "terminating"
STOPPED = "stopped"
def create_status(phase="", message="", state="", key=None):
return {
"phase": phase,
"message": message,
"state": state,
}

View File

@ -0,0 +1,31 @@
import setuptools
REQUIRES = [
"Flask >= 1.1.1",
"Flask-API >= 2.0",
"kubernetes == 22.6.0",
"requests >= 2.22.0",
"urllib3 >= 1.25.7",
"Werkzeug >= 0.16.0",
"Flask-Cors >= 3.0.8",
"gevent",
"prometheus-flask-exporter >= 0.23.1",
"importlib-metadata >= 1.0;python_version<'3.8'",
]
setuptools.setup(
name="kubeflow",
version="1.2",
author="kubeflow-dev-team",
description="A package with a base Flask CRUD backend common code",
packages=setuptools.find_packages(),
install_requires=REQUIRES,
classifiers=[
"Programming Language :: Python :: 3",
"License :: OSI Approved :: Apache Software License",
"Topic :: Software Development",
"Topic :: Software Development :: Libraries",
"Topic :: Software Development :: Libraries :: Python Modules",
],
python_requires=">=3.6",
)

View File

@ -0,0 +1,5 @@
approvers:
- elenzio9
- orfeas-k
emeritus_approvers:
- tasos-ale

View File

@ -0,0 +1,13 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
max_line_length = off
trim_trailing_whitespace = false

View File

@ -0,0 +1,51 @@
{
"root": true,
"ignorePatterns": [
"projects/**/*"
],
"overrides": [
{
"files": [
"*.ts"
],
"parserOptions": {
"project": [
"tsconfig.json",
"e2e/tsconfig.json"
],
"createDefaultProgram": true
},
"extends": [
"plugin:@angular-eslint/ng-cli-compat",
"plugin:@angular-eslint/ng-cli-compat--formatting-add-on",
"plugin:@angular-eslint/template/process-inline-templates"
],
"rules": {
"@typescript-eslint/explicit-member-accessibility": [
"off",
{
"accessibility": "explicit"
}
],
"@typescript-eslint/naming-convention": "off",
"@typescript-eslint/member-ordering": "off",
"@angular-eslint/no-input-rename": "off",
"prefer-arrow/prefer-arrow-functions": "off",
"arrow-parens": [
"off",
"always"
],
"import/order": "off"
}
},
{
"files": [
"*.html"
],
"extends": [
"plugin:@angular-eslint/template/recommended"
],
"rules": {}
}
]
}

View File

@ -0,0 +1,46 @@
# See http://help.github.com/ignore-files/ for more about ignoring files.
# compiled output
/dist
/tmp
/out-tsc
# Only exists if Bazel was run
/bazel-out
# dependencies
/node_modules
# profiling files
chrome-profiler-events*.json
speed-measure-plugin*.json
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# misc
/.sass-cache
/connect.lock
/coverage
/libpeerconnection.log
npm-debug.log
yarn-error.log
testem.log
/typings
# System Files
.DS_Store
Thumbs.db

View File

@ -0,0 +1,43 @@
# Kubeflow Common Frontend Library
This code provides a common library of reusable Angular Components that can be used from our different Kubeflow web apps. This library aims to:
* Enforce a common UX throughout the different apps
* Reduce the development effort required to propagate changes to all the web apps
* Minimize the code duplication between our Kubeflow web apps
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 8.3.20, which is required to build and run the unit tests.
## Local development
In order to use this library while developing locally your Angular app you will need to:
1. Build the `kubeflow` node module from this source code
2. Link the produced module to your global npm modules
3. Link the `kubeflow` module in the npm modules of you app
### Building the library locally
```bash
# build the npm module
npm run build
# might need sudo, depending on where you global folder lives
# https://nodejs.dev/learn/where-does-npm-install-the-packages
npm link dist/kubeflow
```
### Linking it to the app
```bash
cd ${APP_DIR}
npm install
npm link kubeflow
```
## Running unit tests
Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io).
## Contributor Guidelines
### Unit tests
1. Any new component added to this library should also include some basic unit tests
2. The unit tests should be passing at any point of time
### Git commits
Git commits that modify this code should be prefixed with `web-apps(front)`.

View File

@ -0,0 +1,51 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"cli": {
"analytics": false
},
"version": 1,
"newProjectRoot": "projects",
"projects": {
"kubeflow": {
"projectType": "library",
"root": "projects/kubeflow",
"sourceRoot": "projects/kubeflow/src",
"prefix": "lib",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:ng-packagr",
"options": {
"tsConfig": "projects/kubeflow/tsconfig.lib.json",
"project": "projects/kubeflow/ng-package.json"
},
"configurations": {
"production": {
"tsConfig": "projects/kubeflow/tsconfig.lib.prod.json"
}
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"main": "projects/kubeflow/src/test.ts",
"tsConfig": "projects/kubeflow/tsconfig.spec.json",
"karmaConfig": "projects/kubeflow/karma.conf.js"
}
},
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": [
"projects/kubeflow/**/*.ts",
"projects/kubeflow/**/*.html"
]
}
}
}
}
},
"defaultProject": "kubeflow",
"cli": {
"defaultCollection": "@angular-eslint/schematics"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,84 @@
{
"name": "angular-frontend-kubeflow",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build && npm run copyCSS && npm run copyAssets",
"test": "ng test",
"test-ci": "ng test --no-watch --no-progress --browsers=ChromeHeadlessCI",
"test-docker": "docker run -v $(pwd):/usr/src/app browserless/chrome:1.44-chrome-stable npm run test-ci",
"test:prod": "ng test --browsers=ChromeHeadless --watch=false",
"lint-check": "ng lint",
"lint": "ng lint --fix",
"e2e": "ng e2e",
"copyCSS": "cp -r ./projects/kubeflow/src/styles ./dist/kubeflow/styles",
"copyAssets": "cp -r ./projects/kubeflow/src/assets ./dist/kubeflow/assets",
"postinstall": "ngcc",
"format:check": "prettier --check 'projects/kubeflow/src/**/*.{js,ts,html,scss,css}' || node scripts/check-format-error.js",
"format:write": "prettier --write 'projects/kubeflow/src/**/*.{js,ts,html,scss,css}'"
},
"private": true,
"dependencies": {
"@angular/animations": "~14.3.0",
"@angular/cdk": "~14.2.7",
"@angular/cdk-experimental": "~14.2.7",
"@angular/common": "~14.3.0",
"@angular/compiler": "~14.3.0",
"@angular/core": "~14.3.0",
"@angular/forms": "~14.3.0",
"@angular/localize": "~14.3.0",
"@angular/material": "~14.2.7",
"@angular/platform-browser": "~14.3.0",
"@angular/platform-browser-dynamic": "~14.3.0",
"@angular/router": "~14.3.0",
"@fortawesome/angular-fontawesome": "~0.11.1",
"@fortawesome/fontawesome-svg-core": "^1.2.26",
"@fortawesome/free-brands-svg-icons": "^5.12.0",
"@fortawesome/free-solid-svg-icons": "^5.12.0",
"date-fns": "1.29.0",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"material-icons": "^0.7.7",
"monaco-editor": "^0.33.0",
"rxjs": "~7.4.0",
"tslib": "^2.0.0",
"zone.js": "~0.11.4"
},
"devDependencies": {
"@angular-devkit/build-angular": "~14.2.13",
"@angular-devkit/core": "^14.2.13",
"@angular-eslint/builder": "~14.4.0",
"@angular-eslint/eslint-plugin": "~14.4.0",
"@angular-eslint/eslint-plugin-template": "~14.4.0",
"@angular-eslint/schematics": "~14.4.0",
"@angular-eslint/template-parser": "~14.4.0",
"@angular/cli": "~14.2.13",
"@angular/compiler-cli": "~14.3.0",
"@angular/language-service": "~14.3.0",
"@kubernetes/client-node": "^0.16.3",
"@types/jasmine": "~3.6.0",
"@types/jasminewd2": "^2.0.9",
"@types/lodash": "^4.17.6",
"@types/lodash-es": "^4.17.6",
"@types/node": "^12.11.1",
"@typescript-eslint/eslint-plugin": "4.28.2",
"@typescript-eslint/parser": "4.28.2",
"eslint": "^7.26.0",
"eslint-plugin-import": "latest",
"eslint-plugin-jsdoc": "^34.0.0",
"eslint-plugin-prefer-arrow": "latest",
"jasmine-core": "~3.8.0",
"jasmine-spec-reporter": "~5.0.0",
"karma": "~6.3.16",
"karma-chrome-launcher": "~3.1.0",
"karma-coverage-istanbul-reporter": "~3.0.2",
"karma-jasmine": "~4.0.0",
"karma-jasmine-html-reporter": "^1.6.0",
"ng-packagr": "~14.2.2",
"prettier": "2.3.2",
"process": "^0.11.10",
"ts-node": "^10.4.0",
"typescript": "~4.8.0"
}
}

View File

@ -0,0 +1,56 @@
{
"extends": "../../.eslintrc.json",
"ignorePatterns": [
"!**/*",
"lib/editor/interfaces/monaco.ts"
],
"overrides": [
{
"files": [
"*.ts"
],
"parserOptions": {
"project": [
"projects/kubeflow/tsconfig.lib.json",
"projects/kubeflow/tsconfig.spec.json"
],
"createDefaultProgram": true
},
"rules": {
"@angular-eslint/component-selector": [
"error",
{
"type": "element",
"prefix": "lib",
"style": "kebab-case"
}
],
"@angular-eslint/directive-selector": [
"error",
{
"type": "attribute",
"prefix": "lib",
"style": "camelCase"
}
],
"@typescript-eslint/explicit-member-accessibility": [
"off",
{
"accessibility": "explicit"
}
],
"arrow-parens": [
"off",
"always"
],
"import/order": "off"
}
},
{
"files": [
"*.html"
],
"rules": {}
}
]
}

View File

@ -0,0 +1,11 @@
{
"printWidth": 80,
"useTabs": false,
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"bracketSpacing": true,
"arrowParens": "avoid",
"proseWrap": "preserve"
}

View File

@ -0,0 +1,25 @@
# Kubeflow
This library was generated with [Angular CLI](https://github.com/angular/angular-cli) version 8.2.14.
## Code scaffolding
Run `ng generate component component-name --project kubeflow` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module --project kubeflow`.
> Note: Don't forget to add `--project kubeflow` or else it will be added to the default project in your `angular.json` file.
## Build
Run `ng build kubeflow` to build the project. The build artifacts will be stored in the `dist/` directory.
## Publishing
After building your library with `ng build kubeflow`, go to the dist folder `cd dist/kubeflow` and run `npm publish`.
## Running unit tests
Run `ng test kubeflow` to execute the unit tests via [Karma](https://karma-runner.github.io).
## Further help
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI documents](https://angular.io/cli).

View File

@ -0,0 +1,56 @@
// Karma configuration file, see link for more information
// https://karma-runner.github.io/1.0/config/configuration-file.html
module.exports = function (config) {
config.set({
basePath: '../../',
frameworks: ['jasmine', '@angular-devkit/build-angular'],
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'),
require('karma-coverage-istanbul-reporter'),
require('@angular-devkit/build-angular/plugins/karma'),
],
files: [
{
pattern: 'node_modules/monaco-editor/**',
watched: false,
included: false,
served: true,
},
{
pattern: 'projects/kubeflow/src/assets/**',
watched: false,
included: false,
served: true,
},
],
proxies: {
'/static/assets/monaco-editor/': '/base/node_modules/monaco-editor/',
'/static/assets/': '/base/projects/kubeflow/src/assets/',
},
client: {
clearContext: true,
},
coverageIstanbulReporter: {
dir: require('path').join(__dirname, '../../coverage/kubeflow'),
reports: ['html', 'lcovonly', 'text-summary'],
fixWebpackSourcePaths: true,
},
reporters: ['progress', 'kjhtml'],
port: 9876,
colors: true,
logLevel: config.LOG_INFO,
autoWatch: true,
browsers: ['Chrome'],
customLaunchers: {
ChromeHeadlessCI: {
base: 'ChromeHeadless',
flags: ['--no-sandbox'],
},
},
singleRun: false,
restartOnFileChange: true,
});
};

View File

@ -0,0 +1,7 @@
{
"$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
"dest": "../../dist/kubeflow",
"lib": {
"entryFile": "src/public-api.ts"
}
}

View File

@ -0,0 +1,5 @@
{
"name": "kubeflow",
"version": "0.0.1",
"lockfileVersion": 1
}

View File

@ -0,0 +1,23 @@
{
"name": "kubeflow",
"version": "0.0.4",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"@angular/common": "~14.3.0",
"@angular/cdk": "~14.2.7",
"@angular/cdk-experimental": "~14.2.7",
"@angular/core": "~14.3.0",
"@angular/animations": "~14.3.0",
"@angular/compiler": "~14.3.0",
"@angular/forms": "~14.3.0",
"@angular/material": "~14.2.7",
"@angular/localize": "~14.3.0",
"date-fns": "1.29.0",
"lodash-es": "^4.17.21",
"material-icons": "^0.7.7",
"zone.js": "~0.11.4",
"@kubernetes/client-node": "^0.12.2"
}
}

View File

@ -0,0 +1,5 @@
<lib-resource-table
[config]="config"
[data]="conditions"
[trackByFn]="conditionsTrackByFn"
></lib-resource-table>

View File

@ -0,0 +1,27 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { ConditionsTableComponent } from './conditions-table.component';
import { ConditionsTableModule } from './conditions-table.module';
describe('ConditionsTableComponent', () => {
let component: ConditionsTableComponent;
let fixture: ComponentFixture<ConditionsTableComponent>;
beforeEach(
waitForAsync(() => {
TestBed.configureTestingModule({
imports: [ConditionsTableModule],
}).compileComponents();
}),
);
beforeEach(() => {
fixture = TestBed.createComponent(ConditionsTableComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,55 @@
import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
import { Condition, ConditionIR } from './types';
import { STATUS_TYPE } from '../resource-table/status/types';
import { generateConfig } from './config';
import { TableConfig } from '../resource-table/types';
@Component({
selector: 'lib-conditions-table',
templateUrl: './conditions-table.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrls: [],
})
export class ConditionsTableComponent {
private conditionsPrv: ConditionIR[] = [];
public config: TableConfig = generateConfig();
@Input()
set title(t: string) {
this.config.title = t;
}
@Input()
set conditions(cs: ConditionIR[]) {
this.conditionsPrv = JSON.parse(JSON.stringify(cs));
// parse the status. It should be ready only if it was True
for (const condition of this.conditionsPrv) {
condition.statusPhase = STATUS_TYPE.WARNING;
if (condition.status === 'True') {
condition.statusPhase = STATUS_TYPE.READY;
}
// Set default values that are necessary for sorting functionality
if (!condition.lastTransitionTime) {
condition.lastTransitionTime = '';
}
if (!condition.message) {
condition.message = '';
}
if (!condition.reason) {
condition.reason = '';
}
condition.statusMessage = condition.status;
}
}
get conditions(): ConditionIR[] {
return this.conditionsPrv;
}
public conditionsTrackByFn(index: number, c: Condition) {
return `${c.type}/${c.lastTransitionTime}`;
}
}

View File

@ -0,0 +1,12 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ResourceTableModule } from '../resource-table/resource-table.module';
import { ConditionsTableComponent } from './conditions-table.component';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
@NgModule({
declarations: [ConditionsTableComponent],
imports: [CommonModule, ResourceTableModule, BrowserAnimationsModule],
exports: [ConditionsTableComponent],
})
export class ConditionsTableModule {}

View File

@ -0,0 +1,65 @@
import {
PropertyValue,
StatusValue,
TABLE_THEME,
TableConfig,
} from '../resource-table/types';
import { DateTimeValue } from '../resource-table/types/date-time';
export function generateConfig(): TableConfig {
return {
title: '',
width: '100%',
theme: TABLE_THEME.FLAT,
columns: [
{
matHeaderCellDef: 'Status',
matColumnDef: 'status',
style: { width: '40px' },
value: new StatusValue({
fieldPhase: 'statusPhase',
fieldMessage: 'statusMessage',
}),
sort: true,
},
{
matHeaderCellDef: 'Type',
matColumnDef: 'type',
style: { width: '150px' },
value: new PropertyValue({
field: 'type',
}),
sort: true,
},
{
matHeaderCellDef: 'Last Transition Time',
matColumnDef: 'lastTransitionTime',
style: { width: '150px' },
value: new DateTimeValue({
field: 'lastTransitionTime',
}),
sort: true,
},
{
matHeaderCellDef: 'Reason',
matColumnDef: 'reason',
style: { width: '150px' },
value: new PropertyValue({
field: 'reason',
}),
sort: true,
},
{
matHeaderCellDef: 'Message',
matColumnDef: 'message',
style: { width: '150px' },
value: new PropertyValue({
field: 'message',
}),
sort: true,
},
],
sortByColumn: 'lastTransitionTime',
sortDirection: 'desc',
};
}

View File

@ -0,0 +1,12 @@
export interface Condition {
type: string;
status: string;
lastTransitionTime?: string;
reason?: string;
message?: string;
}
export interface ConditionIR extends Condition {
statusPhase?: string;
statusMessage?: string;
}

View File

@ -0,0 +1,17 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ConfirmDialogComponent } from './dialog/dialog.component';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
@NgModule({
declarations: [ConfirmDialogComponent],
imports: [
CommonModule,
MatDialogModule,
MatButtonModule,
MatProgressSpinnerModule,
],
})
export class ConfirmDialogModule {}

View File

@ -0,0 +1,19 @@
import { TestBed } from '@angular/core/testing';
import { ConfirmDialogService } from './confirm-dialog.service';
import { MatDialog } from '@angular/material/dialog';
import { ConfirmDialogModule } from './confirm-dialog.module';
describe('ConfirmDialogService', () => {
beforeEach(() =>
TestBed.configureTestingModule({
imports: [ConfirmDialogModule],
providers: [MatDialog],
}),
);
it('should be created', () => {
const service: ConfirmDialogService = TestBed.inject(ConfirmDialogService);
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,19 @@
import { Injectable } from '@angular/core';
import { ConfirmDialogModule } from './confirm-dialog.module';
import { MatDialog } from '@angular/material/dialog';
import { ConfirmDialogComponent } from './dialog/dialog.component';
import { DialogConfig } from './types';
@Injectable({
providedIn: ConfirmDialogModule,
})
export class ConfirmDialogService {
constructor(private dialog: MatDialog) {}
public open(rsrcName: string, config: DialogConfig) {
return this.dialog.open(ConfirmDialogComponent, {
width: config.width || 'fit-content',
data: config,
});
}
}

View File

@ -0,0 +1,27 @@
<h1 mat-dialog-title>{{ data.title }}</h1>
<div mat-dialog-content>
<p>{{ data.message }}</p>
<p class="error">{{ data.error }}</p>
</div>
<div mat-dialog-actions>
<button mat-button [mat-dialog-close]="DIALOG_RESP.CANCEL" cdkFocusInitial>
{{ data.cancel.toUpperCase() }}
</button>
<button
*ngIf="!isApplying"
mat-button
(click)="onAcceptClicked()"
[color]="data.confirmColor"
>
{{ data.accept.toUpperCase() }}
</button>
<button *ngIf="isApplying" mat-button disabled>
<div class="waiting-button-wrapper">
<mat-spinner diameter="16"></mat-spinner>
<p>{{ data.applying }}</p>
</div>
</button>
</div>

View File

@ -0,0 +1,11 @@
.waiting-button-wrapper {
display: flex;
}
.waiting-button-wrapper mat-spinner {
margin: auto 0.2rem;
}
.error {
color: red;
}

View File

@ -0,0 +1,44 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { ConfirmDialogComponent } from './dialog.component';
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { ConfirmDialogModule } from '../confirm-dialog.module';
describe('ConfirmDialogComponent', () => {
let component: ConfirmDialogComponent;
let fixture: ComponentFixture<ConfirmDialogComponent>;
beforeEach(
waitForAsync(() => {
TestBed.configureTestingModule({
imports: [ConfirmDialogModule],
providers: [
{ provide: MatDialogRef, useValue: {} },
{
provide: MAT_DIALOG_DATA,
useValue: {
title: '',
message: '',
accept: '',
applying: '',
error: '',
confirmColor: '',
cancel: '',
width: '',
},
},
],
}).compileComponents();
}),
);
beforeEach(() => {
fixture = TestBed.createComponent(ConfirmDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,37 @@
import { Component, OnInit, Inject, EventEmitter } from '@angular/core';
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { DialogConfig, DIALOG_RESP } from '../types';
import { Subject } from 'rxjs';
@Component({
selector: 'lib-confirm-dialog',
templateUrl: './dialog.component.html',
styleUrls: ['./dialog.component.scss'],
})
export class ConfirmDialogComponent implements OnInit {
public DIALOG_RESP = DIALOG_RESP;
public isApplying = false;
public applying$ = new Subject<boolean>();
constructor(
public dialogRef: MatDialogRef<ConfirmDialogComponent>,
@Inject(MAT_DIALOG_DATA)
public data: DialogConfig,
) {}
ngOnInit() {
this.applying$.subscribe(b => {
this.isApplying = b;
});
}
onAcceptClicked(): void {
this.isApplying = true;
this.applying$.next(true);
}
onCancelClicked(): void {
this.dialogRef.close(DIALOG_RESP.CANCEL);
}
}

View File

@ -0,0 +1,15 @@
export interface DialogConfig {
title: string;
message: string;
accept: string;
applying: string;
error?: string;
confirmColor: string;
cancel: string;
width?: string;
}
export enum DIALOG_RESP {
CANCEL = 'cancel',
ACCEPT = 'accept',
}

View File

@ -0,0 +1,19 @@
<mat-divider *ngIf="topDivider"></mat-divider>
<div class="list-entry-row">
<!--Key-Title Row-->
<div
class="list-entry-key vertical-align"
[matTooltip]="keyTooltip"
[style.min-width]="keyMinWidth"
>
{{ key }}
</div>
<!--Values-Subtable Row-->
<div class="container">
<ng-content class="vertical-align"> </ng-content>
</div>
</div>
<mat-divider *ngIf="bottomDivider"></mat-divider>

View File

@ -0,0 +1,26 @@
:host {
display: block;
}
.list-entry-row {
padding: 0.4rem 0;
display: flex;
}
.list-entry-key {
font-weight: 500;
}
.container {
display: flex;
flex-direction: column;
color: rgba(0, 0, 0, 0.66);
}
.vertical-align {
margin-bottom: auto;
}
.mat-divider {
margin: 4px 0;
}

View File

@ -0,0 +1,28 @@
import { CommonModule } from '@angular/common';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatDividerModule } from '@angular/material/divider';
import { MatTooltipModule } from '@angular/material/tooltip';
import { ContentListItemComponent } from './content-list-item.component';
describe('ContentListItemComponent', () => {
let component: ContentListItemComponent;
let fixture: ComponentFixture<ContentListItemComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ContentListItemComponent],
imports: [CommonModule, MatDividerModule, MatTooltipModule],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(ContentListItemComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,19 @@
import { Component, Input, OnInit } from '@angular/core';
@Component({
selector: 'lib-content-list-item',
templateUrl: './content-list-item.component.html',
styleUrls: ['./content-list-item.component.scss'],
})
export class ContentListItemComponent implements OnInit {
@Input() key: string;
@Input() keyTooltip: string;
@Input() topDivider = false;
@Input() bottomDivider = true;
@Input() keyMinWidth = '250px';
@Input() loadErrorMsg = 'Resources not available';
constructor() {}
ngOnInit(): void {}
}

View File

@ -0,0 +1,12 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ContentListItemComponent } from './content-list-item.component';
import { MatDividerModule } from '@angular/material/divider';
import { MatTooltipModule } from '@angular/material/tooltip';
@NgModule({
declarations: [ContentListItemComponent],
imports: [CommonModule, MatDividerModule, MatTooltipModule],
exports: [ContentListItemComponent],
})
export class ContentListItemModule {}

View File

@ -0,0 +1,20 @@
<div
[libPopover]="timeTpl"
[libPopoverPosition]="popoverPosition"
[libPopoverDisabled]="isPopoverDisabled"
[libPopoverHideDelay]="100"
[libPopoverShowDelay]="100"
class="truncate"
>
{{ formattedDate }}
</div>
<ng-template #timeTpl>
<lib-details-list-item key="Local" [bottomDivider]="false" keyMinWidth="50px">
{{ date | libToDate }}
</lib-details-list-item>
<lib-details-list-item key="UTC" [bottomDivider]="false" keyMinWidth="50px">
{{ date }}
</lib-details-list-item>
</ng-template>

View File

@ -0,0 +1,28 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { DateTimeComponent } from './date-time.component';
import { ToDatePipe } from './to-date.pipe';
import { DateTimeModule } from './date-time.module';
describe('DateTimeComponent', () => {
let component: DateTimeComponent;
let fixture: ComponentFixture<DateTimeComponent>;
beforeEach(
waitForAsync(() => {
TestBed.configureTestingModule({
imports: [DateTimeModule],
}).compileComponents();
}),
);
beforeEach(() => {
fixture = TestBed.createComponent(DateTimeComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,77 @@
import {
Component,
OnDestroy,
Input,
ChangeDetectionStrategy,
ChangeDetectorRef,
} from '@angular/core';
import { DateTimeService } from '../services/date-time.service';
@Component({
selector: 'lib-date-time',
templateUrl: './date-time.component.html',
styleUrls: [],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DateTimeComponent implements OnDestroy {
private timer: number;
private defaultDisplayValuePrv = '-';
private datePrv: string | Date;
@Input()
get date(): string | Date {
return this.datePrv;
}
set date(v: string | Date) {
this.datePrv = v;
this.formattedDate = this.timeAgo(v);
}
formattedDate: string;
@Input() popoverPosition = 'below';
@Input('default')
set defaultDisplayValue(v: string) {
this.defaultDisplayValuePrv = v;
this.checkAndUpdate(this.date);
}
get defaultDisplayValue(): string {
return this.defaultDisplayValuePrv;
}
get isPopoverDisabled(): boolean {
return !this.date;
}
constructor(
private dtService: DateTimeService,
private cdRef: ChangeDetectorRef,
) {
this.timer = window.setInterval(() => {
if (this.date) {
this.checkAndUpdate(this.date);
}
}, 1000);
}
ngOnDestroy() {
if (this.timer) {
clearTimeout(this.timer);
}
}
private timeAgo(d: string | Date): string {
if (!d) {
return this.defaultDisplayValue;
}
return this.dtService.distanceInWords(d);
}
private checkAndUpdate(date: string | Date) {
const d = this.timeAgo(date);
if (this.formattedDate !== d && this.cdRef) {
this.formattedDate = d;
this.cdRef.detectChanges();
}
}
}

View File

@ -0,0 +1,13 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DateTimeComponent } from './date-time.component';
import { PopoverModule } from '../popover/popover.module';
import { ToDatePipe } from './to-date.pipe';
import { DetailsListModule } from '../details-list/details-list.module';
@NgModule({
declarations: [DateTimeComponent, ToDatePipe],
imports: [CommonModule, PopoverModule, DetailsListModule],
exports: [DateTimeComponent, ToDatePipe],
})
export class DateTimeModule {}

Some files were not shown because too many files have changed in this diff Show More