From 08cd6c30b316b622d6ee10426d9bb73a43d99ce1 Mon Sep 17 00:00:00 2001 From: willdavsmith Date: Thu, 9 Jul 2026 15:30:32 -0700 Subject: [PATCH] Add Azure resource type samples Add ten self-contained samples for published Radius resource types backed by Azure recipes. Wire source-built workloads through containerImages, replace verification-only tools with SQLPad and a Go Azure Search API, and document registry requirements. Preserve nested outputs.secrets contracts for sensitive recipe outputs. Signed-off-by: willdavsmith --- README.md | 17 ++ samples/azure-search-api/README.md | 35 +++ samples/azure-search-api/app.bicep | 64 +++++ samples/azure-search-api/bicepconfig.json | 8 + samples/azure-search-api/env-azure.bicep | 86 ++++++ samples/azure-search-api/src/Dockerfile | 11 + samples/azure-search-api/src/go.mod | 3 + samples/azure-search-api/src/handlers.go | 95 +++++++ samples/azure-search-api/src/main.go | 48 ++++ samples/azure-search-api/src/main_test.go | 259 ++++++++++++++++++ samples/azure-search-api/src/search.go | 160 +++++++++++ samples/berriai-litellm/README.md | 34 +++ samples/berriai-litellm/app.bicep | 91 ++++++ samples/berriai-litellm/bicepconfig.json | 8 + samples/berriai-litellm/env-azure.bicep | 103 +++++++ samples/dockersamples-todo-list-app/README.md | 32 +++ samples/dockersamples-todo-list-app/app.bicep | 75 +++++ .../bicepconfig.json | 8 + .../env-azure.bicep | 115 ++++++++ samples/drakkan-sftpgo/README.md | 34 +++ samples/drakkan-sftpgo/app.bicep | 100 +++++++ samples/drakkan-sftpgo/bicepconfig.json | 8 + samples/drakkan-sftpgo/env-azure.bicep | 102 +++++++ samples/kafka-ui/README.md | 30 ++ samples/kafka-ui/app.bicep | 71 +++++ samples/kafka-ui/bicepconfig.json | 8 + samples/kafka-ui/env-azure.bicep | 78 ++++++ samples/mongo-express-mongo-express/README.md | 34 +++ samples/mongo-express-mongo-express/app.bicep | 84 ++++++ .../bicepconfig.json | 8 + .../env-azure.bicep | 104 +++++++ samples/radius-project-samples-demo/README.md | 34 +++ samples/radius-project-samples-demo/app.bicep | 66 +++++ .../bicepconfig.json | 8 + .../env-azure.bicep | 94 +++++++ samples/sosedoff-pgweb/README.md | 32 +++ samples/sosedoff-pgweb/app.bicep | 71 +++++ samples/sosedoff-pgweb/bicepconfig.json | 8 + samples/sosedoff-pgweb/env-azure.bicep | 117 ++++++++ samples/sqlpad-sqlpad/README.md | 32 +++ samples/sqlpad-sqlpad/app.bicep | 107 ++++++++ samples/sqlpad-sqlpad/bicepconfig.json | 8 + samples/sqlpad-sqlpad/env-azure.bicep | 94 +++++++ samples/warpstreamlabs-bento/README.md | 34 +++ samples/warpstreamlabs-bento/app.bicep | 128 +++++++++ samples/warpstreamlabs-bento/bicepconfig.json | 8 + samples/warpstreamlabs-bento/env-azure.bicep | 93 +++++++ 47 files changed, 2847 insertions(+) create mode 100644 samples/azure-search-api/README.md create mode 100644 samples/azure-search-api/app.bicep create mode 100644 samples/azure-search-api/bicepconfig.json create mode 100644 samples/azure-search-api/env-azure.bicep create mode 100644 samples/azure-search-api/src/Dockerfile create mode 100644 samples/azure-search-api/src/go.mod create mode 100644 samples/azure-search-api/src/handlers.go create mode 100644 samples/azure-search-api/src/main.go create mode 100644 samples/azure-search-api/src/main_test.go create mode 100644 samples/azure-search-api/src/search.go create mode 100644 samples/berriai-litellm/README.md create mode 100644 samples/berriai-litellm/app.bicep create mode 100644 samples/berriai-litellm/bicepconfig.json create mode 100644 samples/berriai-litellm/env-azure.bicep create mode 100644 samples/dockersamples-todo-list-app/README.md create mode 100644 samples/dockersamples-todo-list-app/app.bicep create mode 100644 samples/dockersamples-todo-list-app/bicepconfig.json create mode 100644 samples/dockersamples-todo-list-app/env-azure.bicep create mode 100644 samples/drakkan-sftpgo/README.md create mode 100644 samples/drakkan-sftpgo/app.bicep create mode 100644 samples/drakkan-sftpgo/bicepconfig.json create mode 100644 samples/drakkan-sftpgo/env-azure.bicep create mode 100644 samples/kafka-ui/README.md create mode 100644 samples/kafka-ui/app.bicep create mode 100644 samples/kafka-ui/bicepconfig.json create mode 100644 samples/kafka-ui/env-azure.bicep create mode 100644 samples/mongo-express-mongo-express/README.md create mode 100644 samples/mongo-express-mongo-express/app.bicep create mode 100644 samples/mongo-express-mongo-express/bicepconfig.json create mode 100644 samples/mongo-express-mongo-express/env-azure.bicep create mode 100644 samples/radius-project-samples-demo/README.md create mode 100644 samples/radius-project-samples-demo/app.bicep create mode 100644 samples/radius-project-samples-demo/bicepconfig.json create mode 100644 samples/radius-project-samples-demo/env-azure.bicep create mode 100644 samples/sosedoff-pgweb/README.md create mode 100644 samples/sosedoff-pgweb/app.bicep create mode 100644 samples/sosedoff-pgweb/bicepconfig.json create mode 100644 samples/sosedoff-pgweb/env-azure.bicep create mode 100644 samples/sqlpad-sqlpad/README.md create mode 100644 samples/sqlpad-sqlpad/app.bicep create mode 100644 samples/sqlpad-sqlpad/bicepconfig.json create mode 100644 samples/sqlpad-sqlpad/env-azure.bicep create mode 100644 samples/warpstreamlabs-bento/README.md create mode 100644 samples/warpstreamlabs-bento/app.bicep create mode 100644 samples/warpstreamlabs-bento/bicepconfig.json create mode 100644 samples/warpstreamlabs-bento/env-azure.bicep diff --git a/README.md b/README.md index fe3ef652..48a110ef 100644 --- a/README.md +++ b/README.md @@ -21,3 +21,20 @@ The current repository offers a codespace setup with Radius and its dependencies | **eshop** | A Rad-ified version of eShop on Containers, the .NET reference app | **eshop-dapr** | A Rad-ified version of eShop on Dapr | **volumes** | An app to interact with mounted volumes + +### Resource type samples + +Each of these samples connects a real third-party application to a cloud resource provisioned through a Radius resource type wired directly to a standard Azure Verified Module (AVM). Every directory is self-contained (`app.bicep`, `env-azure.bicep`, `bicepconfig.json`, and any application source) with its own README. + +| Sample | Resource type | Application | +|--------|---------------|-------------| +| **kafka-ui** | `Radius.Messaging/kafka` (Azure Event Hubs) | Kafbat Kafka UI | +| **berriai-litellm** | `Radius.AI/models` (Azure OpenAI) | LiteLLM proxy | +| **mongo-express-mongo-express** | `Radius.Data/mongoDatabases` (Azure Cosmos DB for MongoDB) | mongo-express | +| **dockersamples-todo-list-app** | `Radius.Data/mySqlDatabases` (Azure MySQL) | Docker todo-list-app | +| **sosedoff-pgweb** | `Radius.Data/postgreSqlDatabases` (Azure PostgreSQL) | pgweb, built from source and run | +| **warpstreamlabs-bento** | `Radius.Messaging/rabbitMQ` (Azure Service Bus) | Bento streaming pipeline, built from source | +| **radius-project-samples-demo** | `Radius.Data/redisCaches` (Azure Managed Redis) | Radius samples demo app | +| **sqlpad-sqlpad** | `Radius.Data/sqlServerDatabases` (Azure SQL) | SQLPad SQL client UI, built from source | +| **azure-search-api** | `Radius.AI/search` (Azure AI Search) | Go search API service, built from source | +| **drakkan-sftpgo** | `Radius.Storage/objectStorage` (Azure Blob Storage) | SFTPGo | diff --git a/samples/azure-search-api/README.md b/samples/azure-search-api/README.md new file mode 100644 index 00000000..1ddeddb9 --- /dev/null +++ b/samples/azure-search-api/README.md @@ -0,0 +1,35 @@ +# Go search API service + Radius.AI/search (Azure AI Search) + +This sample provisions an Azure AI Search service through `Radius.AI/search` using an Azure Verified Module (AVM) recipe. It builds and runs a small Go HTTP API that stores documents in the search index and queries them through the Azure AI Search REST API. + +## What this sample shows +- **Resource type:** `Radius.AI/search` +- **Cloud backing (Azure):** Azure AI Search via the AVM `mcr.microsoft.com/bicep/avm/res/search/search-service:0.12.2` module (`env-azure.bicep`). +- **Application:** Go search API service `v1`, built from source via `Radius.Compute/containerImages` and run via `imageReference`. +- **Credential model:** The recipe exposes the admin key under nested `outputs.secrets.apiKey`. The app declares `connections.search` (Radius auto-injects the non-secret `CONNECTION_SEARCH_ENDPOINT`) and consumes the key explicitly as `CONNECTION_SEARCH_APIKEY` via a `secretKeyRef` backed by `searchService.properties.secrets`, which the Go service reads. + +## Files +| File | Role | +| --- | --- | +| `app.bicep` | Developer view: the Radius application, search resource, search API image build, and search API container. | +| `env-azure.bicep` | Platform-engineer view: the `recipePacks` binding the type to the AVM module plus supporting recipes, and the `environment`. | +| `bicepconfig.json` | Radius Bicep extension configuration. | +| `src/` | Go source for the search API service (stdlib only), its tests, and Dockerfile. | + +## Deploying +Deploy `env-azure.bicep` (the environment) first, then `app.bicep`. + +This sample builds its container image from source with a `Radius.Compute/containerImages` recipe and pushes it to a registry you supply. When deploying `env-azure.bicep`, provide: +- `containerImagesRegistry` — an OCI registry you can push to (e.g. `ghcr.io/your-org`). +- `containerImagesRegistrySecretName` — the name of a Kubernetes Secret (`username`/`password`) in the environment namespace, used to authenticate the BuildKit **push** of the built image. Create it before deploying. + +Image **pull** auth is separate: the `containerImages` recipe does not configure kubelet pull credentials, so the built image repository must be publicly readable by the cluster, or you must configure a Kubernetes image-pull secret (e.g. a `kubernetes.io/dockerconfigjson` `imagePullSecret` on the namespace's `default` ServiceAccount) before deploying `app.bicep` — otherwise the app pods fail with `ImagePullBackOff`. + +## Notes +The containerImages recipe builds from `git::https://github.com/radius-project/samples.git//samples/azure-search-api/src?ref=edge`; this source lands in this repo on merge. The service uses only the Go standard library, calls Azure AI Search REST API version `2024-07-01`, ensures an index on startup, and exposes `GET /healthz`, `POST /documents`, and `GET /search?q=...` on port 8080. + +The default index name is `radius-sample`, overridable with `SEARCH_INDEX_NAME`. `POST /documents` accepts JSON documents with `id` and `content`, and `/search` returns the upstream Azure AI Search JSON response. + +Additional deployment details: +- The Azure AI Search service uses the Basic SKU with one replica and one partition. +- The container image builds with the Dockerfile under `src/`. diff --git a/samples/azure-search-api/app.bicep b/samples/azure-search-api/app.bicep new file mode 100644 index 00000000..71f08a15 --- /dev/null +++ b/samples/azure-search-api/app.bicep @@ -0,0 +1,64 @@ +extension radius + +@description('The ID of your Radius Environment. Set automatically by the rad CLI.') +param environment string + +resource app 'Radius.Core/applications@2025-08-01-preview' = { + name: 'search-azure-app-test' + properties: { + environment: environment + } +} + +resource searchService 'Radius.AI/search@2025-08-01-preview' = { + name: 'search' + properties: { + environment: environment + application: app.id + } +} + +resource searchApiImage 'Radius.Compute/containerImages@2025-08-01-preview' = { + name: 'search-api-image' + properties: { + environment: environment + application: app.id + tag: 'v1' + build: { + source: 'git::https://github.com/radius-project/samples.git//samples/azure-search-api/src?ref=edge' + } + } +} + +resource searchapictr 'Radius.Compute/containers@2025-08-01-preview' = { + name: 'searchapictr' + properties: { + environment: environment + application: app.id + containers: { + searchapi: { + image: searchApiImage.properties.imageReference + ports: { + web: { + containerPort: 8080 + } + } + env: { + CONNECTION_SEARCH_APIKEY: { + valueFrom: { + secretKeyRef: { + secretName: searchService.properties.secrets.name + key: 'apiKey' + } + } + } + } + } + } + connections: { + search: { + source: searchService.id + } + } + } +} diff --git a/samples/azure-search-api/bicepconfig.json b/samples/azure-search-api/bicepconfig.json new file mode 100644 index 00000000..ba32f165 --- /dev/null +++ b/samples/azure-search-api/bicepconfig.json @@ -0,0 +1,8 @@ +{ + "experimentalFeaturesEnabled": { + "extensibility": true + }, + "extensions": { + "radius": "br:biceptypes.azurecr.io/radius:latest" + } +} diff --git a/samples/azure-search-api/env-azure.bicep b/samples/azure-search-api/env-azure.bicep new file mode 100644 index 00000000..e2560cbb --- /dev/null +++ b/samples/azure-search-api/env-azure.bicep @@ -0,0 +1,86 @@ +extension radius + +@description('Azure subscription ID the environment provisions resources into.') +param azureSubscriptionId string + +@description('Azure resource group the environment provisions resources into. Must already exist.') +param azureResourceGroup string + +@description('Globally-unique Azure AI Search service name (2-60 lowercase letters, digits, or hyphens; cannot start or end with a hyphen). The workflow generates a unique value per run.') +param searchServiceName string + +@description('OCI ref for the Radius.Compute/containers recipe. Override to a custom build if needed; defaults to the released public recipe.') +param containersRecipe string = 'ghcr.io/radius-project/kube-recipes/containers:latest' + +@description('OCI registry the containerImages recipe builds and pushes the search-api image to (e.g. `ghcr.io/myorg`).') +param containerImagesRegistry string + +@description('Kubernetes Secret (in the environment namespace) holding the push registry `username`/`password`.') +param containerImagesRegistrySecretName string + +resource recipes 'Radius.Core/recipePacks@2025-08-01-preview' = { + name: 'search-azure-avm' + properties: { + recipes: { + 'Radius.AI/search': { + kind: 'bicep' + + source: 'mcr.microsoft.com/bicep/avm/res/search/search-service:0.12.2' + parameters: { + name: searchServiceName + sku: 'basic' + disableLocalAuth: false + replicaCount: 1 + partitionCount: 1 + + enableTelemetry: false + } + + outputs: { + endpoint: 'endpoint' + secrets: { + apiKey: 'primaryKey' + } + } + } + + 'Radius.Compute/containers': { + kind: 'bicep' + source: containersRecipe + } + + 'Radius.Security/secrets': { + kind: 'bicep' + source: 'ghcr.io/radius-project/kube-recipes/secrets:latest' + } + + 'Radius.Compute/containerImages': { + kind: 'terraform' + source: 'git::https://github.com/radius-project/resource-types-contrib.git//Compute/containerImages/recipes/kubernetes/terraform?ref=b9e0fad536a53349b98f94c5be961db84845e1b7' + parameters: { + registry: containerImagesRegistry + registrySecretName: containerImagesRegistrySecretName + } + } + } + } +} + +resource env 'Radius.Core/environments@2025-08-01-preview' = { + name: 'azure' + properties: { + providers: { + azure: { + subscriptionId: azureSubscriptionId + resourceGroupName: azureResourceGroup + } + + kubernetes: { + namespace: 'default' + } + } + recipePacks: [ + recipes.id + ] + } +} diff --git a/samples/azure-search-api/src/Dockerfile b/samples/azure-search-api/src/Dockerfile new file mode 100644 index 00000000..9eb1d460 --- /dev/null +++ b/samples/azure-search-api/src/Dockerfile @@ -0,0 +1,11 @@ +FROM golang:1.23-alpine AS build +WORKDIR /src +COPY go.mod ./ +COPY *.go ./ +RUN CGO_ENABLED=0 GOOS=linux go build -o /out/azure-search-api . + +FROM gcr.io/distroless/static-debian12 +COPY --from=build /out/azure-search-api /azure-search-api +EXPOSE 8080 +USER nonroot:nonroot +ENTRYPOINT ["/azure-search-api"] diff --git a/samples/azure-search-api/src/go.mod b/samples/azure-search-api/src/go.mod new file mode 100644 index 00000000..c0adc38e --- /dev/null +++ b/samples/azure-search-api/src/go.mod @@ -0,0 +1,3 @@ +module github.com/radius-project/samples/azure-search-api + +go 1.23 diff --git a/samples/azure-search-api/src/handlers.go b/samples/azure-search-api/src/handlers.go new file mode 100644 index 00000000..bea70c22 --- /dev/null +++ b/samples/azure-search-api/src/handlers.go @@ -0,0 +1,95 @@ +package main + +import ( + "encoding/json" + "log" + "net/http" +) + +type Server struct { + client *SearchClient + logger *log.Logger +} + +func NewServer(client *SearchClient, logger *log.Logger) http.Handler { + if logger == nil { + logger = log.Default() + } + server := &Server{client: client, logger: logger} + + mux := http.NewServeMux() + mux.HandleFunc("/healthz", server.healthz) + mux.HandleFunc("/documents", server.documents) + mux.HandleFunc("/search", server.search) + return mux +} + +func (s *Server) healthz(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + w.Header().Set("content-type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"ok"}`)) +} + +func (s *Server) documents(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + defer r.Body.Close() + + var doc Document + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&doc); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON document") + return + } + if doc.ID == "" || doc.Content == "" { + writeError(w, http.StatusBadRequest, "id and content are required") + return + } + + if err := s.client.UploadDocument(r.Context(), doc); err != nil { + s.logger.Printf("document upload failed: %v", err) + writeError(w, http.StatusBadGateway, "search service request failed") + return + } + + w.Header().Set("content-type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"ok"}`)) +} + +func (s *Server) search(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + query := r.URL.Query().Get("q") + if query == "" { + writeError(w, http.StatusBadRequest, "q query parameter is required") + return + } + + body, err := s.client.Search(r.Context(), query) + if err != nil { + s.logger.Printf("search failed: %v", err) + writeError(w, http.StatusBadGateway, "search service request failed") + return + } + + w.Header().Set("content-type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(body) +} + +func writeError(w http.ResponseWriter, status int, message string) { + w.Header().Set("content-type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]string{"error": message}) +} diff --git a/samples/azure-search-api/src/main.go b/samples/azure-search-api/src/main.go new file mode 100644 index 00000000..53c71319 --- /dev/null +++ b/samples/azure-search-api/src/main.go @@ -0,0 +1,48 @@ +package main + +import ( + "context" + "log" + "net/http" + "os" + "time" +) + +const ( + defaultPort = "8080" + defaultIndexName = "radius-sample" +) + +func main() { + logger := log.New(os.Stdout, "azure-search-api: ", log.LstdFlags) + + port := getenvDefault("PORT", defaultPort) + indexName := getenvDefault("SEARCH_INDEX_NAME", defaultIndexName) + client := NewSearchClient( + os.Getenv("CONNECTION_SEARCH_ENDPOINT"), + os.Getenv("CONNECTION_SEARCH_APIKEY"), + indexName, + &http.Client{Timeout: 30 * time.Second}, + ) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + if err := client.EnsureIndex(ctx); err != nil { + logger.Printf("warning: unable to ensure search index %q at startup: %v", indexName, err) + } else { + logger.Printf("search index %q is ready", indexName) + } + cancel() + + addr := ":" + port + logger.Printf("listening on %s", addr) + if err := http.ListenAndServe(addr, NewServer(client, logger)); err != nil { + logger.Fatalf("server stopped: %v", err) + } +} + +func getenvDefault(name, fallback string) string { + if value := os.Getenv(name); value != "" { + return value + } + return fallback +} diff --git a/samples/azure-search-api/src/main_test.go b/samples/azure-search-api/src/main_test.go new file mode 100644 index 00000000..2ba8b97a --- /dev/null +++ b/samples/azure-search-api/src/main_test.go @@ -0,0 +1,259 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "io" + "log" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" +) + +const testAPIKey = "super-secret-test-key" + +type recordedRequest struct { + Method string + Path string + Query string + APIKey string + Body []byte +} + +type recordingUpstream struct { + t *testing.T + server *httptest.Server + mu sync.Mutex + requests []recordedRequest +} + +func newRecordingUpstream(t *testing.T) *recordingUpstream { + t.Helper() + ru := &recordingUpstream{t: t} + ru.server = httptest.NewServer(http.HandlerFunc(ru.handle)) + t.Cleanup(ru.server.Close) + return ru +} + +func (ru *recordingUpstream) handle(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + ru.t.Errorf("read body: %v", err) + } + ru.mu.Lock() + ru.requests = append(ru.requests, recordedRequest{ + Method: r.Method, + Path: r.URL.Path, + Query: r.URL.RawQuery, + APIKey: r.Header.Get("api-key"), + Body: body, + }) + ru.mu.Unlock() + + if r.URL.Query().Get("api-version") != searchAPIVersion { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"bad api-version"}`)) + return + } + if r.Header.Get("api-key") != testAPIKey { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"bad api-key"}`)) + return + } + + switch { + case r.Method == http.MethodPut && r.URL.Path == "/indexes/test-index": + w.WriteHeader(http.StatusCreated) + case r.Method == http.MethodPost && r.URL.Path == "/indexes/test-index/docs/index": + w.Header().Set("content-type", "application/json") + _, _ = w.Write([]byte(`{"value":[{"key":"doc-1","status":true}]}`)) + case r.Method == http.MethodPost && r.URL.Path == "/indexes/test-index/docs/search": + w.Header().Set("content-type", "application/json") + _, _ = w.Write([]byte(`{"value":[{"id":"doc-1","content":"hello world"}]}`)) + default: + w.WriteHeader(http.StatusNotFound) + } +} + +func (ru *recordingUpstream) lastRequest(t *testing.T) recordedRequest { + t.Helper() + ru.mu.Lock() + defer ru.mu.Unlock() + if len(ru.requests) == 0 { + t.Fatal("expected at least one upstream request") + } + return ru.requests[len(ru.requests)-1] +} + +func (ru *recordingUpstream) requestCount() int { + ru.mu.Lock() + defer ru.mu.Unlock() + return len(ru.requests) +} + +func testClient(endpoint string) *SearchClient { + return NewSearchClient(endpoint, testAPIKey, "test-index", &http.Client{Timeout: 5 * time.Second}) +} + +func TestHealthzDoesNotCallUpstream(t *testing.T) { + ru := newRecordingUpstream(t) + handler := NewServer(testClient(ru.server.URL), log.New(io.Discard, "", 0)) + + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + if strings.TrimSpace(rec.Body.String()) != `{"status":"ok"}` { + t.Fatalf("body = %q", rec.Body.String()) + } + if got := ru.requestCount(); got != 0 { + t.Fatalf("upstream request count = %d, want 0", got) + } +} + +func TestEnsureIndexIssuesCorrectPUT(t *testing.T) { + ru := newRecordingUpstream(t) + client := testClient(ru.server.URL) + + if err := client.EnsureIndex(context.Background()); err != nil { + t.Fatalf("EnsureIndex() error = %v", err) + } + + got := ru.lastRequest(t) + if got.Method != http.MethodPut { + t.Fatalf("method = %s, want PUT", got.Method) + } + if got.Path != "/indexes/test-index" { + t.Fatalf("path = %s", got.Path) + } + if got.Query != "api-version=2024-07-01" { + t.Fatalf("query = %s", got.Query) + } + if got.APIKey != testAPIKey { + t.Fatalf("api-key header = %q", got.APIKey) + } + + var payload struct { + Name string `json:"name"` + Fields []struct { + Name string `json:"name"` + Type string `json:"type"` + Key bool `json:"key"` + Searchable bool `json:"searchable"` + } `json:"fields"` + } + if err := json.Unmarshal(got.Body, &payload); err != nil { + t.Fatalf("unmarshal body: %v", err) + } + if payload.Name != "test-index" || len(payload.Fields) != 2 { + t.Fatalf("unexpected index payload: %+v", payload) + } + if payload.Fields[0].Name != "id" || payload.Fields[0].Type != "Edm.String" || !payload.Fields[0].Key || payload.Fields[0].Searchable { + t.Fatalf("unexpected id field: %+v", payload.Fields[0]) + } + if payload.Fields[1].Name != "content" || payload.Fields[1].Type != "Edm.String" || payload.Fields[1].Key || !payload.Fields[1].Searchable { + t.Fatalf("unexpected content field: %+v", payload.Fields[1]) + } +} + +func TestPostDocumentsForwardsMergeOrUpload(t *testing.T) { + ru := newRecordingUpstream(t) + handler := NewServer(testClient(ru.server.URL), log.New(io.Discard, "", 0)) + + req := httptest.NewRequest(http.MethodPost, "/documents", strings.NewReader(`{"id":"doc-1","content":"hello world"}`)) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + got := ru.lastRequest(t) + if got.Method != http.MethodPost || got.Path != "/indexes/test-index/docs/index" || got.Query != "api-version=2024-07-01" { + t.Fatalf("unexpected upstream request: %+v", got) + } + + var payload struct { + Value []map[string]string `json:"value"` + } + if err := json.Unmarshal(got.Body, &payload); err != nil { + t.Fatalf("unmarshal upload body: %v", err) + } + if len(payload.Value) != 1 || payload.Value[0]["@search.action"] != "mergeOrUpload" || payload.Value[0]["id"] != "doc-1" || payload.Value[0]["content"] != "hello world" { + t.Fatalf("unexpected upload body: %+v", payload) + } +} + +func TestSearchForwardsQueryAndReturnsResults(t *testing.T) { + ru := newRecordingUpstream(t) + handler := NewServer(testClient(ru.server.URL), log.New(io.Discard, "", 0)) + + req := httptest.NewRequest(http.MethodGet, "/search?q=hello", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + if strings.TrimSpace(rec.Body.String()) != `{"value":[{"id":"doc-1","content":"hello world"}]}` { + t.Fatalf("unexpected search response: %s", rec.Body.String()) + } + got := ru.lastRequest(t) + if got.Method != http.MethodPost || got.Path != "/indexes/test-index/docs/search" || got.Query != "api-version=2024-07-01" { + t.Fatalf("unexpected upstream request: %+v", got) + } + var payload map[string]string + if err := json.Unmarshal(got.Body, &payload); err != nil { + t.Fatalf("unmarshal search body: %v", err) + } + if payload["search"] != "hello" { + t.Fatalf("search payload = %+v", payload) + } +} + +func TestAPIKeyIsNotLoggedOrReturnedOnErrors(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"do not expose ` + testAPIKey + `"}`)) + })) + defer upstream.Close() + + var logs bytes.Buffer + handler := NewServer(testClient(upstream.URL), log.New(&logs, "", 0)) + + req := httptest.NewRequest(http.MethodPost, "/documents", strings.NewReader(`{"id":"doc-1","content":"hello"}`)) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadGateway { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadGateway) + } + if strings.Contains(rec.Body.String(), testAPIKey) { + t.Fatalf("response exposed api key: %s", rec.Body.String()) + } + if strings.Contains(logs.String(), testAPIKey) { + t.Fatalf("logs exposed api key: %s", logs.String()) + } +} + +func TestEnsureIndexErrorDoesNotContainAPIKey(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"` + testAPIKey + `"}`)) + })) + defer upstream.Close() + + err := testClient(upstream.URL).EnsureIndex(context.Background()) + if err == nil { + t.Fatal("expected error") + } + if strings.Contains(err.Error(), testAPIKey) { + t.Fatalf("error exposed api key: %v", err) + } +} diff --git a/samples/azure-search-api/src/search.go b/samples/azure-search-api/src/search.go new file mode 100644 index 00000000..5753f51e --- /dev/null +++ b/samples/azure-search-api/src/search.go @@ -0,0 +1,160 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +const searchAPIVersion = "2024-07-01" + +// SearchClient is a small Azure AI Search REST client. +type SearchClient struct { + endpoint string + apiKey string + indexName string + httpClient *http.Client +} + +// Document is the JSON payload accepted by POST /documents. +type Document struct { + ID string `json:"id"` + Content string `json:"content"` +} + +func NewSearchClient(endpoint, apiKey, indexName string, httpClient *http.Client) *SearchClient { + if httpClient == nil { + httpClient = &http.Client{Timeout: 30 * time.Second} + } + return &SearchClient{ + endpoint: strings.TrimRight(strings.TrimSpace(endpoint), "/"), + apiKey: apiKey, + indexName: indexName, + httpClient: httpClient, + } +} + +func (c *SearchClient) EnsureIndex(ctx context.Context) error { + body := map[string]any{ + "name": c.indexName, + "fields": []map[string]any{ + { + "name": "id", + "type": "Edm.String", + "key": true, + "searchable": false, + }, + { + "name": "content", + "type": "Edm.String", + "searchable": true, + }, + }, + } + + status, _, err := c.doJSON(ctx, http.MethodPut, "/indexes/"+url.PathEscape(c.indexName), body) + if err != nil { + return err + } + if status == http.StatusOK || status == http.StatusCreated || status == http.StatusNoContent { + return nil + } + return fmt.Errorf("ensure index failed: upstream returned HTTP %d", status) +} + +func (c *SearchClient) UploadDocument(ctx context.Context, doc Document) error { + body := map[string]any{ + "value": []map[string]any{ + { + "@search.action": "mergeOrUpload", + "id": doc.ID, + "content": doc.Content, + }, + }, + } + + status, _, err := c.doJSON(ctx, http.MethodPost, "/indexes/"+url.PathEscape(c.indexName)+"/docs/index", body) + if err != nil { + return err + } + if status != http.StatusOK { + return fmt.Errorf("upload document failed: upstream returned HTTP %d", status) + } + return nil +} + +func (c *SearchClient) Search(ctx context.Context, query string) ([]byte, error) { + body := map[string]string{"search": query} + status, responseBody, err := c.doJSON(ctx, http.MethodPost, "/indexes/"+url.PathEscape(c.indexName)+"/docs/search", body) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, fmt.Errorf("search failed: upstream returned HTTP %d", status) + } + return responseBody, nil +} + +func (c *SearchClient) doJSON(ctx context.Context, method, path string, payload any) (int, []byte, error) { + if c.endpoint == "" { + return 0, nil, errors.New("search endpoint is not configured") + } + if c.apiKey == "" { + return 0, nil, errors.New("search api key is not configured") + } + if c.indexName == "" { + return 0, nil, errors.New("search index name is not configured") + } + + requestURL, err := c.url(path) + if err != nil { + return 0, nil, err + } + + var body bytes.Buffer + if err := json.NewEncoder(&body).Encode(payload); err != nil { + return 0, nil, fmt.Errorf("encode search request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, method, requestURL, &body) + if err != nil { + return 0, nil, fmt.Errorf("create search request: %w", err) + } + req.Header.Set("api-key", c.apiKey) + req.Header.Set("accept", "application/json") + req.Header.Set("content-type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return 0, nil, fmt.Errorf("call search service: %w", err) + } + defer resp.Body.Close() + + responseBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return resp.StatusCode, nil, fmt.Errorf("read search response: %w", err) + } + return resp.StatusCode, responseBody, nil +} + +func (c *SearchClient) url(path string) (string, error) { + u, err := url.Parse(c.endpoint) + if err != nil { + return "", fmt.Errorf("parse search endpoint: %w", err) + } + if u.Scheme == "" || u.Host == "" { + return "", errors.New("search endpoint must be an absolute URL") + } + u.Path = strings.TrimRight(u.Path, "/") + path + q := u.Query() + q.Set("api-version", searchAPIVersion) + u.RawQuery = q.Encode() + return u.String(), nil +} diff --git a/samples/berriai-litellm/README.md b/samples/berriai-litellm/README.md new file mode 100644 index 00000000..f72e54da --- /dev/null +++ b/samples/berriai-litellm/README.md @@ -0,0 +1,34 @@ +# LiteLLM proxy + Radius.AI/models (Azure OpenAI) + +This sample provisions an Azure OpenAI account and chat deployment through `Radius.AI/models` using an Azure Verified Module (AVM) recipe. It builds and runs LiteLLM as a proxy that exposes the model through a local OpenAI-compatible endpoint. + +## What this sample shows +- **Resource type:** `Radius.AI/models` +- **Cloud backing (Azure):** Azure OpenAI via the AVM `mcr.microsoft.com/bicep/avm/res/cognitive-services/account:0.15.0` module (`env-azure.bicep`). +- **Application:** LiteLLM `v1.91.0`, built from source via `Radius.Compute/containerImages` and run via `imageReference`. +- **Credential model:** The recipe exposes the API key under nested `outputs.secrets.apiKey`. The app reads the non-secret `model.properties.endpoint` directly and consumes the key as `AZURE_API_KEY` via a `secretKeyRef` backed by `model.properties.secrets`. + +## Files +| File | Role | +| --- | --- | +| `app.bicep` | Developer view: the Radius application, model resource, LiteLLM image build, and LiteLLM container. | +| `env-azure.bicep` | Platform-engineer view: the `recipePacks` binding the type to the AVM module plus supporting recipes, and the `environment`. | +| `bicepconfig.json` | Radius Bicep extension configuration. | + +## Deploying +Deploy `env-azure.bicep` (the environment) first, then `app.bicep`. + +This sample builds its container image from source with a `Radius.Compute/containerImages` recipe and pushes it to a registry you supply. When deploying `env-azure.bicep`, provide: +- `containerImagesRegistry` — an OCI registry you can push to (e.g. `ghcr.io/your-org`). +- `containerImagesRegistrySecretName` — the name of a Kubernetes Secret (`username`/`password`) in the environment namespace, used to authenticate the BuildKit **push** of the built image. Create it before deploying. + +Image **pull** auth is separate: the `containerImages` recipe does not configure kubelet pull credentials, so the built image repository must be publicly readable by the cluster, or you must configure a Kubernetes image-pull secret (e.g. a `kubernetes.io/dockerconfigjson` `imagePullSecret` on the namespace's `default` ServiceAccount) before deploying `app.bicep` — otherwise the app pods fail with `ImagePullBackOff`. + +## Notes +The LiteLLM container writes a minimal config at startup, maps the Azure OpenAI deployment to model name `chat`, and listens on port 4000. It uses Azure OpenAI API version `2025-04-01-preview` and sets a sample `LITELLM_MASTER_KEY`. + +The model resource requests `gpt-5-mini`; the environment recipe creates the matching Azure OpenAI deployment named `chat`. Override the Azure account name with the environment parameter required by your deployment flow. + +Additional deployment details: +- The container exposes the LiteLLM proxy on port 4000. +- The image build uses the upstream LiteLLM repository at tag `v1.91.0`. diff --git a/samples/berriai-litellm/app.bicep b/samples/berriai-litellm/app.bicep new file mode 100644 index 00000000..ece48b8e --- /dev/null +++ b/samples/berriai-litellm/app.bicep @@ -0,0 +1,91 @@ +extension radius + +@description('The ID of your Radius Environment. Set automatically by the rad CLI.') +param environment string + +resource app 'Radius.Core/applications@2025-08-01-preview' = { + name: 'llm-azure-app-test' + properties: { + environment: environment + } +} + +resource model 'Radius.AI/models@2025-08-01-preview' = { + name: 'model' + properties: { + environment: environment + application: app.id + model: 'gpt-5-mini' + } +} + +resource litellmImage 'Radius.Compute/containerImages@2025-08-01-preview' = { + name: 'litellm-image' + properties: { + environment: environment + application: app.id + tag: 'v1.91.0' + build: { + source: 'git::https://github.com/BerriAI/litellm.git//?ref=v1.91.0' + } + } +} + +resource litellmctr 'Radius.Compute/containers@2025-08-01-preview' = { + name: 'litellmctr' + properties: { + environment: environment + application: app.id + containers: { + litellm: { + image: litellmImage.properties.imageReference + command: [ + '/bin/sh' + '-c' + ''' +set -eu +cat > /tmp/litellm.config.yaml <<'EOF' +model_list: + - model_name: chat + litellm_params: + model: azure/chat + api_base: os.environ/AZURE_API_BASE + api_key: os.environ/AZURE_API_KEY + api_version: os.environ/AZURE_API_VERSION +EOF +exec litellm --config /tmp/litellm.config.yaml --host 0.0.0.0 --port 4000 +''' + ] + ports: { + web: { + containerPort: 4000 + } + } + env: { + AZURE_API_BASE: { + value: model.properties.endpoint + } + AZURE_API_KEY: { + valueFrom: { + secretKeyRef: { + secretName: model.properties.secrets.name + key: 'apiKey' + } + } + } + AZURE_API_VERSION: { + value: '2025-04-01-preview' + } + LITELLM_MASTER_KEY: { + value: 'sk-radius-verify' + } + } + } + } + connections: { + model: { + source: model.id + } + } + } +} diff --git a/samples/berriai-litellm/bicepconfig.json b/samples/berriai-litellm/bicepconfig.json new file mode 100644 index 00000000..ba32f165 --- /dev/null +++ b/samples/berriai-litellm/bicepconfig.json @@ -0,0 +1,8 @@ +{ + "experimentalFeaturesEnabled": { + "extensibility": true + }, + "extensions": { + "radius": "br:biceptypes.azurecr.io/radius:latest" + } +} diff --git a/samples/berriai-litellm/env-azure.bicep b/samples/berriai-litellm/env-azure.bicep new file mode 100644 index 00000000..ddeb7ec8 --- /dev/null +++ b/samples/berriai-litellm/env-azure.bicep @@ -0,0 +1,103 @@ +extension radius + +@description('Azure subscription ID the environment provisions resources into.') +param azureSubscriptionId string + +@description('Azure resource group the environment provisions resources into. Must already exist.') +param azureResourceGroup string + +@description('Globally-unique Azure OpenAI account name (2-64 alphanumerics/hyphens). The workflow generates a unique value per run.') +param accountName string + +@description('OCI ref for the Radius.Compute/containers recipe. Override to a custom build if needed; defaults to the released public recipe.') +param containersRecipe string = 'ghcr.io/radius-project/kube-recipes/containers:latest' + +@description('OCI registry the containerImages recipe builds and pushes the LiteLLM image to (e.g. `ghcr.io/myorg`).') +param containerImagesRegistry string + +@description('Kubernetes Secret (in the environment namespace) holding the push registry `username`/`password`.') +param containerImagesRegistrySecretName string + +resource recipes 'Radius.Core/recipePacks@2025-08-01-preview' = { + name: 'llm-azure-avm' + properties: { + recipes: { + 'Radius.AI/models': { + kind: 'bicep' + + source: 'mcr.microsoft.com/bicep/avm/res/cognitive-services/account:0.15.0' + parameters: { + name: accountName + kind: 'OpenAI' + sku: 'S0' + customSubDomainName: accountName + + disableLocalAuth: false + + publicNetworkAccess: 'Enabled' + deployments: [ + { + name: 'chat' + model: { + format: 'OpenAI' + name: '{{context.resource.properties.model}}' + version: '2025-08-07' + } + sku: { + name: 'GlobalStandard' + capacity: 1 + } + } + ] + + enableTelemetry: false + } + + outputs: { + endpoint: 'endpoint' + secrets: { + apiKey: 'primaryKey' + } + } + } + + 'Radius.Compute/containers': { + kind: 'bicep' + source: containersRecipe + } + + 'Radius.Compute/containerImages': { + kind: 'terraform' + source: 'git::https://github.com/radius-project/resource-types-contrib.git//Compute/containerImages/recipes/kubernetes/terraform?ref=b9e0fad536a53349b98f94c5be961db84845e1b7' + parameters: { + registry: containerImagesRegistry + registrySecretName: containerImagesRegistrySecretName + } + } + + 'Radius.Security/secrets': { + kind: 'bicep' + source: 'ghcr.io/radius-project/kube-recipes/secrets:latest' + } + } + } +} + +resource env 'Radius.Core/environments@2025-08-01-preview' = { + name: 'azure' + properties: { + providers: { + azure: { + subscriptionId: azureSubscriptionId + resourceGroupName: azureResourceGroup + } + + kubernetes: { + namespace: 'default' + } + } + recipePacks: [ + recipes.id + ] + } +} diff --git a/samples/dockersamples-todo-list-app/README.md b/samples/dockersamples-todo-list-app/README.md new file mode 100644 index 00000000..45341c06 --- /dev/null +++ b/samples/dockersamples-todo-list-app/README.md @@ -0,0 +1,32 @@ +# Docker todo-list-app + Radius.Data/mySqlDatabases (Azure MySQL) + +This sample provisions an Azure Database for MySQL flexible server and database through `Radius.Data/mySqlDatabases` using an Azure Verified Module (AVM) recipe. It builds and runs Docker's getting-started todo-list-app against that MySQL database. + +## What this sample shows +- **Resource type:** `Radius.Data/mySqlDatabases` +- **Cloud backing (Azure):** Azure Database for MySQL via the AVM `mcr.microsoft.com/bicep/avm/res/db-for-my-sql/flexible-server:0.10.3` module (`env-azure.bicep`). +- **Application:** Docker getting-started todo-list-app `v1.0.0` from commit `55680777bc46c59d3fe0ab9ff7e79ee947d0c757`, built from source via `Radius.Compute/containerImages` and run via `imageReference`. +- **Credential model:** A developer-authored secure `password` parameter is set on the MySQL resource and reused by the app as `MYSQL_PASSWORD`. + +## Files +| File | Role | +| --- | --- | +| `app.bicep` | Developer view: the Radius application, MySQL database resource, todo app image build, and todo app container. | +| `env-azure.bicep` | Platform-engineer view: the `recipePacks` binding the type to the AVM module plus supporting recipes, and the `environment`. | +| `bicepconfig.json` | Radius Bicep extension configuration. | + +## Deploying +Deploy `env-azure.bicep` (the environment) first, then `app.bicep`. + +This sample builds its container image from source with a `Radius.Compute/containerImages` recipe and pushes it to a registry you supply. When deploying `env-azure.bicep`, provide: +- `containerImagesRegistry` — an OCI registry you can push to (e.g. `ghcr.io/your-org`). +- `containerImagesRegistrySecretName` — the name of a Kubernetes Secret (`username`/`password`) in the environment namespace, used to authenticate the BuildKit **push** of the built image. Create it before deploying. + +Image **pull** auth is separate: the `containerImages` recipe does not configure kubelet pull credentials, so the built image repository must be publicly readable by the cluster, or you must configure a Kubernetes image-pull secret (e.g. a `kubernetes.io/dockerconfigjson` `imagePullSecret` on the namespace's `default` ServiceAccount) before deploying `app.bicep` — otherwise the app pods fail with `ImagePullBackOff`. + +Provide the database admin `password` parameter when deploying `app.bicep`. + +## Notes +The MySQL recipe creates database `appdb`, admin user `radadmin`, and a firewall rule for the supplied client egress IP. The sample disables MySQL `require_secure_transport` so the todo app can connect with its standard MySQL settings. + +The todo app listens on port 3000 and receives `MYSQL_HOST`, `MYSQL_DB`, `MYSQL_USER`, and `MYSQL_PASSWORD` from `app.bicep`. The Radius database resource uses MySQL version `8.0`. diff --git a/samples/dockersamples-todo-list-app/app.bicep b/samples/dockersamples-todo-list-app/app.bicep new file mode 100644 index 00000000..b0af1fd2 --- /dev/null +++ b/samples/dockersamples-todo-list-app/app.bicep @@ -0,0 +1,75 @@ +extension radius +@description('The ID of your Radius Environment. Set automatically by the rad CLI.') +param environment string + +@description('Database admin password. Marked @secure(); Radius encrypts it and injects it into the recipe and the container.') +@secure() +param password string + +var databaseName = 'appdb' + +resource app 'Radius.Core/applications@2025-08-01-preview' = { + name: 'mysql-azure-app-test' + properties: { + environment: environment + } +} + +resource mysql 'Radius.Data/mySqlDatabases@2025-08-01-preview' = { + name: 'mysql' + properties: { + environment: environment + application: app.id + version: '8.0' + database: databaseName + + username: 'radadmin' + password: password + } +} + +resource todoAppImage 'Radius.Compute/containerImages@2025-08-01-preview' = { + name: 'todo-app-image' + properties: { + environment: environment + application: app.id + + tag: 'v1.0.0' + build: { + source: 'git::https://github.com/docker/getting-started-todo-app.git//?ref=55680777bc46c59d3fe0ab9ff7e79ee947d0c757' + } + } +} + +resource todoctr 'Radius.Compute/containers@2025-08-01-preview' = { + name: 'todoctr' + properties: { + environment: environment + application: app.id + containers: { + todo: { + image: todoAppImage.properties.imageReference + ports: { + web: { + containerPort: 3000 + } + } + env: { + MYSQL_HOST: { + value: mysql.properties.host + } + MYSQL_DB: { + value: databaseName + } + + MYSQL_USER: { + value: 'radadmin' + } + MYSQL_PASSWORD: { + value: password + } + } + } + } + } +} diff --git a/samples/dockersamples-todo-list-app/bicepconfig.json b/samples/dockersamples-todo-list-app/bicepconfig.json new file mode 100644 index 00000000..ba32f165 --- /dev/null +++ b/samples/dockersamples-todo-list-app/bicepconfig.json @@ -0,0 +1,8 @@ +{ + "experimentalFeaturesEnabled": { + "extensibility": true + }, + "extensions": { + "radius": "br:biceptypes.azurecr.io/radius:latest" + } +} diff --git a/samples/dockersamples-todo-list-app/env-azure.bicep b/samples/dockersamples-todo-list-app/env-azure.bicep new file mode 100644 index 00000000..1a31b7fe --- /dev/null +++ b/samples/dockersamples-todo-list-app/env-azure.bicep @@ -0,0 +1,115 @@ +extension radius + +@description('Azure subscription ID the environment provisions resources into.') +param azureSubscriptionId string + +@description('Azure resource group the environment provisions resources into. Must already exist.') +param azureResourceGroup string + +@description('Globally-unique MySQL flexible server name (3-63 lowercase alphanumerics/hyphens). The workflow generates a unique value per run.') +param serverName string + +@description('Public IP allowed through the flexible server firewall — the CI runner egress IP, which the in-cluster app container also NATs through. The verify workflow computes it at provision time.') +param clientIpAddress string + +@description('OCI ref for the Radius.Compute/containers recipe. Override to a custom build if needed; defaults to the released public recipe.') +param containersRecipe string = 'ghcr.io/radius-project/kube-recipes/containers:latest' + +@description('OCI registry the containerImages recipe builds and pushes the todo-app image to (e.g. `ghcr.io/myorg`).') +param containerImagesRegistry string + +@description('Kubernetes Secret (in the environment namespace) holding the push registry `username`/`password`.') +param containerImagesRegistrySecretName string + +resource recipes 'Radius.Core/recipePacks@2025-08-01-preview' = { + name: 'mysql-azure-app-avm' + properties: { + recipes: { + 'Radius.Data/mySqlDatabases': { + kind: 'bicep' + + source: 'mcr.microsoft.com/bicep/avm/res/db-for-my-sql/flexible-server:0.10.3' + parameters: { + name: serverName + + administratorLogin: '{{context.resource.properties.username}}' + administratorLoginPassword: '{{context.resource.properties.password}}' + + skuName: 'Standard_B1ms' + tier: 'Burstable' + + version: '{{context.resource.properties.version == "5.7" ? "5.7" : "8.0.21"}}' + + databases: [ + { + name: '{{context.resource.properties.database}}' + } + ] + + firewallRules: [ + { + name: 'e2e-runner' + startIpAddress: clientIpAddress + endIpAddress: clientIpAddress + } + ] + + configurations: [ + { + name: 'require_secure_transport' + source: 'user-override' + value: 'OFF' + } + ] + + availabilityZone: -1 + + highAvailability: 'Disabled' + geoRedundantBackup: 'Disabled' + storageSizeGB: 32 + + publicNetworkAccess: 'Enabled' + + enableTelemetry: false + } + + outputs: { + host: 'fqdn' + } + } + + 'Radius.Compute/containers': { + kind: 'bicep' + source: containersRecipe + } + + 'Radius.Compute/containerImages': { + kind: 'terraform' + source: 'git::https://github.com/radius-project/resource-types-contrib.git//Compute/containerImages/recipes/kubernetes/terraform?ref=b9e0fad536a53349b98f94c5be961db84845e1b7' + parameters: { + registry: containerImagesRegistry + registrySecretName: containerImagesRegistrySecretName + } + } + } + } +} + +resource env 'Radius.Core/environments@2025-08-01-preview' = { + name: 'azure' + properties: { + providers: { + azure: { + subscriptionId: azureSubscriptionId + resourceGroupName: azureResourceGroup + } + + kubernetes: { + namespace: 'default' + } + } + recipePacks: [ + recipes.id + ] + } +} diff --git a/samples/drakkan-sftpgo/README.md b/samples/drakkan-sftpgo/README.md new file mode 100644 index 00000000..78e7893e --- /dev/null +++ b/samples/drakkan-sftpgo/README.md @@ -0,0 +1,34 @@ +# SFTPGo + Radius.Storage/objectStorage (Azure Blob Storage) + +This sample provisions an Azure Storage account and blob container through `Radius.Storage/objectStorage` using an Azure Verified Module (AVM) recipe. It builds and runs SFTPGo with an SFTP user backed by the provisioned Blob Storage container. + +## What this sample shows +- **Resource type:** `Radius.Storage/objectStorage` +- **Cloud backing (Azure):** Azure Blob Storage via the AVM `mcr.microsoft.com/bicep/avm/res/storage/storage-account:0.32.1` module (`env-azure.bicep`). +- **Application:** SFTPGo `v2.7.4`, built from source via `Radius.Compute/containerImages` and run via `imageReference`. +- **Credential model:** The recipe exposes the storage account key under nested `outputs.secrets.accountKey`. The app reads the non-secret `store.properties.accountName` and `store.properties.containerName` directly and consumes the key as `AZ_KEY` via a `secretKeyRef` backed by `store.properties.secrets`, to initialize the SFTPGo Azure Blob filesystem. + +## Files +| File | Role | +| --- | --- | +| `app.bicep` | Developer view: the Radius application, object storage resource, SFTPGo image build, and SFTPGo container. | +| `env-azure.bicep` | Platform-engineer view: the `recipePacks` binding the type to the AVM module plus supporting recipes, and the `environment`. | +| `bicepconfig.json` | Radius Bicep extension configuration. | + +## Deploying +Deploy `env-azure.bicep` (the environment) first, then `app.bicep`. + +This sample builds its container image from source with a `Radius.Compute/containerImages` recipe and pushes it to a registry you supply. When deploying `env-azure.bicep`, provide: +- `containerImagesRegistry` — an OCI registry you can push to (e.g. `ghcr.io/your-org`). +- `containerImagesRegistrySecretName` — the name of a Kubernetes Secret (`username`/`password`) in the environment namespace, used to authenticate the BuildKit **push** of the built image. Create it before deploying. + +Image **pull** auth is separate: the `containerImages` recipe does not configure kubelet pull credentials, so the built image repository must be publicly readable by the cluster, or you must configure a Kubernetes image-pull secret (e.g. a `kubernetes.io/dockerconfigjson` `imagePullSecret` on the namespace's `default` ServiceAccount) before deploying `app.bicep` — otherwise the app pods fail with `ImagePullBackOff`. + +## Notes +SFTPGo uses an in-memory data provider and loads a generated `init.json` at startup. The sample exposes SFTP on port 2022 and the HTTP UI on port 8080, creates an admin user, and creates a `radius` user whose home directory maps to the Azure Blob container. + +The object storage recipe creates a `Standard_LRS` StorageV2 account and a blob container named `data`. Blob public access is disabled, while network ACLs allow access for this sample. + +Additional deployment details: +- The app declares a `connections.store` relationship to the object storage resource. +- The image build uses the upstream SFTPGo repository at tag `v2.7.4`. diff --git a/samples/drakkan-sftpgo/app.bicep b/samples/drakkan-sftpgo/app.bicep new file mode 100644 index 00000000..9f0ae1a1 --- /dev/null +++ b/samples/drakkan-sftpgo/app.bicep @@ -0,0 +1,100 @@ +extension radius +@description('The ID of your Radius Environment. Set automatically by the rad CLI.') +param environment string + +resource app 'Radius.Core/applications@2025-08-01-preview' = { + name: 'storage-azure-app-test' + properties: { + environment: environment + } +} + +resource sftpgoImage 'Radius.Compute/containerImages@2025-08-01-preview' = { + name: 'sftpgo-image' + properties: { + environment: environment + application: app.id + + tag: 'v2.7.4' + build: { + source: 'git::https://github.com/drakkan/sftpgo.git//?ref=v2.7.4' + } + } +} + +resource store 'Radius.Storage/objectStorage@2025-08-01-preview' = { + name: 'store' + properties: { + environment: environment + application: app.id + + containerName: 'data' + } +} + +resource sftpgoctr 'Radius.Compute/containers@2025-08-01-preview' = { + name: 'sftpgoctr' + properties: { + environment: environment + application: app.id + containers: { + sftpgo: { + image: sftpgoImage.properties.imageReference + + command: [ + '/bin/sh' + '-c' + ''' +set -eu +cat > /var/lib/sftpgo/init.json < /tmp/producer.yaml < /tmp/consumer.yaml <