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
94 changes: 9 additions & 85 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,104 +5,28 @@ on:
branches:
- main
pull_request:
workflow_dispatch:

permissions:
contents: read
packages: write
packages: read

concurrency:
group: e2e-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.sha }}
cancel-in-progress: true

jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 60
env:
CHART_DIR: charts/llm
CHART_REGISTRY: oci://ghcr.io/agynio/charts
IMAGE_NAME: ghcr.io/agynio/llm
GOTOOLCHAIN: local
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Determine PR artifact versions
id: artifact
if: ${{ github.event_name == 'pull_request' }}
run: |
set -euo pipefail
base_version="$(grep '^version:' "$CHART_DIR/Chart.yaml" | awk '{print $2}')"
chart_version="${base_version}-pr.${{ github.event.pull_request.number }}.${{ github.run_number }}.${{ github.run_attempt }}"
image_tag="pr-${{ github.event.pull_request.number }}-${{ github.sha }}"
echo "chart_version=${chart_version}" >> "$GITHUB_OUTPUT"
echo "image_tag=${image_tag}" >> "$GITHUB_OUTPUT"

- name: Set up Docker Buildx
if: ${{ github.event_name == 'pull_request' }}
uses: docker/setup-buildx-action@v3

- name: Log in to GHCR
if: ${{ github.event_name == 'pull_request' }}
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Build and push PR image
if: ${{ github.event_name == 'pull_request' }}
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
push: true
tags: ${{ env.IMAGE_NAME }}:${{ steps.artifact.outputs.image_tag }}
cache-from: type=gha,scope=llm-e2e
cache-to: type=gha,scope=llm-e2e,mode=max
build-args: GO_VERSION=1.25

- name: Set up Helm
if: ${{ github.event_name == 'pull_request' }}
uses: azure/setup-helm@v4
with:
version: v3.14.4

- name: Log in to GHCR for Helm
if: ${{ github.event_name == 'pull_request' }}
run: |
set -euo pipefail
echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io --username "${{ github.actor }}" --password-stdin

- name: Build chart dependencies
if: ${{ github.event_name == 'pull_request' }}
run: helm dependency build "$CHART_DIR"

- name: Lint chart
if: ${{ github.event_name == 'pull_request' }}
run: helm lint "$CHART_DIR" --set llm.databaseUrl.value=dummy

- name: Package PR chart
if: ${{ github.event_name == 'pull_request' }}
run: |
set -euo pipefail
mkdir -p dist
helm package "$CHART_DIR" \
--destination dist \
--version "${{ steps.artifact.outputs.chart_version }}" \
--app-version "${{ steps.artifact.outputs.chart_version }}"

- name: Push PR chart
if: ${{ github.event_name == 'pull_request' }}
run: |
set -euo pipefail
chart_name="$(basename "$CHART_DIR")"
helm push "dist/${chart_name}-${{ steps.artifact.outputs.chart_version }}.tgz" "$CHART_REGISTRY"

- name: Provision cluster with PR artifact
if: ${{ github.event_name == 'pull_request' }}
uses: agynio/bootstrap/.github/actions/provision@main
env:
TF_VAR_llm_chart_version: ${{ steps.artifact.outputs.chart_version }}
TF_VAR_llm_image_tag: ${{ steps.artifact.outputs.image_tag }}

- name: Provision cluster
if: ${{ github.event_name != 'pull_request' }}
uses: agynio/bootstrap/.github/actions/provision@main
uses: agynio/e2e/.github/actions/provision-vm@main

- name: Set up Go
uses: actions/setup-go@v5
Expand Down
46 changes: 46 additions & 0 deletions Dockerfile.localtest
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# syntax=docker/dockerfile:1.8
ARG GO_VERSION=1.25
ARG BUF_VERSION=1.66.0

FROM --platform=$BUILDPLATFORM golang:${GO_VERSION}-alpine AS buf
ARG BUF_VERSION
RUN apk add --no-cache curl
RUN curl -sSL \
"https://github.com/bufbuild/buf/releases/download/v${BUF_VERSION}/buf-$(uname -s)-$(uname -m)" \
-o /usr/local/bin/buf && \
chmod +x /usr/local/bin/buf

FROM --platform=$BUILDPLATFORM golang:${GO_VERSION}-alpine AS build

WORKDIR /src

COPY --from=buf /usr/local/bin/buf /usr/local/bin/buf

COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go mod download

COPY .gen ./.gen


COPY . .

ARG TARGETOS TARGETARCH
ENV CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH

RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go build -trimpath -ldflags "-s -w" -o /out/llm ./cmd/llm

FROM alpine:3.21 AS runtime

WORKDIR /app

COPY --from=build /out/llm /app/llm

RUN addgroup -S app && adduser -S app -G app

USER app

ENTRYPOINT ["/app/llm"]
2 changes: 1 addition & 1 deletion devspace.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
version: v2beta1

vars:
LLM_NAMESPACE: platform
LLM_NAMESPACE: agyn-platform

functions:
disable_argocd_sync: |-
Expand Down
82 changes: 82 additions & 0 deletions internal/grpcserver/reference_checks_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package grpcserver

import (
"context"
"testing"

llmv1 "github.com/agynio/llm/.gen/go/agynio/api/llm/v1"
"github.com/agynio/llm/internal/model"
"github.com/google/uuid"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)

type missingModelStore struct{ fakeModelStore }

func (m *missingModelStore) Get(context.Context, uuid.UUID) (model.Model, error) {
return model.Model{}, model.ErrModelNotFound
}

func TestResolveModelExists(t *testing.T) {
organizationID := uuid.MustParse("f79b0bde-9e46-44c0-9756-9eac9f383acd")
modelID := uuid.MustParse("1bb21ea2-03c8-453b-a0ef-c4a12f0f8f2a")
models := &fakeModelStore{getModel: model.Model{ID: modelID, OrganizationID: organizationID}}
server := newTestServer(&fakeProviderStore{}, models)

resp, err := server.ResolveModelExists(contextWithIdentity(), &llmv1.ResolveModelExistsRequest{
ModelId: modelID.String(),
OrganizationId: organizationID.String(),
})
if err != nil {
t.Fatalf("resolve: %v", err)
}
if !resp.GetExists() {
t.Fatal("a model in the organization did not resolve")
}

// Another organization answers exactly as a missing model does, so the
// check cannot be used to probe for models elsewhere.
resp, err = server.ResolveModelExists(contextWithIdentity(), &llmv1.ResolveModelExistsRequest{
ModelId: modelID.String(),
OrganizationId: uuid.NewString(),
})
if err != nil {
t.Fatalf("resolve for another organization: %v", err)
}
if resp.GetExists() {
t.Fatal("a model resolved for an organization it does not belong to")
}
}

func TestResolveModelExistsOnMissingModel(t *testing.T) {
server := newTestServer(&fakeProviderStore{}, &missingModelStore{})

resp, err := server.ResolveModelExists(contextWithIdentity(), &llmv1.ResolveModelExistsRequest{
ModelId: uuid.NewString(),
OrganizationId: uuid.NewString(),
})
if err != nil {
t.Fatalf("a missing model should answer false, not error: %v", err)
}
if resp.GetExists() {
t.Fatal("a missing model resolved")
}
}

func TestResolveModelExistsRejectsMalformedIDs(t *testing.T) {
server := newTestServer(&fakeProviderStore{}, &fakeModelStore{})

if _, err := server.ResolveModelExists(contextWithIdentity(), &llmv1.ResolveModelExistsRequest{
ModelId: "not-a-uuid",
OrganizationId: uuid.NewString(),
}); status.Code(err) != codes.InvalidArgument {
t.Fatalf("want InvalidArgument for a malformed model_id, got %v", err)
}

if _, err := server.ResolveModelExists(contextWithIdentity(), &llmv1.ResolveModelExistsRequest{
ModelId: uuid.NewString(),
OrganizationId: "not-a-uuid",
}); status.Code(err) != codes.InvalidArgument {
t.Fatalf("want InvalidArgument for a malformed organization_id, got %v", err)
}
}
31 changes: 31 additions & 0 deletions internal/grpcserver/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,37 @@ func (s *Server) ListModels(ctx context.Context, req *llmv1.ListModelsRequest) (
return resp, nil
}

// ResolveModelExists reports whether a model exists in an organization.
//
// It is a dedicated existence check rather than GetModel because the caller is
// another service validating a reference before it persists it — today the
// Apps Service, resolving an x-agyn-ref property on an app installation. It
// acts for an admin whose own `can_use` on the model is beside the point, and
// it needs one boolean rather than a record it would have to be trusted not to
// leak. Internal only: no OpenFGA check.
//
// A model in another organization answers false exactly as a nonexistent one
// does, so the caller cannot use the check to probe for models elsewhere.
func (s *Server) ResolveModelExists(ctx context.Context, req *llmv1.ResolveModelExistsRequest) (*llmv1.ResolveModelExistsResponse, error) {
modelID, err := parseUUID(req.GetModelId(), "model_id")
if err != nil {
return nil, err
}
organizationID, err := parseUUID(req.GetOrganizationId(), "organization_id")
if err != nil {
return nil, err
}

mdl, err := s.models.Get(ctx, modelID)
if err != nil {
if errors.Is(err, model.ErrModelNotFound) {
return &llmv1.ResolveModelExistsResponse{Exists: false}, nil
}
return nil, toStatusError(err)
}
return &llmv1.ResolveModelExistsResponse{Exists: mdl.OrganizationID == organizationID}, nil
}

func (s *Server) ResolveModel(ctx context.Context, req *llmv1.ResolveModelRequest) (*llmv1.ResolveModelResponse, error) {
modelID, err := parseUUID(req.GetModelId(), "model_id")
if err != nil {
Expand Down
Loading