···11-# Loom Kubernetes Operator - Implementation Plan
22-33-## Project Overview
44-55-Loom is a Kubernetes operator for coordinating tangled.org Spindles - ephemeral CI/CD runners that execute pipelines in response to events from tangled.org knots. Inspired by GitHub's Actions Runner Controller (ARC) but adapted for tangled.org's AT Protocol-based, event-driven architecture.
66-77-## Architecture
88-99-### Core Principles
1010-- **Ephemeral Spindles**: One Kubernetes Job per pipeline event (scale-to-zero)
1111-- **Event-Driven**: WebSocket connection to tangled.org knot for pipeline events
1212-- **Code Reuse**: Leverage `tangled.org/core/spindle` for WebSocket, models, interfaces
1313-- **Simple Images**: Use standard Docker images (golang:1.24, node:20, etc.) - no Nixery for MVP
1414-- **Multi-Arch Support**: Schedule jobs on amd64/arm64 nodes based on workflow specification
1515-- **New Component**: Kubernetes-native Engine that spawns Jobs instead of Docker containers
1616-1717-### Key Components
1818-1919-1. **SpindleSet CRD**: Configures connection to tangled.org knot and job templates
2020-2. **SpindleSet Controller**: Maintains WebSocket connection, handles pipeline events
2121-3. **KubernetesEngine**: Implements tangled.org's Engine interface for Kubernetes Jobs
2222-4. **Job Builder**: Generates Job specs with multi-arch node affinity
2323-5. **Log Streamer**: Streams pod logs to knot via Kubernetes API
2424-6. **Status Reporter**: Reports workflow status back to tangled.org
2525-2626----
2727-2828-## Phase 1: CRD Design & Basic Structure
2929-3030-### SpindleSet CRD
3131-```yaml
3232-apiVersion: loom.j5t.io/v1alpha1
3333-kind: SpindleSet
3434-metadata:
3535- name: tangled-org-spindle
3636-spec:
3737- # Knot configuration
3838- knotUrl: https://tangled.org/@org/repo
3939- knotAuthSecret: spindle-auth # Secret with auth token
4040-4141- # Scaling configuration
4242- maxConcurrentJobs: 10
4343-4444- # Default template (can be overridden by workflow)
4545- template:
4646- resources:
4747- requests:
4848- cpu: 500m
4949- memory: 1Gi
5050- limits:
5151- cpu: 2
5252- memory: 4Gi
5353-5454- # Node targeting defaults
5555- nodeSelector: {}
5656- tolerations: []
5757- affinity: {}
5858-```
5959-6060-### Status Fields
6161-- `conditions`: Standard Kubernetes conditions
6262-- `pendingJobs`, `runningJobs`: Current job counts
6363-- `completedJobs`, `failedJobs`: Cumulative counters
6464-- `webSocketConnected`: WebSocket connection status
6565-- `lastEventTime`: Last received event timestamp
6666-6767----
6868-6969-## Phase 2: Kubernetes Engine Implementation
7070-7171-### Workflow File Format
7272-```yaml
7373-# In tangled.org repository's .tangled/pipeline.yaml
7474-image: golang:1.24-bookworm
7575-architecture: amd64 # or arm64
7676-7777-steps:
7878- - name: run tests
7979- command: |
8080- go test -v ./...
8181-8282- - name: build binary
8383- command: |
8484- go build -o app ./cmd
8585-```
8686-8787-### Job Pod Structure
8888-- **Init container**: Clone repository from tangled.org
8989-- **Main container**:
9090- - Image: `{workflow.image}` (e.g., `golang:1.24-bookworm`)
9191- - Platform: `linux/{architecture}`
9292- - Execute all steps sequentially
9393-- **Volumes**:
9494- - `/tangled/workspace` - Shared workspace (emptyDir)
9595- - `/tmp/step-outputs` - Step output communication
9696- - `/tmp/github` - GITHUB_ENV-style env passing
9797-- **Node Affinity**: Based on `architecture` field
9898-9999-### Multi-Architecture Support
100100-```go
101101-func (e *KubernetesEngine) buildJobAffinity(arch string) *corev1.Affinity {
102102- return &corev1.Affinity{
103103- NodeAffinity: &corev1.NodeAffinity{
104104- RequiredDuringSchedulingIgnoredDuringExecution: &corev1.NodeSelector{
105105- NodeSelectorTerms: []corev1.NodeSelectorTerm{
106106- {
107107- MatchExpressions: []corev1.NodeSelectorRequirement{
108108- {
109109- Key: "kubernetes.io/arch",
110110- Operator: corev1.NodeSelectorOpIn,
111111- Values: []string{arch}, // amd64 or arm64
112112- },
113113- },
114114- },
115115- },
116116- },
117117- },
118118- }
119119-}
120120-```
121121-122122-### Step Execution Model
123123-Generate bash script that executes all steps sequentially:
124124-- GitHub Actions-compatible environment variables (`GITHUB_ENV`, `GITHUB_OUTPUT`)
125125-- Environment passing between steps
126126-- Error handling and exit on failure
127127-- Step-level logging with timestamps
128128-129129----
130130-131131-## Phase 3: WebSocket Integration & Event Handling
132132-133133-### WebSocket Client (Reuse from core/spindle)
134134-- Connect to `{knotUrl}/spindle/events`
135135-- Handle cursor-based backfill for missed events
136136-- Subscribe to live `sh.tangled.pipeline` events
137137-- Exponential backoff on connection failures
138138-139139-### Event Handler → Job Creation
140140-1. Parse pipeline event payload
141141-2. Extract workflow definition, repo, commit SHA
142142-3. Create Kubernetes Job with:
143143- - Correct architecture node affinity
144144- - Image from workflow spec
145145- - Steps as bash script
146146- - Owner reference to SpindleSet (for cleanup)
147147-4. Label Job with pipeline metadata
148148-149149-### SpindleSet Controller Reconciliation
150150-- Establish WebSocket connection to knot
151151-- Subscribe to pipeline events
152152-- Create Jobs on event received
153153-- Monitor running Jobs
154154-- Update SpindleSet status
155155-- Handle connection failures
156156-157157----
158158-159159-## Phase 4: Status Reporting & Observability
160160-161161-### Job Status Tracking
162162-Watch Job events via Kubernetes API:
163163-- Job created → Report "running" to knot
164164-- Job succeeded → Report "success" to knot
165165-- Job failed → Report "failure" with error to knot
166166-- Job timeout → Report "timeout" to knot
167167-168168-### Status Reporting to Knot
169169-Reuse `spindle/db` status update patterns:
170170-- `StatusRunning()` - When Job starts
171171-- `StatusSuccess()` - When Job succeeds
172172-- `StatusFailed()` - When Job fails with error message
173173-- `StatusTimeout()` - When Job exceeds timeout
174174-175175-### Prometheus Metrics
176176-```go
177177-loom_pending_spindles // Gauge: jobs pending
178178-loom_running_spindles // Gauge: jobs running
179179-loom_completed_spindles_total // Counter: total completed
180180-loom_failed_spindles_total // Counter: total failed
181181-loom_pipeline_duration_seconds // Histogram: execution duration
182182-```
183183-184184-Exposed via controller-runtime's metrics server.
185185-186186----
187187-188188-## Phase 5: Log Streaming via Kubernetes API
189189-190190-### Implementation
191191-```go
192192-func (e *KubernetesEngine) StreamLogsToKnot(ctx context.Context, jobName string, knotClient *KnotClient) {
193193- // 1. Get pod for job
194194- // 2. Stream logs via K8s API
195195- // 3. Forward each line to knot in real-time
196196-}
197197-```
198198-199199-### Log Format
200200-Send to knot in tangled.org spindle format:
201201-```json
202202-{
203203- "kind": "data", // or "control"
204204- "content": "test output line",
205205- "stepId": 0,
206206- "stepKind": "user"
207207-}
208208-```
209209-210210----
211211-212212-## Phase 6: Testing & Deployment
213213-214214-### Unit Tests
215215-- Job template generation with different architectures
216216-- Node affinity generation (amd64 vs arm64)
217217-- Step script builder
218218-- Mock WebSocket client
219219-220220-### Integration Tests
221221-```go
222222-// Test with real cluster
223223-func TestE2E_SimpleGoPipeline(t *testing.T) {
224224- // 1. Deploy SpindleSet CR
225225- // 2. Send test pipeline event
226226- // 3. Verify Job created on correct arch node
227227- // 4. Wait for completion
228228- // 5. Check logs streamed to knot
229229-}
230230-```
231231-232232-### Manual Testing
233233-```bash
234234-# Deploy operator
235235-make deploy IMG=ghcr.io/you/loom:v0.1.0
236236-237237-# Create SpindleSet
238238-kubectl apply -f config/samples/spindleset_sample.yaml
239239-240240-# Push code to tangled.org with .tangled/pipeline.yaml
241241-242242-# Watch Jobs
243243-kubectl get jobs -l loom.j5t.io/spindleset=test-spindle -w
244244-245245-# Check pod placement
246246-kubectl get pods -o wide
247247-248248-# View logs
249249-kubectl logs -f job/runner-<hash>
250250-```
251251-252252----
253253-254254-## File Structure
255255-256256-```
257257-loom/
258258-├── api/v1alpha1/
259259-│ ├── spindleset_types.go # SpindleSet CRD
260260-│ └── groupversion_info.go
261261-│
262262-├── internal/
263263-│ ├── controller/
264264-│ │ └── spindleset_controller.go # Main reconciliation loop
265265-│ │
266266-│ └── engine/
267267-│ └── kubernetes_engine.go # K8s-native Engine implementation
268268-│
269269-├── pkg/
270270-│ ├── ingester/
271271-│ │ └── websocket.go # WebSocket client (adapted from core)
272272-│ │
273273-│ ├── jobbuilder/
274274-│ │ ├── job_template.go # Generate Job specs
275275-│ │ ├── affinity.go # Multi-arch node affinity
276276-│ │ └── script_builder.go # Step execution script
277277-│ │
278278-│ └── knot/
279279-│ └── client.go # Knot API client for status/logs
280280-│
281281-├── config/
282282-│ ├── crd/ # Generated CRD manifests
283283-│ ├── rbac/ # RBAC for Job CRUD
284284-│ └── samples/
285285-│ └── spindleset_sample.yaml
286286-│
287287-└── cmd/main.go # Operator entrypoint
288288-```
289289-290290----
291291-292292-## Dependencies
293293-294294-### From tangled.org/core
295295-```go
296296-import (
297297- "tangled.org/core/spindle/models" // Engine interface
298298- "tangled.org/core/spindle/config" // Config models
299299- "tangled.org/core/api/tangled" // Pipeline types
300300- // Adapt WebSocket logic from spindle/stream.go, ingester.go
301301-)
302302-```
303303-304304-### Kubernetes
305305-```go
306306-import (
307307- batchv1 "k8s.io/api/batch/v1"
308308- corev1 "k8s.io/api/core/v1"
309309- "sigs.k8s.io/controller-runtime/pkg/client"
310310-)
311311-```
312312-313313-### Metrics
314314-```go
315315-import (
316316- "github.com/prometheus/client_golang/prometheus"
317317- "sigs.k8s.io/controller-runtime/pkg/metrics"
318318-)
319319-```
320320-321321----
322322-323323-## Implementation Order
324324-325325-1. ✅ Create SpindleSet CRD (API types, generate manifests)
326326-2. ⏳ Implement Job builder (template generation, multi-arch affinity)
327327-3. ⏳ Implement KubernetesEngine (Engine interface for K8s Jobs)
328328-4. ⏳ Import WebSocket client (adapt from core/spindle)
329329-5. ⏳ Implement SpindleSet controller (reconciliation + event handling)
330330-6. ⏳ Add Job status monitoring (watch Jobs, report to knot)
331331-7. ⏳ Add log streaming (K8s API → knot)
332332-8. ⏳ Add Prometheus metrics (instrument controller)
333333-9. ⏳ Testing (unit + integration tests)
334334-10. ⏳ Documentation (usage guide, architecture diagrams)
335335-336336----
337337-338338-## MVP Scope
339339-340340-### Include ✅
341341-- SpindleSet CRD with knot configuration
342342-- WebSocket connection to knot
343343-- Kubernetes Job creation per pipeline event
344344-- Multi-architecture support (amd64/arm64 node targeting)
345345-- Standard Docker images (golang:1.24, node:20, etc.)
346346-- Sequential step execution in single pod
347347-- Log streaming from K8s pods to knot via K8s API
348348-- Status reporting to knot (success/failure/timeout)
349349-- Prometheus metrics
350350-351351-### Exclude (Future Enhancements) ❌
352352-- Nixery integration (add later)
353353-- Kaniko/Buildah for container builds
354354-- Persistent Nix store caching
355355-- Multi-knot support
356356-- Advanced auto-scaling policies
357357-- Service containers (DB sidecars)
358358-- Matrix builds
359359-360360----
361361-362362-## Key Design Decisions
363363-364364-1. **Ephemeral Jobs**: Scale-to-zero, one Job per pipeline event
365365-2. **Simple Images**: Use any Docker Hub image, no Nixery complexity for MVP
366366-3. **Multi-Arch Native**: Use Kubernetes node affinity for amd64/arm64 targeting
367367-4. **All steps in one pod**: GitHub Actions model (shared filesystem/env)
368368-5. **K8s API for logs**: Stream pod logs to knot, no disk-based logging needed
369369-6. **Reuse spindle models**: Maintain compatibility, adapt only execution layer
370370-7. **Prometheus metrics**: Standard observability from day one
371371-372372----
373373-374374-## Future Enhancements
375375-376376-### Phase 7: Nixery Integration
377377-- Detect `dependencies.nixpkgs` in workflow spec
378378-- Generate Nixery image URL dynamically
379379-- Support both standard images and Nixery
380380-- Implement Nix store caching (PVC)
381381-382382-### Phase 8: Advanced Features
383383-- ✅ **Buildah integration for container builds** (MVP completed - no caching)
384384-- Service containers (like GitHub Actions services)
385385-- Matrix builds (multiple arch/version combinations)
386386-- Caching strategies (build cache, dependencies)
387387-- Advanced auto-scaling (predictive scaling)
388388-389389-### Phase 9: Multi-Tenancy
390390-- Multiple SpindleSets per cluster
391391-- Resource quotas per SpindleSet
392392-- Network policies for isolation
393393-- Multi-knot support (one operator, many knots)
394394-395395----
396396-397397-## Success Criteria
398398-399399-**MVP is complete when:**
400400-1. SpindleSet CRD can be deployed to cluster
401401-2. WebSocket connection to tangled.org knot established
402402-3. Pipeline events trigger Job creation
403403-4. Jobs execute on correct architecture nodes
404404-5. Logs stream back to knot in real-time
405405-6. Status updates sent to knot (success/failure)
406406-7. Prometheus metrics exposed
407407-8. Basic integration test passes
408408-409409-**Production-ready when:**
410410-1. Full test coverage (unit + integration)
411411-2. Error handling and retry logic robust
412412-3. Documentation complete
413413-4. Helm chart available
414414-5. Multi-arch container images published
415415-6. Performance benchmarked
416416-7. Security review completed
+154-85
README.md
···11-# loom
22-// TODO(user): Add simple overview of use/purpose
11+# Loom
3244-## Description
55-// TODO(user): An in-depth paragraph about your project and overview of use
33+Loom is a Kubernetes operator that runs CI/CD pipeline workflows from [tangled.org](https://tangled.org). It creates ephemeral Jobs in response to events (pushes, pull requests) and streams logs back to the tangled.org platform.
6477-## Getting Started
55+## Architecture
8699-### Prerequisites
1010-- go version v1.24.0+
1111-- docker version 17.03+.
1212-- kubectl version v1.11.3+.
1313-- Access to a Kubernetes v1.11.3+ cluster.
77+```
88+┌─────────────────────────────────────────────────────────────┐
99+│ Loom Operator Pod │
1010+│ │
1111+│ ┌────────────────────────────────────────────────────────┐ │
1212+│ │ Controller Manager │ │
1313+│ │ - Watches SpindleSet CRD │ │
1414+│ │ - Creates/monitors Kubernetes Jobs │ │
1515+│ └────────────────────────────────────────────────────────┘ │
1616+│ │
1717+│ ┌────────────────────────────────────────────────────────┐ │
1818+│ │ Embedded Spindle Server │ │
1919+│ │ - WebSocket connection to tangled.org knots │ │
2020+│ │ - Database, queue, secrets vault │ │
2121+│ │ - KubernetesEngine (creates Jobs) │ │
2222+│ └────────────────────────────────────────────────────────┘ │
2323+└─────────────────────────────────────────────────────────────┘
2424+ │
2525+ │ creates
2626+ ▼
2727+ ┌───────────────────────────────┐
2828+ │ Kubernetes Job (per workflow) │
2929+ │ │
3030+ │ Init: setup-user, clone-repo │
3131+ │ Main: runner binary + image │
3232+ └───────────────────────────────┘
3333+```
14341515-### To Deploy on the cluster
1616-**Build and push your image to the location specified by `IMG`:**
3535+### Components
17361818-```sh
1919-make docker-build docker-push IMG=<some-registry>/loom:tag
2020-```
3737+**Controller (`cmd/controller`)** - The Kubernetes operator that:
3838+- Connects to tangled.org knots via WebSocket to receive pipeline events
3939+- Creates `SpindleSet` custom resources for each pipeline run
4040+- Reconciles SpindleSets into Kubernetes Jobs
4141+- Manages secrets injection and cleanup
21422222-**NOTE:** This image ought to be published in the personal registry you specified.
2323-And it is required to have access to pull the image from the working environment.
2424-Make sure you have the proper permission to the registry if the above commands don’t work.
4343+**Runner (`cmd/runner`)** - A lightweight binary injected into workflow pods that:
4444+- Executes workflow steps sequentially
4545+- Emits structured JSON log events for real-time status updates
4646+- Handles step-level environment variable injection
25472626-**Install the CRDs into the cluster:**
4848+## How It Works
27492828-```sh
2929-make install
3030-```
5050+1. A push or PR event triggers a pipeline on tangled.org
5151+2. Loom receives the event via WebSocket and parses the workflow YAML
5252+3. A `SpindleSet` CR is created with the pipeline specification
5353+4. The controller creates a Kubernetes Job with:
5454+ - Init containers for user setup and repository cloning
5555+ - The runner binary injected via shared volume
5656+ - The user's workflow image as the main container
5757+5. The runner executes steps and streams logs back to the controller
5858+6. On completion, the SpindleSet and its resources are cleaned up
31593232-**Deploy the Manager to the cluster with the image specified by `IMG`:**
6060+## Features
33613434-```sh
3535-make deploy IMG=<some-registry>/loom:tag
6262+- **Multi-architecture support**: Schedule workflows on amd64 or arm64 nodes
6363+- **Rootless container builds**: Buildah support with user namespace configuration
6464+- **Secret management**: Repository secrets injected as environment variables with log masking
6565+- **Resource profiles**: Configure CPU/memory based on node labels
6666+- **Automatic cleanup**: TTL-based Job cleanup and orphan detection
6767+6868+## Configuration
6969+7070+### Loom ConfigMap
7171+7272+Loom is configured via a ConfigMap mounted at `/etc/loom/config.yaml`:
7373+7474+```yaml
7575+maxConcurrentJobs: 10
7676+template:
7777+ resourceProfiles:
7878+ - nodeSelector:
7979+ kubernetes.io/arch: amd64
8080+ resources:
8181+ requests:
8282+ cpu: "500m"
8383+ memory: "1Gi"
8484+ limits:
8585+ cpu: "2"
8686+ memory: "4Gi"
8787+ - nodeSelector:
8888+ kubernetes.io/arch: arm64
8989+ resources:
9090+ requests:
9191+ cpu: "500m"
9292+ memory: "1Gi"
9393+ limits:
9494+ cpu: "2"
9595+ memory: "4Gi"
3696```
37973838-> **NOTE**: If you encounter RBAC errors, you may need to grant yourself cluster-admin
3939-privileges or be logged in as admin.
9898+### Spindle Environment Variables
9999+100100+The embedded spindle server is configured via environment variables:
101101+102102+| Variable | Required | Description |
103103+|----------|----------|-------------|
104104+| `SPINDLE_SERVER_LISTEN_ADDR` | Yes | HTTP server address (e.g., `0.0.0.0:6555`) |
105105+| `SPINDLE_SERVER_DB_PATH` | Yes | SQLite database path |
106106+| `SPINDLE_SERVER_HOSTNAME` | Yes | Hostname for spindle DID |
107107+| `SPINDLE_SERVER_OWNER` | Yes | Owner DID (e.g., `did:web:example.com`) |
108108+| `SPINDLE_SERVER_JETSTREAM_ENDPOINT` | Yes | Bluesky jetstream WebSocket URL |
109109+| `SPINDLE_SERVER_MAX_JOB_COUNT` | No | Max concurrent workflows (default: 2) |
110110+| `SPINDLE_SERVER_SECRETS_PROVIDER` | No | `sqlite` or `openbao` (default: sqlite) |
401114141-**Create instances of your solution**
4242-You can apply the samples (examples) from the config/sample:
112112+## Workflow Format
431134444-```sh
4545-kubectl apply -k config/samples/
4646-```
114114+Workflows are defined in `.tangled/workflows/*.yaml` in your repository:
471154848->**NOTE**: Ensure that the samples has default values to test it out.
116116+```yaml
117117+image: golang:1.24
118118+architecture: amd64
491195050-### To Uninstall
5151-**Delete the instances (CRs) from the cluster:**
120120+steps:
121121+ - name: Build
122122+ command: go build ./...
521235353-```sh
5454-kubectl delete -k config/samples/
124124+ - name: Test
125125+ command: go test ./...
55126```
561275757-**Delete the APIs(CRDs) from the cluster:**
128128+## Security
581295959-```sh
6060-make uninstall
6161-```
130130+### Job Pod Security
621316363-**UnDeploy the controller from the cluster:**
132132+Jobs run with hardened security contexts:
133133+- Non-root user (UID 1000)
134134+- Minimal capabilities (only SETUID/SETGID for buildah)
135135+- No service account token mounting
136136+- Unconfined seccomp (required for buildah user namespaces)
641376565-```sh
6666-make undeploy
6767-```
138138+### Secrets
681396969-## Project Distribution
140140+Repository secrets are:
141141+- Stored in the spindle vault (SQLite or OpenBao)
142142+- Injected as environment variables via Kubernetes Secrets
143143+- Masked in log output
701447171-Following the options to release and provide this solution to the users.
145145+## Prerequisites
146146+147147+- go version v1.24.0+
148148+- docker version 17.03+
149149+- kubectl version v1.11.3+
150150+- Access to a Kubernetes v1.11.3+ cluster
721517373-### By providing a bundle with all YAML files
152152+## Deployment
741537575-1. Build the installer for the image built and published in the registry:
154154+Build and push the image:
7615577156```sh
7878-make build-installer IMG=<some-registry>/loom:tag
157157+make docker-build docker-push IMG=<registry>/loom:tag
79158```
801598181-**NOTE:** The makefile target mentioned above generates an 'install.yaml'
8282-file in the dist directory. This file contains all the resources built
8383-with Kustomize, which are necessary to install this project without its
8484-dependencies.
160160+Install the CRDs:
851618686-2. Using the installer
162162+```sh
163163+make install
164164+```
871658888-Users can just run 'kubectl apply -f <URL for YAML BUNDLE>' to install
8989-the project, i.e.:
166166+Deploy the controller:
9016791168```sh
9292-kubectl apply -f https://raw.githubusercontent.com/<org>/loom/<tag or branch>/dist/install.yaml
169169+make deploy IMG=<registry>/loom:tag
93170```
941719595-### By providing a Helm Chart
172172+## Development
961739797-1. Build the chart using the optional helm plugin
174174+Generate CRDs and code:
9817599176```sh
100100-operator-sdk edit --plugins=helm/v1-alpha
177177+make manifests generate
101178```
102179103103-2. See that a chart was generated under 'dist/chart', and users
104104-can obtain this solution from there.
180180+Run tests:
181181+182182+```sh
183183+make test
184184+```
105185106106-**NOTE:** If you change the project, you need to update the Helm Chart
107107-using the same command above to sync the latest changes. Furthermore,
108108-if you create webhooks, you need to use the above command with
109109-the '--force' flag and manually ensure that any custom configuration
110110-previously added to 'dist/chart/values.yaml' or 'dist/chart/manager/manager.yaml'
111111-is manually re-applied afterwards.
186186+Run locally (for debugging):
112187113113-## Contributing
114114-// TODO(user): Add detailed information on how you would like others to contribute to this project
188188+```sh
189189+make install run
190190+```
115191116116-**NOTE:** Run `make help` for more information on all potential `make` targets
192192+## Uninstall
117193118118-More information can be found via the [Kubebuilder Documentation](https://book.kubebuilder.io/introduction.html)
194194+```sh
195195+kubectl delete -k config/samples/
196196+make uninstall
197197+make undeploy
198198+```
119199120200## License
121201122202Copyright 2025 Evan Jarrett.
123203124124-Licensed under the Apache License, Version 2.0 (the "License");
125125-you may not use this file except in compliance with the License.
126126-You may obtain a copy of the License at
127127-128128- http://www.apache.org/licenses/LICENSE-2.0
129129-130130-Unless required by applicable law or agreed to in writing, software
131131-distributed under the License is distributed on an "AS IS" BASIS,
132132-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
133133-See the License for the specific language governing permissions and
134134-limitations under the License.
135135-204204+Licensed under the Apache License, Version 2.0.