From 6b98999a6e07c33be23a3106c9ab57fe2bdff8a1 Mon Sep 17 00:00:00 2001 From: Kris Coleman Date: Tue, 17 Mar 2026 23:42:37 -0400 Subject: [PATCH 1/7] feat(license-validation): add application example demonstrating custom license field consumption and signature validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This new example application showcases the full lifecycle of custom Replicated license field consumption and cryptographic validation: - A Go web dashboard that consumes edition tier (community/trial/enterprise) and seat_count entitlements from the Replicated SDK - Cryptographic signature validation (RSA-PSS/SHA-256) on each license field to detect tampering - Observable behavioral enforcement: UI theme changes by edition tier, features gate by license tier, seat usage meter with color-coded warnings, and complete feature lockdown on invalid signatures or expired licenses - Full Helm chart with Replicated SDK subchart dependency, KOTS integration, preflight checks, and support bundles - Comprehensive README with demo walkthrough using Replicated Vendor Portal and Compatibility Matrix clusters - Dockerfile and Taskfile for easy build and release automation Follows the monolithic pattern established by storagebox and mlflow with the four-way contract: development-values ↔ kots-config ↔ HelmChart CR ↔ chart values. Closes #3 Co-Authored-By: Claude Haiku 4.5 --- applications/license-validation/Dockerfile | 13 + applications/license-validation/README.md | 323 +++++++++ applications/license-validation/Taskfile.yaml | 91 +++ applications/license-validation/app/go.mod | 3 + applications/license-validation/app/main.go | 684 ++++++++++++++++++ .../charts/license-validation/Chart.lock | 6 + .../charts/license-validation/Chart.yaml | 11 + .../license-validation/templates/_helpers.tpl | 60 ++ .../templates/_preflight.tpl | 35 + .../templates/_supportbundle.tpl | 26 + .../templates/deployment.yaml | 66 ++ .../templates/replicated-preflight.yaml | 11 + .../templates/replicated-supportbundle.yaml | 11 + .../templates/secret-public-key.yaml | 11 + .../license-validation/templates/service.yaml | 15 + .../charts/license-validation/values.yaml | 39 + .../development-values.yaml | 16 + .../license-validation/kots/kots-app.yaml | 19 + .../license-validation/kots/kots-config.yaml | 35 + .../kots/license-validation-chart.yaml | 25 + 20 files changed, 1500 insertions(+) create mode 100644 applications/license-validation/Dockerfile create mode 100644 applications/license-validation/README.md create mode 100644 applications/license-validation/Taskfile.yaml create mode 100644 applications/license-validation/app/go.mod create mode 100644 applications/license-validation/app/main.go create mode 100644 applications/license-validation/charts/license-validation/Chart.lock create mode 100644 applications/license-validation/charts/license-validation/Chart.yaml create mode 100644 applications/license-validation/charts/license-validation/templates/_helpers.tpl create mode 100644 applications/license-validation/charts/license-validation/templates/_preflight.tpl create mode 100644 applications/license-validation/charts/license-validation/templates/_supportbundle.tpl create mode 100644 applications/license-validation/charts/license-validation/templates/deployment.yaml create mode 100644 applications/license-validation/charts/license-validation/templates/replicated-preflight.yaml create mode 100644 applications/license-validation/charts/license-validation/templates/replicated-supportbundle.yaml create mode 100644 applications/license-validation/charts/license-validation/templates/secret-public-key.yaml create mode 100644 applications/license-validation/charts/license-validation/templates/service.yaml create mode 100644 applications/license-validation/charts/license-validation/values.yaml create mode 100644 applications/license-validation/development-values.yaml create mode 100644 applications/license-validation/kots/kots-app.yaml create mode 100644 applications/license-validation/kots/kots-config.yaml create mode 100644 applications/license-validation/kots/license-validation-chart.yaml diff --git a/applications/license-validation/Dockerfile b/applications/license-validation/Dockerfile new file mode 100644 index 00000000..97dc2b29 --- /dev/null +++ b/applications/license-validation/Dockerfile @@ -0,0 +1,13 @@ +FROM golang:1.23-alpine AS builder +WORKDIR /build +COPY app/go.mod ./ +RUN go mod download +COPY app/ ./ +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /license-validation . + +FROM alpine:3.20 +RUN apk --no-cache add ca-certificates +COPY --from=builder /license-validation /usr/local/bin/license-validation +USER 65534:65534 +EXPOSE 8080 +ENTRYPOINT ["license-validation"] diff --git a/applications/license-validation/README.md b/applications/license-validation/README.md new file mode 100644 index 00000000..d2700583 --- /dev/null +++ b/applications/license-validation/README.md @@ -0,0 +1,323 @@ +# License Validation Demo + +A demo application that showcases how to consume **custom license fields** from a Replicated license, validate their **cryptographic signatures**, and tie license field values to **observable application behavior**. + +## What This Demonstrates + +1. **Custom license field consumption** — The app reads `edition` (string) and `seat_count` (integer) fields from the Replicated SDK at runtime +2. **Signature validation** — Each license field's RSA-PSS/SHA-256 signature is verified against the application's public key to detect tampering +3. **Behavioral enforcement** — License fields visibly control the application: + - **Edition tier** changes the entire UI theme (blue = Community, amber = Trial, green = Enterprise) and gates feature access + - **Seat count** renders a usage meter with color-coded warnings when approaching or exceeding the limit + - **Invalid signatures** lock all features and display an error banner + - **Expired licenses** lock all features + +## Custom License Fields to Configure + +In the [Replicated Vendor Portal](https://vendor.replicated.com), create these custom license fields for your application: + +| Field Name | Type | Description | Example Values | +|-------------|---------|--------------------------------------------------|----------------------------------| +| `edition` | String | Controls UI theme and feature gating | `community`, `trial`, `enterprise` | +| `seat_count` | Integer | Maximum number of licensed seats | `10`, `50`, `100` | + +### Setting Up License Fields + +1. Go to **Settings > Custom License Fields** in the Vendor Portal +2. Click **Create a custom field** +3. Add `edition` as a **String** field with default value `community` +4. Add `seat_count` as an **Integer** field with default value `10` +5. When creating or editing customer licenses, set these fields to the desired values + +## Signature Validation Approach + +The application validates license field signatures using the following mechanism: + +1. The Replicated SDK subchart runs alongside the application and exposes a REST API +2. The app queries `GET /api/v1/license/fields` which returns each custom field with its `signature.v1` value +3. For each field, the app: + - Extracts the string representation of the field value + - Computes a SHA-256 hash + - Verifies the hash against the base64-decoded signature using RSA-PSS with the application's public key +4. If any signature fails validation, all features are locked + +### Getting the Application Public Key + +1. In the Vendor Portal, go to **Settings** +2. Copy the **Application Public Key** (RSA PEM format) +3. Provide it via the KOTS admin console config or the `appPublicKey` Helm value + +## Deployment + +### Prerequisites + +- Kubernetes 1.25+ +- Helm 3.x +- [Replicated CLI](https://docs.replicated.com/reference/replicated-cli-overview) (for releases) +- [Task](https://taskfile.dev/) (optional, for automation) + +### Local Development + +```bash +# Build the Go binary and run locally (shows SDK error state) +task go:run + +# Build Docker image +task docker:build + +# Install to a local cluster without the Replicated SDK +task helm:install-local +``` + +### Creating a Replicated Release + +```bash +# Update Helm dependencies (fetches the Replicated SDK subchart) +task helm:update-deps + +# Lint the chart +task helm:lint + +# Create a release and promote to Unstable +task release-create + +# Or target a specific channel +task release-create CHANNEL=Beta +``` + +### KOTS Installation + +1. Install via the KOTS admin console +2. Configure the **Simulated Seat Usage** (default: 12) +3. Optionally paste your **Application Public Key** to enable signature validation +4. Deploy the application + +The admin console will forward port 8888 to the application (configurable in `kots-app.yaml`). + +## Demo Walkthrough (Vendor Portal + Compatibility Matrix) + +This walks through the full demo end-to-end: setting up the app in the Vendor Portal, deploying to a Compatibility Matrix cluster, and observing license-driven behavior. + +### Prerequisites + +- [Replicated CLI](https://docs.replicated.com/reference/replicated-cli-overview) authenticated (`replicated login`) +- Docker (for building the app image) + +### Step 1: Create the Application in the Vendor Portal + +1. Go to [vendor.replicated.com](https://vendor.replicated.com) and create a new application named `license-validation` +2. Note your **app slug** (e.g., `license-validation`) — you'll use it with the CLI + +Set the app slug for subsequent commands: + +```bash +export REPLICATED_APP=license-validation +``` + +### Step 2: Create Custom License Fields + +1. In the Vendor Portal, go to **Settings > Custom License Fields** +2. Create two fields: + +| Field Name | Type | Default | +|-------------|---------|----------------| +| `edition` | String | `community` | +| `seat_count` | Integer | `10` | + +### Step 3: Build and Push the Application Image + +```bash +# Build the Docker image +docker build -t ttl.sh/license-validation:2h . + +# Push to ttl.sh (ephemeral registry, good for demos) +docker push ttl.sh/license-validation:2h +``` + +> Update `charts/license-validation/values.yaml` to use `ttl.sh/license-validation` as the image repository and `2h` as the tag, or pass these as overrides in the HelmChart CR. + +### Step 4: Create a Release + +```bash +# Fetch the Replicated SDK subchart +task helm:update-deps + +# Package the chart and create a release +task release-create CHANNEL=Unstable +``` + +### Step 5: Create a Customer + +```bash +replicated customer create \ + --name "Demo Customer" \ + --channel Unstable \ + --type dev \ + --expires-in 720h +``` + +Now edit the customer's license in the Vendor Portal to set: +- `edition` = `enterprise` +- `seat_count` = `50` + +### Step 6: Create a Compatibility Matrix Cluster + +```bash +replicated cluster create \ + --name license-validation-demo \ + --distribution k3s \ + --version 1.33 \ + --disk 50 \ + --instance-type r1.small \ + --ttl 4h +``` + +Wait for the cluster to be ready: + +```bash +replicated cluster ls +``` + +### Step 7: Get Kubeconfig + +```bash +replicated cluster kubeconfig \ + --name license-validation-demo \ + --output-path ./demo.kubeconfig + +export KUBECONFIG=./demo.kubeconfig +kubectl get nodes # verify connectivity +``` + +### Step 8: Install the Application + +**Option A: Helm install (direct)** + +```bash +# Log in to the Replicated registry using the customer's license ID +LICENSE_ID=$(replicated customer ls --output json | jq -r '.[] | select(.name == "Demo Customer") | .installationId') + +helm registry login registry.replicated.com \ + --username "$LICENSE_ID" \ + --password "$LICENSE_ID" + +helm install license-validation \ + oci://registry.replicated.com/$REPLICATED_APP/unstable/license-validation \ + --namespace license-validation \ + --create-namespace +``` + +**Option B: KOTS install** + +```bash +# Download the customer license file from the Vendor Portal, then: +kubectl kots install $REPLICATED_APP \ + --namespace license-validation \ + --shared-password password \ + --license-file ./license.yaml +``` + +### Step 9: Access the Dashboard + +```bash +kubectl port-forward svc/license-validation 8888:80 -n license-validation +``` + +Open [http://localhost:8888](http://localhost:8888) — you should see: +- Green **Enterprise** badge and theme +- Seat meter showing 12/50 seats used +- All 5 features unlocked + +### Step 10: See License Changes in Real Time + +Back in the Vendor Portal, edit the customer's license: + +1. Change `edition` from `enterprise` to `trial` — refresh the dashboard and watch the theme change to amber, with SSO/Audit/Priority features now locked +2. Change `seat_count` from `50` to `10` — the seat meter turns red showing 12/10 exceeded +3. Change `edition` to `community` — only "Core Dashboard" remains unlocked + +The app polls the SDK every 30 seconds, so changes appear within ~30s of saving in the portal. + +### Cleanup + +```bash +replicated cluster rm --name license-validation-demo +unset KUBECONFIG +rm -f demo.kubeconfig +``` + +## Testing the Tampered License Scenario + +To see signature validation enforcement in action: + +### Method 1: Invalid Public Key + +1. Deploy the application with signature validation enabled (paste a valid public key) +2. Verify the dashboard shows "All field signatures verified" with a green checkmark +3. Update the KOTS config with a **different** RSA public key (not the one from your app) +4. The app will detect that signatures don't match and lock all features with a red error banner + +### Method 2: No Public Key (Disabled Validation) + +1. Deploy without providing a public key +2. The dashboard shows a yellow warning: "No public key configured - signature validation is disabled" +3. Features remain unlocked but signature status is shown as a warning + +### Method 3: Change License Fields + +1. In the Vendor Portal, change a customer's `edition` from `enterprise` to `community` +2. Watch the dashboard theme change from green to blue and enterprise features lock +3. Change `seat_count` to a value lower than `simulated_seat_usage` to see the seat limit exceeded warning + +## Architecture + +``` +┌──────────────────────────────────────────────────┐ +│ Kubernetes Cluster │ +│ │ +│ ┌─────────────────┐ ┌─────────────────────┐ │ +│ │ license- │ │ replicated-sdk │ │ +│ │ validation │───▶│ (subchart) │ │ +│ │ (Go web app) │ │ :3000 │ │ +│ │ :8080 │ │ │ │ +│ └─────────────────┘ │ GET /api/v1/ │ │ +│ │ │ license/info │ │ +│ │ │ license/fields │ │ +│ ▼ └─────────────────────┘ │ +│ ┌─────────────────┐ │ +│ │ Service │ │ +│ │ :80 → :8080 │ │ +│ └─────────────────┘ │ +└──────────────────────────────────────────────────┘ +``` + +The application is a single Go binary with an embedded HTML template. It polls the Replicated SDK every 30 seconds for updated license information. + +## File Structure + +``` +applications/license-validation/ +├── README.md # This file +├── Dockerfile # Multi-stage Go build +├── Taskfile.yaml # Build and release automation +├── development-values.yaml # KOTS ConfigValues for headless install +├── app/ +│ ├── go.mod # Go module definition +│ └── main.go # Application source (server + template) +├── charts/license-validation/ +│ ├── Chart.yaml # Helm chart with Replicated SDK dependency +│ ├── values.yaml # Default Helm values +│ └── templates/ +│ ├── _helpers.tpl # Template helpers (name, labels, SDK address) +│ ├── _preflight.tpl # Preflight check definitions +│ ├── _supportbundle.tpl # Support bundle definitions +│ ├── deployment.yaml # Application deployment +│ ├── service.yaml # ClusterIP service +│ ├── secret-public-key.yaml # Public key secret (conditional) +│ ├── replicated-preflight.yaml # Preflight secret +│ └── replicated-supportbundle.yaml # Support bundle secret +└── kots/ + ├── kots-app.yaml # KOTS Application metadata + ├── kots-config.yaml # Admin console configuration UI + └── license-validation-chart.yaml # HelmChart CR (maps config → Helm values) +``` diff --git a/applications/license-validation/Taskfile.yaml b/applications/license-validation/Taskfile.yaml new file mode 100644 index 00000000..e7a8274d --- /dev/null +++ b/applications/license-validation/Taskfile.yaml @@ -0,0 +1,91 @@ +version: '3' + +vars: + APP_NAME: license-validation + CHART_DIR: charts/license-validation + KOTS_DIR: kots + CHART_VERSION: + sh: yq '.version' {{.CHART_DIR}}/Chart.yaml + CHANNEL: '{{.CHANNEL | default "Unstable"}}' + +tasks: + default: + desc: Show available tasks + cmds: + - task --list + + # ── Build ────────────────────────────────────────────────── + + helm:update-deps: + desc: Update Helm chart dependencies + cmds: + - helm dependency update {{.CHART_DIR}} + + helm:lint: + desc: Lint the Helm chart + cmds: + - helm lint {{.CHART_DIR}} + + helm:template: + desc: Render chart templates locally for debugging + cmds: + - helm template {{.APP_NAME}} {{.CHART_DIR}} --debug + + docker:build: + desc: Build the Docker image locally + cmds: + - docker build -t {{.APP_NAME}}:latest . + + # ── Release ──────────────────────────────────────────────── + + release-prepare: + desc: Package chart and update KOTS manifest version reference + cmds: + - helm package {{.CHART_DIR}} -d {{.KOTS_DIR}} + - | + sed -i '' "s/chartVersion: .*/chartVersion: {{.CHART_VERSION}}/" {{.KOTS_DIR}}/{{.APP_NAME}}-chart.yaml + + release-create: + desc: Create a Replicated release and promote to channel + deps: [release-prepare] + cmds: + - replicated release create --yaml-dir {{.KOTS_DIR}} --promote {{.CHANNEL}} --version {{.CHART_VERSION}} + + # ── Local Development ────────────────────────────────────── + + helm:install-local: + desc: Install chart locally without Replicated SDK + cmds: + - | + helm upgrade --install {{.APP_NAME}} {{.CHART_DIR}} \ + --set replicated.enabled=false \ + --set image.repository={{.APP_NAME}} \ + --set image.tag=latest \ + --set image.pullPolicy=Never \ + --create-namespace \ + --namespace {{.APP_NAME}} + + helm:uninstall: + desc: Uninstall chart from local cluster + cmds: + - helm uninstall {{.APP_NAME}} --namespace {{.APP_NAME}} + + # ── Validation ───────────────────────────────────────────── + + go:build: + desc: Build the Go binary locally + dir: app + cmds: + - go build -o ../license-validation . + + go:run: + desc: Run the app locally (will fail to reach SDK but shows the UI) + dir: app + cmds: + - go run . + + clean: + desc: Remove build artifacts + cmds: + - rm -f {{.KOTS_DIR}}/*.tgz + - rm -f license-validation diff --git a/applications/license-validation/app/go.mod b/applications/license-validation/app/go.mod new file mode 100644 index 00000000..dc96828e --- /dev/null +++ b/applications/license-validation/app/go.mod @@ -0,0 +1,3 @@ +module github.com/replicatedhq/platform-examples/applications/license-validation + +go 1.23 diff --git a/applications/license-validation/app/main.go b/applications/license-validation/app/main.go new file mode 100644 index 00000000..5a544320 --- /dev/null +++ b/applications/license-validation/app/main.go @@ -0,0 +1,684 @@ +package main + +import ( + "crypto" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "html/template" + "log" + "math" + "net/http" + "os" + "strconv" + "strings" + "sync" + "time" +) + +// LicenseField represents a single custom license field from the Replicated SDK. +type LicenseField struct { + Name string `json:"name"` + Title string `json:"title"` + Type string `json:"type"` + Value any `json:"value"` + Signature struct { + V1 string `json:"v1"` + } `json:"signature"` +} + +// LicenseInfo represents the license metadata from the Replicated SDK. +type LicenseInfo struct { + LicenseID string `json:"license_id"` + InstallationID string `json:"installation_id"` + Assignee string `json:"assignee"` + ReleaseChannel string `json:"release_channel"` + LicenseType string `json:"license_type"` + ExpirationTime *time.Time `json:"expiration_time"` +} + +// AppState holds the current validated license state used for rendering. +type AppState struct { + // License metadata + LicenseID string + CustomerName string + LicenseType string + ReleaseChannel string + ExpirationTime string + IsExpired bool + + // Custom fields + Edition string + SeatCount int + SeatUsage int + + // Validation + SignatureValid bool + SignatureError string + LicenseLoaded bool + SDKError string + + // Derived UI state + ThemeClass string + EditionLabel string + SeatPercent int + SeatBarClass string + FeaturesLocked bool +} + +var ( + state AppState + stateMu sync.RWMutex + publicKey *rsa.PublicKey + tmpl *template.Template +) + +func main() { + sdkAddr := os.Getenv("REPLICATED_SDK_ADDRESS") + if sdkAddr == "" { + sdkAddr = "http://license-validation-replicated:3000" + } + + pubKeyPEM := os.Getenv("REPLICATED_APP_PUBLIC_KEY") + if pubKeyPEM != "" { + key, err := parsePublicKey(pubKeyPEM) + if err != nil { + log.Printf("WARNING: Failed to parse public key: %v", err) + } else { + publicKey = key + log.Println("Loaded application public key for signature validation") + } + } else { + log.Println("WARNING: REPLICATED_APP_PUBLIC_KEY not set - signature validation disabled") + } + + seatUsage := 12 + if v := os.Getenv("SIMULATED_SEAT_USAGE"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + seatUsage = n + } + } + + stateMu.Lock() + state.SeatUsage = seatUsage + stateMu.Unlock() + + var err error + tmpl, err = template.New("index").Funcs(template.FuncMap{ + "lower": strings.ToLower, + }).Parse(indexHTML) + if err != nil { + log.Fatalf("Failed to parse template: %v", err) + } + + // Start background license polling + go pollLicense(sdkAddr) + + http.HandleFunc("/", handleIndex) + http.HandleFunc("/healthz", handleHealthz) + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + log.Printf("License Validation app listening on :%s", port) + log.Fatal(http.ListenAndServe(":"+port, nil)) +} + +func pollLicense(sdkAddr string) { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + // Initial fetch + fetchLicense(sdkAddr) + + for range ticker.C { + fetchLicense(sdkAddr) + } +} + +func fetchLicense(sdkAddr string) { + client := &http.Client{Timeout: 5 * time.Second} + + // Fetch license info + infoResp, err := client.Get(sdkAddr + "/api/v1/license/info") + if err != nil { + stateMu.Lock() + state.SDKError = fmt.Sprintf("Cannot reach Replicated SDK: %v", err) + state.LicenseLoaded = false + state.FeaturesLocked = true + state.ThemeClass = "theme-error" + stateMu.Unlock() + log.Printf("SDK error (license/info): %v", err) + return + } + defer infoResp.Body.Close() + + if infoResp.StatusCode != http.StatusOK { + stateMu.Lock() + state.SDKError = fmt.Sprintf("SDK returned status %d for license/info", infoResp.StatusCode) + state.LicenseLoaded = false + state.FeaturesLocked = true + state.ThemeClass = "theme-error" + stateMu.Unlock() + return + } + + var info LicenseInfo + if err := json.NewDecoder(infoResp.Body).Decode(&info); err != nil { + stateMu.Lock() + state.SDKError = fmt.Sprintf("Failed to decode license info: %v", err) + state.LicenseLoaded = false + stateMu.Unlock() + return + } + + // Fetch license fields + fieldsResp, err := client.Get(sdkAddr + "/api/v1/license/fields") + if err != nil { + stateMu.Lock() + state.SDKError = fmt.Sprintf("Cannot reach Replicated SDK: %v", err) + state.LicenseLoaded = false + stateMu.Unlock() + log.Printf("SDK error (license/fields): %v", err) + return + } + defer fieldsResp.Body.Close() + + var fields []LicenseField + if fieldsResp.StatusCode == http.StatusOK { + if err := json.NewDecoder(fieldsResp.Body).Decode(&fields); err != nil { + log.Printf("Failed to decode license fields: %v", err) + } + } + + // Process and validate + stateMu.Lock() + defer stateMu.Unlock() + + state.SDKError = "" + state.LicenseLoaded = true + state.LicenseID = info.LicenseID + state.CustomerName = info.Assignee + state.LicenseType = info.LicenseType + state.ReleaseChannel = info.ReleaseChannel + + if info.ExpirationTime != nil { + state.ExpirationTime = info.ExpirationTime.Format("2006-01-02") + state.IsExpired = info.ExpirationTime.Before(time.Now()) + } else { + state.ExpirationTime = "Never" + state.IsExpired = false + } + + // Extract custom fields + state.Edition = "community" + state.SeatCount = 0 + allSigsValid := true + sigChecked := false + + for _, f := range fields { + // Validate signature if public key is available + if publicKey != nil && f.Signature.V1 != "" { + sigChecked = true + if !verifyFieldSignature(f) { + allSigsValid = false + log.Printf("Signature validation FAILED for field: %s", f.Name) + } + } + + switch f.Name { + case "edition": + if v, ok := f.Value.(string); ok { + state.Edition = v + } + case "seat_count": + switch v := f.Value.(type) { + case float64: + state.SeatCount = int(v) + case string: + if n, err := strconv.Atoi(v); err == nil { + state.SeatCount = n + } + } + } + } + + if publicKey != nil && sigChecked { + state.SignatureValid = allSigsValid + if !allSigsValid { + state.SignatureError = "One or more license field signatures failed validation. License fields may have been tampered with." + } else { + state.SignatureError = "" + } + } else if publicKey == nil { + state.SignatureValid = false + state.SignatureError = "No public key configured - signature validation is disabled" + } else { + state.SignatureValid = true + state.SignatureError = "" + } + + // Derive UI state + state.FeaturesLocked = state.IsExpired || (publicKey != nil && sigChecked && !allSigsValid) + + switch state.Edition { + case "enterprise": + state.ThemeClass = "theme-enterprise" + state.EditionLabel = "Enterprise" + case "trial": + state.ThemeClass = "theme-trial" + state.EditionLabel = "Trial" + case "community": + state.ThemeClass = "theme-community" + state.EditionLabel = "Community" + default: + state.ThemeClass = "theme-community" + label := state.Edition + if len(label) > 0 { + label = strings.ToUpper(label[:1]) + label[1:] + } + state.EditionLabel = label + } + + if state.FeaturesLocked { + state.ThemeClass = "theme-error" + } + + if state.SeatCount > 0 { + state.SeatPercent = int(math.Min(float64(state.SeatUsage)*100/float64(state.SeatCount), 100)) + } else { + state.SeatPercent = 0 + } + + switch { + case state.SeatPercent >= 90: + state.SeatBarClass = "bar-danger" + case state.SeatPercent >= 70: + state.SeatBarClass = "bar-warning" + default: + state.SeatBarClass = "bar-ok" + } + + log.Printf("License updated: customer=%s edition=%s seats=%d/%d sig_valid=%v", + state.CustomerName, state.Edition, state.SeatUsage, state.SeatCount, state.SignatureValid) +} + +func verifyFieldSignature(field LicenseField) bool { + if publicKey == nil || field.Signature.V1 == "" { + return false + } + + sigBytes, err := base64.StdEncoding.DecodeString(field.Signature.V1) + if err != nil { + log.Printf("Failed to decode signature for field %s: %v", field.Name, err) + return false + } + + // The signed content is the string representation of the field value + var valueStr string + switch v := field.Value.(type) { + case string: + valueStr = v + case float64: + if v == float64(int64(v)) { + valueStr = strconv.FormatInt(int64(v), 10) + } else { + valueStr = strconv.FormatFloat(v, 'f', -1, 64) + } + case bool: + valueStr = strconv.FormatBool(v) + default: + valueStr = fmt.Sprintf("%v", v) + } + + hash := sha256.Sum256([]byte(valueStr)) + err = rsa.VerifyPSS(publicKey, crypto.SHA256, hash[:], sigBytes, nil) + return err == nil +} + +func parsePublicKey(pemStr string) (*rsa.PublicKey, error) { + block, _ := pem.Decode([]byte(pemStr)) + if block == nil { + return nil, fmt.Errorf("failed to decode PEM block") + } + + pub, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse public key: %w", err) + } + + rsaPub, ok := pub.(*rsa.PublicKey) + if !ok { + return nil, fmt.Errorf("public key is not RSA") + } + + return rsaPub, nil +} + +func handleIndex(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + + stateMu.RLock() + s := state + stateMu.RUnlock() + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := tmpl.Execute(w, s); err != nil { + log.Printf("Template error: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } +} + +func handleHealthz(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, `{"status":"ok"}`) +} + +const indexHTML = ` + + + + +License Validation Demo + + + +
+

License Validation Demo

+ {{if .LicenseLoaded}} + {{.EditionLabel}} + {{end}} +
+ +
+ {{if .SDKError}} +
+

Waiting for Replicated SDK

+

{{.SDKError}}

+

The app will automatically retry every 30 seconds.

+
+ {{else}} + + {{if .FeaturesLocked}} +
+
License Enforcement Active
+ {{if .IsExpired}} +

Your license has expired. Features are locked until the license is renewed.

+ {{else if not .SignatureValid}} +

{{.SignatureError}}

+

Features are locked because the license could not be verified. Contact your vendor to resolve this issue.

+ {{end}} +
+ {{end}} + +
+
+
License Details
+
+ Customer + {{.CustomerName}} +
+
+ License ID + {{.LicenseID}} +
+
+ Type + {{.LicenseType}} +
+
+ Channel + {{.ReleaseChannel}} +
+
+ Expires + + {{.ExpirationTime}}{{if .IsExpired}} (EXPIRED){{end}} + +
+
+ +
+
Seat Entitlement
+ {{if gt .SeatCount 0}} +
+
+ {{.SeatUsage}} used + {{.SeatCount}} licensed +
+
+
+
+ {{if gt .SeatUsage .SeatCount}} +

+ Seat limit exceeded. {{.SeatUsage}}/{{.SeatCount}} seats in use. +

+ {{end}} +
+ {{else}} +

No seat limit configured (unlimited).

+ {{end}} +
+ +
+
Signature Validation
+ {{if and .SignatureValid (not .SignatureError)}} +
+ +
+
All field signatures verified
+
License fields are authentic and untampered
+
+
+ {{else if .SignatureError}} +
+ {{if not .SignatureValid}} + + {{else}} + + {{end}} +
+
{{.SignatureError}}
+
+ {{if not .SignatureValid}} + Signature verification uses RSA-PSS with SHA-256 against the application public key + {{end}} +
+
+
+ {{end}} +
+ +
+
Feature Entitlements
+
+
+ {{if and (not .FeaturesLocked) (or (eq .Edition "community") (eq .Edition "trial") (eq .Edition "enterprise"))}} + + {{else}} + 🔒 + {{end}} + Core Dashboard + All editions +
+
+ {{if and (not .FeaturesLocked) (or (eq .Edition "trial") (eq .Edition "enterprise"))}} + + {{else}} + 🔒 + {{end}} + Advanced Analytics + Trial + Enterprise +
+
+ {{if and (not .FeaturesLocked) (eq .Edition "enterprise")}} + + {{else}} + 🔒 + {{end}} + SSO / SAML Integration + Enterprise only +
+
+ {{if and (not .FeaturesLocked) (eq .Edition "enterprise")}} + + {{else}} + 🔒 + {{end}} + Audit Logging + Enterprise only +
+
+ {{if and (not .FeaturesLocked) (eq .Edition "enterprise")}} + + {{else}} + 🔒 + {{end}} + Priority Support + Enterprise only +
+
+
+
+ + {{end}} +
+ +` diff --git a/applications/license-validation/charts/license-validation/Chart.lock b/applications/license-validation/charts/license-validation/Chart.lock new file mode 100644 index 00000000..ac336bca --- /dev/null +++ b/applications/license-validation/charts/license-validation/Chart.lock @@ -0,0 +1,6 @@ +dependencies: +- name: replicated + repository: oci://registry.replicated.com/library + version: 1.12.2 +digest: sha256:7201d0fd42d1811561535f56b8e8881b07098dcecb23f3683501b278e66b4b2d +generated: "2026-03-17T20:38:46.728595-04:00" diff --git a/applications/license-validation/charts/license-validation/Chart.yaml b/applications/license-validation/charts/license-validation/Chart.yaml new file mode 100644 index 00000000..1aefeee5 --- /dev/null +++ b/applications/license-validation/charts/license-validation/Chart.yaml @@ -0,0 +1,11 @@ +apiVersion: v2 +name: license-validation +description: A demo application showcasing Replicated custom license field consumption and signature validation +type: application +version: 0.1.0 +appVersion: 1.0.0 +dependencies: +- name: replicated + version: "~1.12.2" + repository: "oci://registry.replicated.com/library" + condition: replicated.enabled diff --git a/applications/license-validation/charts/license-validation/templates/_helpers.tpl b/applications/license-validation/charts/license-validation/templates/_helpers.tpl new file mode 100644 index 00000000..04ba51d7 --- /dev/null +++ b/applications/license-validation/charts/license-validation/templates/_helpers.tpl @@ -0,0 +1,60 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "license-validation.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +*/}} +{{- define "license-validation.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "license-validation.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "license-validation.labels" -}} +helm.sh/chart: {{ include "license-validation.chart" . }} +{{ include "license-validation.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "license-validation.selectorLabels" -}} +app.kubernetes.io/name: {{ include "license-validation.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Replicated SDK address - auto-detect from release name if not explicitly set. +*/}} +{{- define "license-validation.sdkAddress" -}} +{{- if .Values.replicatedSDKAddress }} +{{- .Values.replicatedSDKAddress }} +{{- else }} +{{- printf "http://%s-replicated:3000" .Release.Name }} +{{- end }} +{{- end }} diff --git a/applications/license-validation/charts/license-validation/templates/_preflight.tpl b/applications/license-validation/charts/license-validation/templates/_preflight.tpl new file mode 100644 index 00000000..f3ca265d --- /dev/null +++ b/applications/license-validation/charts/license-validation/templates/_preflight.tpl @@ -0,0 +1,35 @@ +{{- define "license-validation.preflight" -}} +apiVersion: troubleshoot.sh/v1beta2 +kind: Preflight +metadata: + name: license-validation +spec: + collectors: + - clusterInfo: {} + - clusterResources: {} + analyzers: + - clusterVersion: + outcomes: + - fail: + when: "< 1.25.0" + message: Requires Kubernetes 1.25.0 or later. + uri: https://kubernetes.io + - pass: + message: Kubernetes version is compatible. + - nodeResources: + checkName: Cluster has sufficient memory + outcomes: + - fail: + when: "sum(memoryCapacity) < 512Mi" + message: At least 512Mi of memory is required. + - pass: + message: Sufficient memory available. + - nodeResources: + checkName: Cluster has sufficient CPU + outcomes: + - fail: + when: "sum(cpuCapacity) < 1" + message: At least 1 CPU core is required. + - pass: + message: Sufficient CPU available. +{{- end }} diff --git a/applications/license-validation/charts/license-validation/templates/_supportbundle.tpl b/applications/license-validation/charts/license-validation/templates/_supportbundle.tpl new file mode 100644 index 00000000..bbfd27eb --- /dev/null +++ b/applications/license-validation/charts/license-validation/templates/_supportbundle.tpl @@ -0,0 +1,26 @@ +{{- define "license-validation.supportbundle" -}} +apiVersion: troubleshoot.sh/v1beta2 +kind: SupportBundle +metadata: + name: license-validation +spec: + collectors: + - clusterResources: + namespaces: + - {{ .Release.Namespace }} + - logs: + name: license-validation-logs + namespace: {{ .Release.Namespace }} + selector: + - app.kubernetes.io/name={{ include "license-validation.name" . }} + analyzers: + - deploymentStatus: + name: license-validation + namespace: {{ .Release.Namespace }} + outcomes: + - fail: + when: "< 1" + message: The license-validation deployment has no ready replicas. + - pass: + message: The license-validation deployment is running. +{{- end }} diff --git a/applications/license-validation/charts/license-validation/templates/deployment.yaml b/applications/license-validation/charts/license-validation/templates/deployment.yaml new file mode 100644 index 00000000..fa32f2cc --- /dev/null +++ b/applications/license-validation/charts/license-validation/templates/deployment.yaml @@ -0,0 +1,66 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "license-validation.fullname" . }} + labels: + {{- include "license-validation.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "license-validation.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "license-validation.selectorLabels" . | nindent 8 }} + spec: + {{- with .Values.global.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + securityContext: + runAsNonRoot: true + runAsUser: 65534 + runAsGroup: 65534 + seccompProfile: + type: RuntimeDefault + containers: + - name: license-validation + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: 8080 + protocol: TCP + env: + - name: REPLICATED_SDK_ADDRESS + value: {{ include "license-validation.sdkAddress" . | quote }} + - name: SIMULATED_SEAT_USAGE + value: {{ .Values.simulatedSeatUsage | quote }} + {{- if .Values.appPublicKey }} + - name: REPLICATED_APP_PUBLIC_KEY + valueFrom: + secretKeyRef: + name: {{ include "license-validation.fullname" . }}-public-key + key: public-key + {{- end }} + livenessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 5 + periodSeconds: 15 + readinessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 3 + periodSeconds: 10 + resources: + {{- toYaml .Values.resources | nindent 12 }} + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL diff --git a/applications/license-validation/charts/license-validation/templates/replicated-preflight.yaml b/applications/license-validation/charts/license-validation/templates/replicated-preflight.yaml new file mode 100644 index 00000000..05921938 --- /dev/null +++ b/applications/license-validation/charts/license-validation/templates/replicated-preflight.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "license-validation.fullname" . }}-preflight + labels: + {{- include "license-validation.labels" . | nindent 4 }} + troubleshoot.sh/kind: preflight +type: Opaque +stringData: + preflight.yaml: | + {{ include "license-validation.preflight" . | nindent 4 }} diff --git a/applications/license-validation/charts/license-validation/templates/replicated-supportbundle.yaml b/applications/license-validation/charts/license-validation/templates/replicated-supportbundle.yaml new file mode 100644 index 00000000..8a5993de --- /dev/null +++ b/applications/license-validation/charts/license-validation/templates/replicated-supportbundle.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "license-validation.fullname" . }}-supportbundle + labels: + {{- include "license-validation.labels" . | nindent 4 }} + troubleshoot.sh/kind: support-bundle +type: Opaque +stringData: + support-bundle.yaml: | + {{ include "license-validation.supportbundle" . | nindent 4 }} diff --git a/applications/license-validation/charts/license-validation/templates/secret-public-key.yaml b/applications/license-validation/charts/license-validation/templates/secret-public-key.yaml new file mode 100644 index 00000000..25613ae9 --- /dev/null +++ b/applications/license-validation/charts/license-validation/templates/secret-public-key.yaml @@ -0,0 +1,11 @@ +{{- if .Values.appPublicKey }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "license-validation.fullname" . }}-public-key + labels: + {{- include "license-validation.labels" . | nindent 4 }} +type: Opaque +stringData: + public-key: {{ .Values.appPublicKey | quote }} +{{- end }} diff --git a/applications/license-validation/charts/license-validation/templates/service.yaml b/applications/license-validation/charts/license-validation/templates/service.yaml new file mode 100644 index 00000000..759b7be1 --- /dev/null +++ b/applications/license-validation/charts/license-validation/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "license-validation.fullname" . }} + labels: + {{- include "license-validation.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: {{ .Values.service.targetPort }} + protocol: TCP + name: http + selector: + {{- include "license-validation.selectorLabels" . | nindent 4 }} diff --git a/applications/license-validation/charts/license-validation/values.yaml b/applications/license-validation/charts/license-validation/values.yaml new file mode 100644 index 00000000..e71ba105 --- /dev/null +++ b/applications/license-validation/charts/license-validation/values.yaml @@ -0,0 +1,39 @@ +global: + imagePullSecrets: [] + +replicated: + enabled: true + +image: + repository: ghcr.io/replicatedhq/platform-examples/license-validation + tag: latest + pullPolicy: IfNotPresent + +replicaCount: 1 + +service: + type: ClusterIP + port: 80 + targetPort: 8080 + +# The Replicated SDK address for the app to query license info. +# By default the SDK subchart creates a service named -replicated. +replicatedSDKAddress: "" + +# RSA public key (PEM) for validating license field signatures. +# Obtain this from the Replicated Vendor Portal under your application settings. +appPublicKey: "" + +# Simulated seat usage for demo purposes. +simulatedSeatUsage: 12 + +resources: + requests: + cpu: 50m + memory: 32Mi + limits: + cpu: 200m + memory: 64Mi + +nameOverride: "" +fullnameOverride: "" diff --git a/applications/license-validation/development-values.yaml b/applications/license-validation/development-values.yaml new file mode 100644 index 00000000..1e9d7a30 --- /dev/null +++ b/applications/license-validation/development-values.yaml @@ -0,0 +1,16 @@ +--- +apiVersion: kots.io/v1beta1 +kind: ConfigValues +metadata: + name: license-validation +spec: + values: + # Application Settings + simulated_seat_usage: + default: "12" + value: "12" + + # Signature Validation + app_public_key: + default: "" + value: "" diff --git a/applications/license-validation/kots/kots-app.yaml b/applications/license-validation/kots/kots-app.yaml new file mode 100644 index 00000000..9b1f83f4 --- /dev/null +++ b/applications/license-validation/kots/kots-app.yaml @@ -0,0 +1,19 @@ +apiVersion: kots.io/v1beta1 +kind: Application +metadata: + name: license-validation +spec: + title: License Validation Demo + icon: "" + releaseNotes: "" + allowRollback: true + requireMinimalRBACPrivileges: true + supportMinimalRBACPrivileges: true + ports: + - serviceName: license-validation + servicePort: 80 + localPort: 8888 + applicationUrl: "http://license-validation" + statusInformers: + - deployment/license-validation + graphs: [] diff --git a/applications/license-validation/kots/kots-config.yaml b/applications/license-validation/kots/kots-config.yaml new file mode 100644 index 00000000..4ebd5d14 --- /dev/null +++ b/applications/license-validation/kots/kots-config.yaml @@ -0,0 +1,35 @@ +--- +apiVersion: kots.io/v1beta1 +kind: Config +metadata: + name: config +spec: + groups: + - name: application_settings + title: Application Settings + items: + - name: simulated_seat_usage + title: Simulated Seat Usage + type: text + default: "12" + required: true + description: | + Number of simulated active seats. Used to demonstrate seat entitlement + enforcement against the seat_count license field. + validation: + regex: + pattern: ^\d+$ + message: Must be a whole number. + - name: signature_validation + title: Signature Validation + description: | + Configure the application public key for license field signature validation. + The public key is available in the Replicated Vendor Portal under your application settings. + items: + - name: app_public_key + title: Application Public Key (PEM) + type: textarea + default: "" + description: | + RSA public key in PEM format for validating license field signatures. + Leave empty to disable signature validation. diff --git a/applications/license-validation/kots/license-validation-chart.yaml b/applications/license-validation/kots/license-validation-chart.yaml new file mode 100644 index 00000000..7eb591f9 --- /dev/null +++ b/applications/license-validation/kots/license-validation-chart.yaml @@ -0,0 +1,25 @@ +apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: license-validation +spec: + chart: + name: license-validation + chartVersion: 0.1.0 + values: + global: + imagePullSecrets: + - name: repl{{ ImagePullSecretName }} + image: + repository: repl{{ HasLocalRegistry | ternary (print LocalRegistryHost "/replicatedhq/platform-examples/license-validation") "ghcr.io/replicatedhq/platform-examples/license-validation" }} + tag: latest + pullPolicy: IfNotPresent + replicated: + enabled: true + simulatedSeatUsage: repl{{ ConfigOption "simulated_seat_usage" }} + appPublicKey: repl{{ ConfigOption "app_public_key" }} + + namespace: license-validation + builder: + replicated: + enabled: true From 615564df5c07d609e2018545abe724458e30ca1f Mon Sep 17 00:00:00 2001 From: Kris Coleman Date: Thu, 19 Mar 2026 18:07:27 -0400 Subject: [PATCH 2/7] fix(license-validation): update image refs, RBAC, docs from demo walkthrough + add CI workflow - Switch image from ghcr.io to ttl.sh for demo accessibility - Disable minimal RBAC to fix preflight check permissions - Fix README: correct vendor portal paths, kubeconfig CLI syntax, add license download step, add /unstable channel to KOTS install - Add sbom/ to .gitignore - Add GitHub Actions CI workflow (lint, build, Helm install test, KOTS install test, cleanup) Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/license-validation-ci.yml | 429 ++++++++++++++++++ .gitignore | 1 + applications/license-validation/README.md | 14 +- .../charts/license-validation/values.yaml | 4 +- .../license-validation/kots/kots-app.yaml | 4 +- .../kots/license-validation-chart.yaml | 4 +- 6 files changed, 444 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/license-validation-ci.yml diff --git a/.github/workflows/license-validation-ci.yml b/.github/workflows/license-validation-ci.yml new file mode 100644 index 00000000..b899c51f --- /dev/null +++ b/.github/workflows/license-validation-ci.yml @@ -0,0 +1,429 @@ +name: License Validation CI + +on: + pull_request: + paths: + - 'applications/license-validation/**' + - '.github/workflows/license-validation-ci.yml' + push: + branches: + - main + paths: + - 'applications/license-validation/**' + - '.github/workflows/license-validation-ci.yml' + +env: + APP_SLUG: license-validation + REPLICATED_APP: license-validation + YQ_VERSION: v4.44.6 + +jobs: + lint-and-template: + runs-on: ubuntu-22.04 + outputs: + helm-cache-key: ${{ steps.helm-deps.outputs.cache-key }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Helm + uses: azure/setup-helm@v4.3.0 + with: + version: v3.13.3 + + - name: Install Task + uses: arduino/setup-task@v1 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Cache Helm dependencies + id: helm-deps + uses: actions/cache@v4 + with: + path: applications/license-validation/charts/license-validation/charts/ + key: helm-deps-${{ hashFiles('applications/license-validation/charts/license-validation/Chart.lock') }} + restore-keys: helm-deps- + + - name: Run Lint and Template + working-directory: applications/license-validation + run: | + task helm:update-deps + task helm:lint + task helm:template + + - name: Upload rendered templates + if: failure() + uses: actions/upload-artifact@v4 + with: + name: license-validation-rendered-templates + path: applications/license-validation/charts/.rendered-templates/ + retention-days: 7 + + build-and-push-image: + runs-on: ubuntu-22.04 + outputs: + image-tag: ${{ steps.image-tag.outputs.tag }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Generate image tag + id: image-tag + run: | + TAG="ci-${{ github.run_id }}-${{ github.run_number }}" + echo "tag=$TAG" >> $GITHUB_OUTPUT + echo "Using image tag: $TAG" + + - name: Build and push to ttl.sh + uses: docker/build-push-action@v6 + with: + context: applications/license-validation + push: true + tags: ttl.sh/license-validation:${{ steps.image-tag.outputs.tag }} + cache-from: type=gha + cache-to: type=gha,mode=max + + create-release: + runs-on: ubuntu-22.04 + needs: [lint-and-template, build-and-push-image] + outputs: + customer-id: ${{ steps.create-customer.outputs.customer-id }} + channel-slug: ${{ steps.create-release.outputs.channel-slug }} + chart-version: ${{ steps.chart-version.outputs.chart_version }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Helm + uses: azure/setup-helm@v4.3.0 + with: + version: v3.13.3 + + - name: Install Task + uses: arduino/setup-task@v1 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Install yq + run: | + wget -q https://github.com/mikefarah/yq/releases/download/${{ env.YQ_VERSION }}/yq_linux_amd64 -O /usr/local/bin/yq + chmod +x /usr/local/bin/yq + + - name: Restore Helm dependency cache + uses: actions/cache@v4 + with: + path: applications/license-validation/charts/license-validation/charts/ + key: helm-deps-${{ hashFiles('applications/license-validation/charts/license-validation/Chart.lock') }} + restore-keys: helm-deps- + + - name: Update image tag in HelmChart CR for CI + working-directory: applications/license-validation + run: | + yq -i '.spec.values.image.tag = "${{ needs.build-and-push-image.outputs.image-tag }}"' kots/license-validation-chart.yaml + + - name: Package chart + working-directory: applications/license-validation + run: | + task helm:update-deps + task release-prepare + + - name: Extract chart version + id: chart-version + working-directory: applications/license-validation + run: | + CHART_VERSION=$(yq '.version' charts/license-validation/Chart.yaml) + echo "chart_version=$CHART_VERSION" >> $GITHUB_OUTPUT + echo "Using chart version: $CHART_VERSION" + + - name: Create release + id: create-release + uses: replicatedhq/replicated-actions/create-release@v1.17.0 + with: + app-slug: ${{ env.APP_SLUG }} + api-token: ${{ secrets.REPLICATED_PLATFORM_EXAMPLES_TOKEN }} + yaml-dir: applications/license-validation/kots/ + promote-channel: ci-automation-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }} + version: ${{ steps.chart-version.outputs.chart_version }} + + - name: Create customer + id: create-customer + uses: replicatedhq/replicated-actions/create-customer@main + with: + app-slug: ${{ env.APP_SLUG }} + api-token: ${{ secrets.REPLICATED_PLATFORM_EXAMPLES_TOKEN }} + customer-name: automated-${{ github.run_id }} + customer-email: testcustomer@replicated.com + license-type: dev + channel-slug: ${{ steps.create-release.outputs.channel-slug }} + is-kots-install-enabled: "true" + entitlements: | + - name: edition + value: enterprise + - name: seat_count + value: "50" + + helm-install-test: + runs-on: ubuntu-22.04 + needs: [create-release, build-and-push-image] + strategy: + fail-fast: false + matrix: + cluster: + - distribution: k3s + version: "1.33" + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Helm + uses: azure/setup-helm@v4.3.0 + with: + version: v3.13.3 + + - name: Create Cluster + id: create-cluster + uses: replicatedhq/replicated-actions/create-cluster@v1.17.0 + with: + api-token: ${{ secrets.REPLICATED_PLATFORM_EXAMPLES_TOKEN }} + kubernetes-distribution: ${{ matrix.cluster.distribution }} + kubernetes-version: ${{ matrix.cluster.version }} + cluster-name: lv-ci-${{ github.run_id }}-${{ matrix.cluster.distribution }}-${{ matrix.cluster.version }} + disk: 50 + nodes: 1 + ttl: 1h + export-kubeconfig: true + + - name: Install via Helm + run: | + KUBECONFIG_FILE="/tmp/kubeconfig-${{ github.run_id }}" + echo "$KUBECONFIG" > "$KUBECONFIG_FILE" + + KUBECONFIG="$KUBECONFIG_FILE" helm upgrade --install license-validation \ + applications/license-validation/charts/license-validation \ + --namespace license-validation \ + --create-namespace \ + --set replicated.enabled=false \ + --set image.repository=ttl.sh/license-validation \ + --set image.tag=${{ needs.build-and-push-image.outputs.image-tag }} \ + --wait \ + --timeout 5m + env: + KUBECONFIG: ${{ steps.create-cluster.outputs.cluster-kubeconfig }} + + - name: Verify deployment + run: | + KUBECONFIG_FILE="/tmp/kubeconfig-${{ github.run_id }}" + echo "$KUBECONFIG" > "$KUBECONFIG_FILE" + + echo "Waiting for pods to be ready..." + KUBECONFIG="$KUBECONFIG_FILE" kubectl wait --for=condition=Ready pods \ + -l app.kubernetes.io/name=license-validation \ + -n license-validation \ + --timeout=2m + + echo "Checking deployment status..." + KUBECONFIG="$KUBECONFIG_FILE" kubectl get pods -n license-validation + + echo "Testing health endpoint..." + KUBECONFIG="$KUBECONFIG_FILE" kubectl port-forward \ + svc/license-validation 8888:80 \ + -n license-validation & + sleep 5 + + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8888/healthz) + if [ "$HTTP_CODE" != "200" ]; then + echo "Health check failed with HTTP $HTTP_CODE" + KUBECONFIG="$KUBECONFIG_FILE" kubectl logs -l app.kubernetes.io/name=license-validation -n license-validation + exit 1 + fi + echo "Health check passed (HTTP $HTTP_CODE)" + + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8888/) + echo "Dashboard returned HTTP $HTTP_CODE" + env: + KUBECONFIG: ${{ steps.create-cluster.outputs.cluster-kubeconfig }} + + - name: Debug on failure + if: failure() + run: | + KUBECONFIG_FILE="/tmp/kubeconfig-${{ github.run_id }}" + echo "$KUBECONFIG" > "$KUBECONFIG_FILE" + echo "=== Pod status ===" + KUBECONFIG="$KUBECONFIG_FILE" kubectl get pods -n license-validation -o wide + echo "=== Pod describe ===" + KUBECONFIG="$KUBECONFIG_FILE" kubectl describe pods -n license-validation + echo "=== Pod logs ===" + KUBECONFIG="$KUBECONFIG_FILE" kubectl logs -l app.kubernetes.io/name=license-validation -n license-validation --tail=50 + echo "=== Events ===" + KUBECONFIG="$KUBECONFIG_FILE" kubectl get events -n license-validation --sort-by='.lastTimestamp' + env: + KUBECONFIG: ${{ steps.create-cluster.outputs.cluster-kubeconfig }} + + - name: Remove Cluster + uses: replicatedhq/replicated-actions/remove-cluster@v1.17.0 + if: ${{ always() && steps.create-cluster.outputs.cluster-id != '' }} + with: + api-token: ${{ secrets.REPLICATED_PLATFORM_EXAMPLES_TOKEN }} + cluster-id: ${{ steps.create-cluster.outputs.cluster-id }} + + kots-install-test: + runs-on: ubuntu-22.04 + needs: [create-release, build-and-push-image] + strategy: + fail-fast: false + matrix: + cluster: + - distribution: k3s + version: "1.33" + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install yq + run: | + wget -q https://github.com/mikefarah/yq/releases/download/${{ env.YQ_VERSION }}/yq_linux_amd64 -O /usr/local/bin/yq + chmod +x /usr/local/bin/yq + + - name: Create Cluster + id: create-cluster + uses: replicatedhq/replicated-actions/create-cluster@v1.17.0 + with: + api-token: ${{ secrets.REPLICATED_PLATFORM_EXAMPLES_TOKEN }} + kubernetes-distribution: ${{ matrix.cluster.distribution }} + kubernetes-version: ${{ matrix.cluster.version }} + cluster-name: lv-kots-${{ github.run_id }}-${{ matrix.cluster.distribution }}-${{ matrix.cluster.version }} + disk: 50 + nodes: 1 + ttl: 1h + export-kubeconfig: true + + - name: Download license + id: download-license + run: | + CUSTOMER_ID="${{ needs.create-release.outputs.customer-id }}" + curl -s -H "Authorization: ${{ secrets.REPLICATED_PLATFORM_EXAMPLES_TOKEN }}" \ + "https://api.replicated.com/vendor/v3/app/${{ env.APP_SLUG }}/customer/$CUSTOMER_ID/license-download" \ + > /tmp/license.yaml + + if [ ! -s /tmp/license.yaml ]; then + echo "ERROR: License file is empty" + exit 1 + fi + + yq eval . /tmp/license.yaml > /dev/null + echo "License downloaded and validated" + + LICENSE_CONTENT=$(cat /tmp/license.yaml) + echo "license<> $GITHUB_OUTPUT + echo "$LICENSE_CONTENT" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: KOTS Install + uses: replicatedhq/replicated-actions/kots-install@v1.17.0 + with: + kubeconfig: ${{ steps.create-cluster.outputs.cluster-kubeconfig }} + kots-version: latest + app-slug: ${{ env.APP_SLUG }}/${{ needs.create-release.outputs.channel-slug }} + app-version-label: ${{ needs.create-release.outputs.chart-version }} + license-file: ${{ steps.download-license.outputs.license }} + namespace: license-validation + wait-duration: 10m + shared-password: 'license-validation-ci' + skip-preflights: true + debug: true + + - name: Verify KOTS deployment + run: | + KUBECONFIG_FILE="/tmp/kubeconfig-kots-${{ github.run_id }}" + echo "$KUBECONFIG" > "$KUBECONFIG_FILE" + + echo "Waiting for deployment to be ready..." + KUBECONFIG="$KUBECONFIG_FILE" kubectl wait --for=condition=Ready pods \ + -l app.kubernetes.io/name=license-validation \ + -n license-validation \ + --timeout=5m + + echo "Setting up port forwarding..." + KUBECONFIG="$KUBECONFIG_FILE" kubectl port-forward \ + svc/license-validation 8888:80 \ + -n license-validation & + sleep 5 + + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8888/healthz) + if [ "$HTTP_CODE" != "200" ]; then + echo "Health check failed with HTTP $HTTP_CODE" + exit 1 + fi + echo "Health check passed (HTTP $HTTP_CODE)" + env: + KUBECONFIG: ${{ steps.create-cluster.outputs.cluster-kubeconfig }} + + - name: Debug on failure + if: failure() + run: | + KUBECONFIG_FILE="/tmp/kubeconfig-kots-${{ github.run_id }}" + echo "$KUBECONFIG" > "$KUBECONFIG_FILE" + echo "=== Pod status ===" + KUBECONFIG="$KUBECONFIG_FILE" kubectl get pods -n license-validation -o wide + echo "=== Pod describe ===" + KUBECONFIG="$KUBECONFIG_FILE" kubectl describe pods -n license-validation + echo "=== Pod logs ===" + KUBECONFIG="$KUBECONFIG_FILE" kubectl logs -l app.kubernetes.io/name=license-validation -n license-validation --tail=50 + echo "=== Events ===" + KUBECONFIG="$KUBECONFIG_FILE" kubectl get events -n license-validation --sort-by='.lastTimestamp' + env: + KUBECONFIG: ${{ steps.create-cluster.outputs.cluster-kubeconfig }} + + - name: Install troubleshoot + run: curl -L https://github.com/replicatedhq/troubleshoot/releases/latest/download/support-bundle_linux_amd64.tar.gz | tar xzvf - + if: failure() + + - name: Collect bundle + run: | + KUBECONFIG_FILE="/tmp/kubeconfig-kots-bundle-${{ github.run_id }}" + echo "$KUBECONFIG" > "$KUBECONFIG_FILE" + ./support-bundle --kubeconfig="$KUBECONFIG_FILE" --interactive=false \ + -o kots-ci-bundle-${{ matrix.cluster.distribution }}-${{ matrix.cluster.version }} \ + https://raw.githubusercontent.com/replicatedhq/troubleshoot-specs/main/in-cluster/default.yaml + if: failure() + env: + KUBECONFIG: ${{ steps.create-cluster.outputs.cluster-kubeconfig }} + + - name: Upload support bundle artifact + uses: actions/upload-artifact@v4 + if: failure() + with: + name: lv-kots-bundle-${{ matrix.cluster.distribution }}-${{ matrix.cluster.version }} + path: 'kots-ci-bundle-${{ matrix.cluster.distribution }}-${{ matrix.cluster.version }}.tar.gz' + + - name: Remove Cluster + uses: replicatedhq/replicated-actions/remove-cluster@v1.17.0 + if: ${{ always() && steps.create-cluster.outputs.cluster-id != '' }} + with: + api-token: ${{ secrets.REPLICATED_PLATFORM_EXAMPLES_TOKEN }} + cluster-id: ${{ steps.create-cluster.outputs.cluster-id }} + + cleanup-test-release: + runs-on: ubuntu-22.04 + needs: [create-release, kots-install-test, helm-install-test] + if: always() + steps: + - name: Archive Customer + if: ${{ always() && needs.create-release.outputs.customer-id != '' }} + uses: replicatedhq/replicated-actions/archive-customer@v1.17.0 + with: + api-token: ${{ secrets.REPLICATED_PLATFORM_EXAMPLES_TOKEN }} + customer-id: ${{ needs.create-release.outputs.customer-id }} + + - name: Archive Channel + if: ${{ always() && needs.create-release.outputs.channel-slug != '' }} + uses: replicatedhq/replicated-actions/archive-channel@v1.17.0 + with: + app-slug: ${{ env.APP_SLUG }} + api-token: ${{ secrets.REPLICATED_PLATFORM_EXAMPLES_TOKEN }} + channel-slug: ${{ needs.create-release.outputs.channel-slug }} diff --git a/.gitignore b/.gitignore index 6695e663..493a6d1c 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,7 @@ __pycache__/ # Misc .env +sbom/ # Cursor .cursor/ diff --git a/applications/license-validation/README.md b/applications/license-validation/README.md index d2700583..68c4615e 100644 --- a/applications/license-validation/README.md +++ b/applications/license-validation/README.md @@ -116,7 +116,7 @@ export REPLICATED_APP=license-validation ### Step 2: Create Custom License Fields -1. In the Vendor Portal, go to **Settings > Custom License Fields** +1. In the Vendor Portal, go to **License Fields** 2. Create two fields: | Field Name | Type | Default | @@ -181,8 +181,7 @@ replicated cluster ls ### Step 7: Get Kubeconfig ```bash -replicated cluster kubeconfig \ - --name license-validation-demo \ +replicated cluster kubeconfig license-validation-demo \ --output-path ./demo.kubeconfig export KUBECONFIG=./demo.kubeconfig @@ -210,8 +209,11 @@ helm install license-validation \ **Option B: KOTS install** ```bash -# Download the customer license file from the Vendor Portal, then: -kubectl kots install $REPLICATED_APP \ +# Download the customer license file +replicated customer download-license --customer "Demo Customer" > ./license.yaml + +# Install via KOTS admin console +kubectl kots install $REPLICATED_APP/unstable \ --namespace license-validation \ --shared-password password \ --license-file ./license.yaml @@ -241,7 +243,7 @@ The app polls the SDK every 30 seconds, so changes appear within ~30s of saving ### Cleanup ```bash -replicated cluster rm --name license-validation-demo +replicated cluster rm license-validation-demo unset KUBECONFIG rm -f demo.kubeconfig ``` diff --git a/applications/license-validation/charts/license-validation/values.yaml b/applications/license-validation/charts/license-validation/values.yaml index e71ba105..6db94e7f 100644 --- a/applications/license-validation/charts/license-validation/values.yaml +++ b/applications/license-validation/charts/license-validation/values.yaml @@ -5,8 +5,8 @@ replicated: enabled: true image: - repository: ghcr.io/replicatedhq/platform-examples/license-validation - tag: latest + repository: ttl.sh/license-validation + tag: 2h pullPolicy: IfNotPresent replicaCount: 1 diff --git a/applications/license-validation/kots/kots-app.yaml b/applications/license-validation/kots/kots-app.yaml index 9b1f83f4..2173843c 100644 --- a/applications/license-validation/kots/kots-app.yaml +++ b/applications/license-validation/kots/kots-app.yaml @@ -7,8 +7,8 @@ spec: icon: "" releaseNotes: "" allowRollback: true - requireMinimalRBACPrivileges: true - supportMinimalRBACPrivileges: true + requireMinimalRBACPrivileges: false + supportMinimalRBACPrivileges: false ports: - serviceName: license-validation servicePort: 80 diff --git a/applications/license-validation/kots/license-validation-chart.yaml b/applications/license-validation/kots/license-validation-chart.yaml index 7eb591f9..2db288a9 100644 --- a/applications/license-validation/kots/license-validation-chart.yaml +++ b/applications/license-validation/kots/license-validation-chart.yaml @@ -11,8 +11,8 @@ spec: imagePullSecrets: - name: repl{{ ImagePullSecretName }} image: - repository: repl{{ HasLocalRegistry | ternary (print LocalRegistryHost "/replicatedhq/platform-examples/license-validation") "ghcr.io/replicatedhq/platform-examples/license-validation" }} - tag: latest + repository: repl{{ HasLocalRegistry | ternary (print LocalRegistryHost "/license-validation") "ttl.sh/license-validation" }} + tag: 2h pullPolicy: IfNotPresent replicated: enabled: true From 837f5e1c6c7434cb8a3cb324561ab28f898545ca Mon Sep 17 00:00:00 2001 From: Kris Coleman Date: Thu, 19 Mar 2026 20:37:11 -0400 Subject: [PATCH 3/7] chore: gitignore Replicated license files Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 493a6d1c..0580bf37 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,6 @@ applications/flipt/chart/Chart.lock **/.claude/settings.local.json .worktrees/ + +# Replicated license files +**/license.yaml From 6e6758deb828039a51d2c69eac4faa3cc91eef2a Mon Sep 17 00:00:00 2001 From: Kris Coleman Date: Fri, 20 Mar 2026 11:16:30 -0400 Subject: [PATCH 4/7] fix(license-validation): replace macOS-only sed with yq for cross-platform CI Co-Authored-By: Claude Opus 4.6 (1M context) --- applications/license-validation/Taskfile.yaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/applications/license-validation/Taskfile.yaml b/applications/license-validation/Taskfile.yaml index e7a8274d..da107375 100644 --- a/applications/license-validation/Taskfile.yaml +++ b/applications/license-validation/Taskfile.yaml @@ -42,8 +42,7 @@ tasks: desc: Package chart and update KOTS manifest version reference cmds: - helm package {{.CHART_DIR}} -d {{.KOTS_DIR}} - - | - sed -i '' "s/chartVersion: .*/chartVersion: {{.CHART_VERSION}}/" {{.KOTS_DIR}}/{{.APP_NAME}}-chart.yaml + - yq -i '.spec.chart.chartVersion = "{{.CHART_VERSION}}"' {{.KOTS_DIR}}/{{.APP_NAME}}-chart.yaml release-create: desc: Create a Replicated release and promote to channel From a319864d9b97fd0626cb7d58c97742a5bad17b9f Mon Sep 17 00:00:00 2001 From: ada mancini Date: Wed, 1 Apr 2026 15:21:08 -0400 Subject: [PATCH 5/7] fix(license-validation): fix SDK address and multi-arch image build - Change default SDK address from http://license-validation-replicated:3000 to http://replicated:3000 to match the actual service name deployed by the Replicated SDK subchart - Add --platform linux/amd64,linux/arm64 to docker build command so the image runs on CMX K3s clusters when built on Apple Silicon --- applications/license-validation/README.md | 2 +- applications/license-validation/app/main.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/applications/license-validation/README.md b/applications/license-validation/README.md index 68c4615e..59219823 100644 --- a/applications/license-validation/README.md +++ b/applications/license-validation/README.md @@ -128,7 +128,7 @@ export REPLICATED_APP=license-validation ```bash # Build the Docker image -docker build -t ttl.sh/license-validation:2h . +docker build --platform linux/amd64,linux/arm64 -t ttl.sh/license-validation:2h . # Push to ttl.sh (ephemeral registry, good for demos) docker push ttl.sh/license-validation:2h diff --git a/applications/license-validation/app/main.go b/applications/license-validation/app/main.go index 5a544320..d7c2cf7f 100644 --- a/applications/license-validation/app/main.go +++ b/applications/license-validation/app/main.go @@ -80,7 +80,7 @@ var ( func main() { sdkAddr := os.Getenv("REPLICATED_SDK_ADDRESS") if sdkAddr == "" { - sdkAddr = "http://license-validation-replicated:3000" + sdkAddr = "http://replicated:3000" } pubKeyPEM := os.Getenv("REPLICATED_APP_PUBLIC_KEY") From f927b242abd2cd7e698023e3776a767bf552a76d Mon Sep 17 00:00:00 2001 From: James Wilson <14128934+jmboby@users.noreply.github.com> Date: Thu, 2 Apr 2026 08:59:53 +1300 Subject: [PATCH 6/7] Add amd64 platform flag to Taskfile docker build target --- applications/license-validation/Taskfile.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/applications/license-validation/Taskfile.yaml b/applications/license-validation/Taskfile.yaml index da107375..281c9c4a 100644 --- a/applications/license-validation/Taskfile.yaml +++ b/applications/license-validation/Taskfile.yaml @@ -34,7 +34,7 @@ tasks: docker:build: desc: Build the Docker image locally cmds: - - docker build -t {{.APP_NAME}}:latest . + - docker build -platform linux/amd64,linux/arm64 -t {{.APP_NAME}}:latest . # ── Release ──────────────────────────────────────────────── From 27f90407ee66d4b5cfd527f0cdde86b7ca1cabdb Mon Sep 17 00:00:00 2001 From: James Wilson <14128934+jmboby@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:03:39 +1300 Subject: [PATCH 7/7] fix(license-validation): add extra dash to docker build --platform flag --- applications/license-validation/Taskfile.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/applications/license-validation/Taskfile.yaml b/applications/license-validation/Taskfile.yaml index 281c9c4a..d32e4915 100644 --- a/applications/license-validation/Taskfile.yaml +++ b/applications/license-validation/Taskfile.yaml @@ -34,7 +34,7 @@ tasks: docker:build: desc: Build the Docker image locally cmds: - - docker build -platform linux/amd64,linux/arm64 -t {{.APP_NAME}}:latest . + - docker build --platform linux/amd64,linux/arm64 -t {{.APP_NAME}}:latest . # ── Release ────────────────────────────────────────────────