Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 178 additions & 0 deletions .github/workflows/api-image-fast.yml

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

any reason why we create this new workflow?

Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
name: Fast API Image CI

on:
workflow_dispatch:
inputs:
image_tag:
description: Docker image tag to use. Defaults to the commit SHA.
required: false
type: string
push_image:
description: Push the built image to GHCR.
required: true
default: true
type: boolean

env:
GO_VERSION: "1.22"
DOCKER_REGISTRY: ghcr.io

jobs:
prepare:
runs-on: ubuntu-latest
outputs:
image_tag: ${{ steps.meta.outputs.image_tag }}
artifact_name: ${{ steps.meta.outputs.artifact_name }}
transformer_artifact_name: ${{ steps.meta.outputs.transformer_artifact_name }}
steps:
- id: meta
env:
INPUT_IMAGE_TAG: ${{ inputs.image_tag }}
run: |
IMAGE_TAG="${INPUT_IMAGE_TAG}"
if [ -z "${IMAGE_TAG}" ]; then
IMAGE_TAG="${GITHUB_SHA}"
fi

echo "image_tag=${IMAGE_TAG}" >> "$GITHUB_OUTPUT"
echo "artifact_name=merlin.${IMAGE_TAG}.tar" >> "$GITHUB_OUTPUT"
echo "transformer_artifact_name=merlin-transformer.${IMAGE_TAG}.tar" >> "$GITHUB_OUTPUT"

test-api:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:12.4
env:
POSTGRES_DB: postgres
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache-dependency-path: api/go.sum
- name: Install dependencies
run: |
make setup
make init-dep-api
- name: Test API files
env:
POSTGRES_HOST: localhost
POSTGRES_DB: postgres
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
run: make it-test-api-ci

build-ui:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: yarn
cache-dependency-path: ui/yarn.lock
- name: Install dependencies
run: make init-dep-ui
- name: Build UI static files
run: make build-ui
- name: Publish UI artifact
uses: actions/upload-artifact@v4
with:
name: merlin-ui-dist-fast
path: ui/build/

build-transformer:
runs-on: ubuntu-latest
needs:
- prepare
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache-dependency-path: api/go.sum
- name: Install dependencies
run: make init-dep-api
- name: Build Standard Transformer
run: make build-transformer
- name: Build Standard Transformer Docker image
run: docker build -t merlin-transformer:${{ needs.prepare.outputs.image_tag }} -f transformer.Dockerfile .
- name: Save Standard Transformer Docker image
run: docker image save --output "${{ needs.prepare.outputs.transformer_artifact_name }}" "merlin-transformer:${{ needs.prepare.outputs.image_tag }}"
- name: Publish Standard Transformer Docker artifact
uses: actions/upload-artifact@v4
with:
name: ${{ needs.prepare.outputs.transformer_artifact_name }}
path: ${{ needs.prepare.outputs.transformer_artifact_name }}

build-api-image:
runs-on: ubuntu-latest
needs:
- prepare
- test-api
- build-ui
steps:
- uses: actions/checkout@v4
- name: Download UI artifact
uses: actions/download-artifact@v4
with:
name: merlin-ui-dist-fast
path: ui/build
- name: Build API Docker image
run: docker build -t merlin:${{ needs.prepare.outputs.image_tag }} -f Dockerfile .
- name: Save API Docker image
run: docker image save --output "${{ needs.prepare.outputs.artifact_name }}" "merlin:${{ needs.prepare.outputs.image_tag }}"
- name: Publish API Docker artifact
uses: actions/upload-artifact@v4
with:
name: ${{ needs.prepare.outputs.artifact_name }}
path: ${{ needs.prepare.outputs.artifact_name }}

push-api-image:
if: ${{ inputs.push_image }}
runs-on: ubuntu-latest
needs:
- prepare
- build-api-image
permissions:
packages: write
steps:
- name: Download API Docker artifact
uses: actions/download-artifact@v4
with:
name: ${{ needs.prepare.outputs.artifact_name }}
- name: Push Docker image
env:
IMAGE_TAG: ghcr.io/${{ github.repository }}/merlin:${{ needs.prepare.outputs.image_tag }}
run: |
docker login ${{ env.DOCKER_REGISTRY }} -u ${{ github.actor }} -p ${{ secrets.GITHUB_TOKEN }}
docker image load --input "${{ needs.prepare.outputs.artifact_name }}"
docker tag "merlin:${{ needs.prepare.outputs.image_tag }}" "${IMAGE_TAG}"
docker push "${IMAGE_TAG}"

push-transformer-image:
if: ${{ inputs.push_image }}
runs-on: ubuntu-latest
needs:
- prepare
- build-transformer
permissions:
packages: write
steps:
- name: Download Standard Transformer Docker artifact
uses: actions/download-artifact@v4
with:
name: ${{ needs.prepare.outputs.transformer_artifact_name }}
- name: Push Docker image
env:
IMAGE_TAG: ghcr.io/${{ github.repository }}/merlin-transformer:${{ needs.prepare.outputs.image_tag }}
run: |
docker login ${{ env.DOCKER_REGISTRY }} -u ${{ github.actor }} -p ${{ secrets.GITHUB_TOKEN }}
docker image load --input "${{ needs.prepare.outputs.transformer_artifact_name }}"
docker tag "merlin-transformer:${{ needs.prepare.outputs.image_tag }}" "${IMAGE_TAG}"
docker push "${IMAGE_TAG}"
53 changes: 53 additions & 0 deletions api/api/node_api.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Copyright 2020 The Merlin Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package api

import (
"errors"
"fmt"
"net/http"

"gorm.io/gorm"
)

// NodeController serves node listing for a deployment environment.
type NodeController struct {
*AppContext
}

// ListNodes returns the nodes (name, status, labels, taints) of the cluster
// backing the given environment, so the UI can offer them for model placement.
func (c *NodeController) ListNodes(r *http.Request, vars map[string]string, _ interface{}) *Response {
ctx := r.Context()

environmentName := vars["environment_name"]
env, err := c.EnvironmentService.GetEnvironment(environmentName)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return NotFound(fmt.Sprintf("Environment not found: %v", err))
}
return InternalServerError(fmt.Sprintf("Error getting environment: %v", err))
}

// Route by environment name — this selects the same per-environment controller
// that deployment uses, so nodes are read from the exact cluster the model
// deploys to (in-cluster SA locally, or mTLS client cert for a remote cluster).
nodes, err := c.NodeService.ListNodes(ctx, env.Name)
if err != nil {
return InternalServerError(fmt.Sprintf("Error listing nodes: %v", err))
}

return Ok(nodes)
}
3 changes: 3 additions & 0 deletions api/api/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ type AppContext struct {
VersionImageService service.VersionImageService
EndpointsService service.EndpointsService
LogService service.LogService
NodeService service.NodeService
PredictionJobService service.PredictionJobService
SecretService service.SecretService
ModelEndpointAlertService service.ModelEndpointAlertService
Expand Down Expand Up @@ -168,6 +169,7 @@ func NewRouter(appCtx AppContext) (*mux.Router, error) {
endpointsController := EndpointsController{&appCtx}
predictionJobController := PredictionJobController{&appCtx}
logController := LogController{&appCtx}
nodeController := NodeController{&appCtx}
secretController := SecretsController{&appCtx}
alertsController := AlertsController{&appCtx}
transformerController := TransformerController{&appCtx}
Expand All @@ -176,6 +178,7 @@ func NewRouter(appCtx AppContext) (*mux.Router, error) {
routes := []Route{
// Environment API
{http.MethodGet, "/environments", nil, environmentController.ListEnvironments, "ListEnvironments"},
{http.MethodGet, "/environments/{environment_name}/nodes", nil, nodeController.ListNodes, "ListNodes"},

// Project API
{http.MethodGet, "/projects/{project_id:[0-9]+}", nil, projectsController.GetProject, "GetProject"},
Expand Down
80 changes: 73 additions & 7 deletions api/api/validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import (
"context"
"errors"
"fmt"
"strings"

"golang.org/x/exp/slices"
corev1 "k8s.io/api/core/v1"

"github.com/caraml-dev/merlin/config"
"github.com/caraml-dev/merlin/models"
Expand Down Expand Up @@ -61,22 +63,86 @@ func validateRequest(validators ...requestValidator) error {

func resourceRequestValidation(endpoint *models.VersionEndpoint) requestValidator {
return newFuncValidate(func() error {
if endpoint.ResourceRequest == nil {
return nil
}
if endpoint.ResourceRequest != nil {
if endpoint.ResourceRequest.MinReplica > endpoint.ResourceRequest.MaxReplica {
return fmt.Errorf("min replica must be less or equal to max replica")
}

if endpoint.ResourceRequest.MaxReplica < 1 {
return fmt.Errorf("max replica must be greater than 0")
}

if err := validateTolerations(endpoint.ResourceRequest.Tolerations); err != nil {
return fmt.Errorf("invalid toleration in resource request: %w", err)
}

if endpoint.ResourceRequest.MinReplica > endpoint.ResourceRequest.MaxReplica {
return fmt.Errorf("min replica must be less or equal to max replica")
if err := validateNodeSelector(endpoint.ResourceRequest.NodeSelector); err != nil {
return fmt.Errorf("invalid node selector in resource request: %w", err)
}
}

if endpoint.ResourceRequest.MaxReplica < 1 {
return fmt.Errorf("max replica must be greater than 0")
if endpoint.Transformer != nil && endpoint.Transformer.ResourceRequest != nil {
if endpoint.Transformer.ResourceRequest.MinReplica > endpoint.Transformer.ResourceRequest.MaxReplica {
return fmt.Errorf("transformer min replica must be less or equal to max replica")
}

if endpoint.Transformer.ResourceRequest.MaxReplica < 1 {
return fmt.Errorf("transformer max replica must be greater than 0")
}

if err := validateTolerations(endpoint.Transformer.ResourceRequest.Tolerations); err != nil {
return fmt.Errorf("invalid toleration in transformer resource request: %w", err)
}

if err := validateNodeSelector(endpoint.Transformer.ResourceRequest.NodeSelector); err != nil {
return fmt.Errorf("invalid node selector in transformer resource request: %w", err)
}
}

return nil
})
}

var validTolerationEffects = []corev1.TaintEffect{
corev1.TaintEffectNoSchedule,
corev1.TaintEffectPreferNoSchedule,
corev1.TaintEffectNoExecute,
"", // empty matches all effects
}

var validTolerationOperators = []corev1.TolerationOperator{
corev1.TolerationOpEqual,
corev1.TolerationOpExists,
"", // empty defaults to Equal
}

func validateTolerations(tolerations []corev1.Toleration) error {
for i, t := range tolerations {
if !slices.Contains(validTolerationEffects, t.Effect) {
return fmt.Errorf("toleration[%d] has invalid effect %q; must be one of: NoSchedule, PreferNoSchedule, NoExecute", i, t.Effect)
}
if !slices.Contains(validTolerationOperators, t.Operator) {
return fmt.Errorf("toleration[%d] has invalid operator %q; must be one of: Equal, Exists", i, t.Operator)
}
if t.Operator == corev1.TolerationOpExists && t.Value != "" {
return fmt.Errorf("toleration[%d] with operator 'Exists' must not specify a value", i)
}
if t.TolerationSeconds != nil && t.Effect != corev1.TaintEffectNoExecute {
return fmt.Errorf("toleration[%d] tolerationSeconds is only valid for effect 'NoExecute'", i)
}
}
return nil
}

func validateNodeSelector(nodeSelector map[string]string) error {
for k := range nodeSelector {
if strings.TrimSpace(k) == "" {
return fmt.Errorf("node selector must not contain an empty label key")
}
}
return nil
}

func customModelValidation(model *models.Model, version *models.Version) requestValidator {
return newFuncValidate(func() error {
if model.Type == models.ModelTypeCustom {
Expand Down
Loading
Loading