Compare commits
10 Commits
feature/registry
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8724360fb2 | |||
| a4a8554fb6 | |||
| 272ed647cf | |||
| 5818b05d96 | |||
| 59b2c10c43 | |||
| e1ebe7d139 | |||
| 86decddcd1 | |||
| c1c2d1c83b | |||
| 58ca1561ec | |||
| 95efe242ac |
@@ -0,0 +1,233 @@
|
|||||||
|
# Gitea → MicroK8s Webhook Orchestrator → Scaleway Containers (CI Runner System)
|
||||||
|
|
||||||
|
A lightweight CI orchestration system where a self-hosted Gitea instance triggers a Go-based webhook service running inside MicroK8s. The service decides whether to run jobs locally or launch ephemeral CI runners in Scaleway Serverless Containers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. High-Level Architecture
|
||||||
|
|
||||||
|
```text
|
||||||
|
On-Premises (MicroK8s Cluster)
|
||||||
|
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ Gitea │
|
||||||
|
│ │
|
||||||
|
│ Repo push / PR event │
|
||||||
|
└───────────────┬──────────────────────────────────────────────┘
|
||||||
|
│ Webhook (internal cluster network)
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ ci-orchestrator (Go service) │
|
||||||
|
│ │
|
||||||
|
│ - Validates webhook signature (HMAC) │
|
||||||
|
│ - Parses event payload │
|
||||||
|
│ - Applies routing rules │
|
||||||
|
│ - Decides runner target (local vs cloud) │
|
||||||
|
└───────────────┬──────────────────────────────────────────────┘
|
||||||
|
│ HTTPS API call (authenticated)
|
||||||
|
▼
|
||||||
|
──────────────────────── Internet ───────────────────────────────
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ Scaleway Serverless Containers API │
|
||||||
|
│ │
|
||||||
|
│ - Starts ephemeral container │
|
||||||
|
│ - Runs Gitea Runner │
|
||||||
|
│ - Executes CI job │
|
||||||
|
│ - Shuts down after completion │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Current State
|
||||||
|
|
||||||
|
| Component | Location | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| **Gitea** | `prod-01.alexpires.me` (K8s) | Deployed via Terraform module, TLS, ingress, S3 storage, MariaDB |
|
||||||
|
| **MicroK8s** | `prod-01`, `dev-01` | nginx ingress, cert-manager, multiple apps running |
|
||||||
|
| **Scaleway** | `fr-par` | Registry + S3 buckets + IAM keys exist, no Serverless Containers yet |
|
||||||
|
| **Gitea Runners** | `ci-01.lab.alexpires.me` | Windows Podman + native runners already deployed |
|
||||||
|
| **DNS** | CoreDNS in `dev-01` | `ci-01 → 10.19.4.207` already resolved |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Design Decisions
|
||||||
|
|
||||||
|
1. **Orchestrator cluster:** `prod-01` for low latency to Gitea
|
||||||
|
2. **Routing:** Cloud runners only trigger when `cloud` label is present
|
||||||
|
3. **Runner image:** `gitea/act_runner` (official)
|
||||||
|
4. **Webhook registration:** Terraform-managed via `go-gitea/gitea` provider
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Implementation Plan
|
||||||
|
|
||||||
|
### Phase 1: Scaleway IaC (`terraform/iac/scaleway/`)
|
||||||
|
|
||||||
|
**New Terraform resources:**
|
||||||
|
- `scaleway_container_namespace` — CI runners namespace
|
||||||
|
- `scaleway_container` — the `gitea/act_runner` container (ephemeral, privacy=private)
|
||||||
|
- `scaleway_iam_policy` — `ContainersInvoke` permission for the CI orchestrator app
|
||||||
|
- `scaleway_container_domain` (optional) — custom domain if desired
|
||||||
|
|
||||||
|
**IAM:**
|
||||||
|
- New app `ci_runner_app` with policy granting `ContainersInvoke`
|
||||||
|
- API key for the Go service to invoke containers
|
||||||
|
|
||||||
|
**Existing reuse:**
|
||||||
|
- `data.terraform_remote_state.scaleway` in `prod-01` for outputs
|
||||||
|
- Existing `scaleway_registry_namespace` for pushing the runner image
|
||||||
|
|
||||||
|
### Phase 2: Go Service (`ci-orchestrator/`)
|
||||||
|
|
||||||
|
```
|
||||||
|
ci-orchestrator/
|
||||||
|
├── go.mod
|
||||||
|
├── main.go # Entry: HTTP server
|
||||||
|
├── Dockerfile # Multi-stage, distroless
|
||||||
|
├── .dockerignore
|
||||||
|
└── internal/
|
||||||
|
├── webhook/
|
||||||
|
│ ├── gitea.go # Parse Gitea push/PR payloads
|
||||||
|
│ └── hmac.go # HMAC-SHA256 validation
|
||||||
|
├── router/
|
||||||
|
│ ├── router.go # Check if job has "cloud" label
|
||||||
|
│ └── rules.go # Config-based routing rules
|
||||||
|
└── runner/
|
||||||
|
└── scaleway.go # Invoke SCW Serverless Container
|
||||||
|
```
|
||||||
|
|
||||||
|
**Routing logic:**
|
||||||
|
- Parse webhook → extract repo, branch, commit, labels (from PR body or commit message convention)
|
||||||
|
- If `cloud` label → invoke Scaleway container
|
||||||
|
- If no label → ignore (existing runners on `ci-01` handle it)
|
||||||
|
|
||||||
|
**Endpoints:**
|
||||||
|
- `POST /webhook/gitea` — main webhook receiver
|
||||||
|
- `GET /health` — health check
|
||||||
|
|
||||||
|
### Phase 3: Runner Container Image
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM gitea/act_runner:latest
|
||||||
|
ENV GITEA_RUNNER_IGNORE_RUNNING_JOBS_IN_SCRIPT=true
|
||||||
|
# Entrypoint configured via Terraform env vars
|
||||||
|
```
|
||||||
|
|
||||||
|
Push to: `rg.fr-par.scw.cloud/<ci-namespace>/act-runner:<tag>`
|
||||||
|
|
||||||
|
### Phase 4: Terraform K8s Deployment (`terraform/modules/apps/ci-orchestrator/`)
|
||||||
|
|
||||||
|
Deployed in `prod-01` namespace:
|
||||||
|
|
||||||
|
| Resource | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `kubernetes_namespace` | `ci-orchestrator` |
|
||||||
|
| `kubernetes_deployment` | Go service (hardened security context like other modules) |
|
||||||
|
| `kubernetes_service` | ClusterIP, port 8080 |
|
||||||
|
| `kubernetes_ingress_v1` | Ingress via nginx, path `/webhook/gitea` |
|
||||||
|
| `kubernetes_config_map` | Routing rules config |
|
||||||
|
| `kubernetes_secret` | HMAC key, Scaleway API credentials, Gitea URL |
|
||||||
|
|
||||||
|
### Phase 5: Gitea Webhook Registration
|
||||||
|
|
||||||
|
```hcl
|
||||||
|
provider "gitea" {
|
||||||
|
address = "https://code.alexpires.me"
|
||||||
|
username = "<user>"
|
||||||
|
token = "<token>"
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "gitea_repository_webhook" "ci_orchestrator" {
|
||||||
|
username = "<org-or-user>"
|
||||||
|
name = "<repo-name>"
|
||||||
|
type = "gitea"
|
||||||
|
url = "https://code.alexpires.me/webhook/gitea"
|
||||||
|
secret = var.webhook_secret
|
||||||
|
content_type = "json"
|
||||||
|
branch_filter = "*"
|
||||||
|
events = ["push", "pull_request"]
|
||||||
|
active = true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Can be per-repo or a single wildcard. The Go service routes based on labels.
|
||||||
|
|
||||||
|
### Phase 6: CI Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
push/PR to Gitea
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Webhook → ci-orchestrator (prod-01)
|
||||||
|
│
|
||||||
|
├─ no cloud label → ignore (local runners handle it)
|
||||||
|
│
|
||||||
|
└─ cloud label → invoke Scaleway container
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
gitea/act_runner starts
|
||||||
|
Registers with Gitea
|
||||||
|
Receives and executes job
|
||||||
|
Exits → container shuts down (cost: per-second)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Provider Compatibility
|
||||||
|
|
||||||
|
### `go-gitea/gitea` (v0.7.0)
|
||||||
|
- `gitea_repository_webhook` resource — creates and manages webhooks
|
||||||
|
- Supports: `url`, `secret`, `content_type`, `events`, `branch_filter`, `active`, `type`
|
||||||
|
|
||||||
|
### `scaleway/scaleway` (v2.77.1)
|
||||||
|
- `scaleway_container` — Serverless Container resource
|
||||||
|
- `privacy` can be set to `private` (requires IAM auth)
|
||||||
|
- `min_scale = 0`, `max_scale = 1` for ephemeral execution
|
||||||
|
- Supports `command`, `args`, `environment_variables`, `secret_environment_variables`
|
||||||
|
- `scaleway_container_namespace` — namespace for containers
|
||||||
|
- `scaleway_iam_policy` — IAM policies for container invocation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Knowns (Updated)
|
||||||
|
|
||||||
|
### Gitea
|
||||||
|
- URL: `https://code.alexpires.me`
|
||||||
|
- Admin user: `alexandre.pires`
|
||||||
|
- API token: `${GITEA_ADMIN_TOKEN}` (via sectool)
|
||||||
|
- Provider config: `gitea_credentials` in `terraform/iac/scaleway/` or `prod-01/`
|
||||||
|
|
||||||
|
### Webhook registration
|
||||||
|
- Target: all existing repos + new ones
|
||||||
|
- Method: one-time Ansible script to register webhooks on all repos, then run once per new repo
|
||||||
|
- Webhook target URL: `https://code.alexpires.me/webhook/gitea` (points to Go service ingress)
|
||||||
|
|
||||||
|
### Runner resources
|
||||||
|
- CPU: 2 vCPU
|
||||||
|
- Memory: 4Gi
|
||||||
|
- Image: `gitea/act_runner:latest` (or pinned version)
|
||||||
|
|
||||||
|
### Repository
|
||||||
|
- Moved to Gitea: `https://code.alexpires.me/alexpires.me/a13labs.infra`
|
||||||
|
- Origin remote updated to `ssh://git@code.alexpires.me:22/alexpires.me/a13labs.infra.git`
|
||||||
|
- All branches and tags pushed
|
||||||
|
|
||||||
|
### CI repo workflow
|
||||||
|
- `ci-01` runner builds Terraform images using Gitea Actions
|
||||||
|
- Images pushed to Scaleway Registry
|
||||||
|
- Terraform state managed via S3 on Scaleway
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Open Decisions (to be addressed during implementation)
|
||||||
|
|
||||||
|
1. **Routing labels** — How will the Go service detect "cloud" intent? Options:
|
||||||
|
- PR body labels (`[cloud]` in title/body)
|
||||||
|
- Commit message convention (`[cloud]` prefix)
|
||||||
|
- Branch naming convention
|
||||||
|
- Separate "cloud" branch filter per repo
|
||||||
|
2. **Go service ingress path** — Current plan: `/webhook/gitea`. Need to ensure it doesn't conflict with Gitea's own routes.
|
||||||
|
3. **Scaling strategy** — `max_scale=1` per invocation, but multiple concurrent jobs? May need pool or queue.
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
# Gateway API Migration & Cloudflare Integration
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Migrate prod-01 and dev-01 clusters from the deprecated nginx-ingress controller (retired March 2026) to NGINX Gateway Fabric via the Gateway API, improve cross-cluster WireGuard connectivity, and add Cloudflare Tunnel for edge protection.
|
||||||
|
|
||||||
|
## Current Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Internet → Contabo Public IP → OPNsense → prod-01
|
||||||
|
↓
|
||||||
|
nginx-ingress plugin (MicroK8s)
|
||||||
|
↓
|
||||||
|
MetalLB (10.2.0.40-49)
|
||||||
|
↓
|
||||||
|
┌───────────────┼─────────────────┐
|
||||||
|
↓ ↓ ↓
|
||||||
|
[proxy pod] [app pods] [TCP streams]
|
||||||
|
(nginx + WG) (gitea, m3uproxy, etc) (hostPort)
|
||||||
|
↓
|
||||||
|
WireGuard → aristotle (Contabo VPN)
|
||||||
|
↓
|
||||||
|
dev-01 backends (open-webui, nextcloud)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key facts:**
|
||||||
|
- 14 `kubernetes_ingress_v1` resources across both clusters
|
||||||
|
- All use `ingress.class: public` + nginx-specific annotations
|
||||||
|
- Proxy module on prod-01 creates a double-proxy (Ingress → proxy pod → backends)
|
||||||
|
- TCP/UDP streams use nginx with hostPorts
|
||||||
|
- DNS: ClouDNS, no Cloudflare involvement
|
||||||
|
- cert-manager with DNS-01 (dev-01) and HTTP-01 (prod-01) for Let's Encrypt
|
||||||
|
- **Both clusters are single-node** — no need for MetalLB, NodePort is sufficient
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
|
||||||
|
| Decision | Choice | Rationale |
|
||||||
|
|---|---|---|
|
||||||
|
| Gateway API implementation | **NGINX Gateway Fabric** | Keeps nginx data plane, minimal behavioral changes, conformant v1.4.1 |
|
||||||
|
| Cross-cluster connectivity | **Improve WireGuard** | Keep through aristotle, allowed_ips to dev-01 only (10.19.4.136/32), remove gpu-01 |
|
||||||
|
| Ollama removal | **Remove** | gpu-01 runs llamacpp (not ollama), prod-01 ollama proxy is broken, dev-01 DNS CNAME stale |
|
||||||
|
| Cloudflare scope | **Tunnels only** | Get DDoS+WAF, keep DNS with ClouDNS, least disruptive |
|
||||||
|
| Proxy module | **All through proxy** | Gateway routes everything to proxy Service, simpler Terraform |
|
||||||
|
| Website module | **Direct Gateway route** | Keep separate pods, no proxy overhead for static sites |
|
||||||
|
| TLS termination | **At Cloudflare edge** | Simpler cert management, Cloudflare provides origin cert |
|
||||||
|
|
||||||
|
## Target Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Internet → Cloudflare Tunnel → Cloudflare Edge (TLS termination)
|
||||||
|
↓
|
||||||
|
cloudflared pods (DaemonSet)
|
||||||
|
↓
|
||||||
|
Gateway (NGINX Gateway Fabric, NodePort Service)
|
||||||
|
HTTP listener :80 (from cloudflared)
|
||||||
|
↓
|
||||||
|
┌───────────────┼─────────────────┐
|
||||||
|
↓ ↓ ↓
|
||||||
|
[proxy Service] [app Services] [TCP/UDP Routes]
|
||||||
|
↓ ↓ ↓
|
||||||
|
[proxy pod + WG] [direct pods] [direct pods]
|
||||||
|
(nginx) (website, etc.) (rustdesk, etc.)
|
||||||
|
↓
|
||||||
|
Cross-cluster → dev-01 backends
|
||||||
|
(open-webui, nextcloud)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Gateway API Migration
|
||||||
|
|
||||||
|
### 1A. Install NGINX Gateway Fabric (Ansible)
|
||||||
|
|
||||||
|
**New file: `ansible/roles/microk8s/tasks/gateway-api.yml`**
|
||||||
|
|
||||||
|
```
|
||||||
|
- Install `snap` package manager (if needed)
|
||||||
|
- Install helm via snap
|
||||||
|
- Add nginx-stable helm repo
|
||||||
|
- Install Gateway API CRDs (standard channel + experimental for TCPRoute/UDPRoute)
|
||||||
|
- Install NGINX Gateway Fabric via Helm (experimental features enabled)
|
||||||
|
- Create GatewayClass named `public`
|
||||||
|
- Create Gateway Service with type = NodePort (single-node, no MetalLB needed)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Modify: `ansible/roles/microk8s/tasks/main.yml`**
|
||||||
|
- Include `gateway-api.yml` AFTER existing setup (runs alongside nginx-ingress initially)
|
||||||
|
|
||||||
|
**Modify: `ansible/roles/microk8s/defaults/main.yml`**
|
||||||
|
- Add variables: `gateway_fabric_version`, `gateway_fabric_experimental_enabled`
|
||||||
|
- Remove MetalLB-related vars (`microk8s_lb_provider`, `microk8s_lb_ips`, `microk8s_metallb_version`)
|
||||||
|
|
||||||
|
### 1B. Create Gateway Module (Terraform)
|
||||||
|
|
||||||
|
**New module: `terraform/modules/utils/gateway/`**
|
||||||
|
- `main.tf`: Gateway resource with HTTP (:80) listener, `ServiceType: NodePort`
|
||||||
|
- `variables.tf`: TLS secret names, listener config
|
||||||
|
- `outputs.tf`: Gateway service name, NodePort port numbers
|
||||||
|
|
||||||
|
**Deploy in both clusters** (`prod-01/apps.tf`, `dev-01/apps.tf`)
|
||||||
|
|
||||||
|
### 1C. Migrate Terraform Modules (Ingress → Gateway API Resources)
|
||||||
|
|
||||||
|
#### Proxy module (`terraform/modules/utils/proxy/main.tf`)
|
||||||
|
|
||||||
|
- **Remove**: `kubernetes_ingress_v1.ingress` resource (lines 160-203)
|
||||||
|
- **Remove**: `host_port` from stream container ports in Deployment
|
||||||
|
- **Remove**: ollama proxy service definition from prod-01 config.tf
|
||||||
|
- **Add**: HTTPRoute for cross-cluster services (open-webui, nextcloud)
|
||||||
|
- **Add**: TCPRoute resources for RustDesk (21115, 21116, 21117 TCP) and Prometheus (9090)
|
||||||
|
- **Add**: UDPRoute resource for RustDesk (21116 UDP)
|
||||||
|
- The proxy pod's nginx stream block can be removed (Gateway handles L4 routing)
|
||||||
|
- **Keep**: WireGuard sidecar, auth sidecar, internal nginx reverse proxy config
|
||||||
|
|
||||||
|
#### Website module (`terraform/modules/apps/nginx/main.tf`)
|
||||||
|
|
||||||
|
- **Remove**: `kubernetes_ingress_v1.website_ingress` (lines 103-145)
|
||||||
|
- **Add**: HTTPRoute per website with backendRef → website Service
|
||||||
|
- Gateway handles TLS termination, website pods receive plain HTTP
|
||||||
|
- Website pods can drop internal TLS (simplification)
|
||||||
|
|
||||||
|
#### Gitea module (`terraform/modules/apps/gitea/ingress.tf`)
|
||||||
|
|
||||||
|
- **Remove**: `kubernetes_ingress_v1.gitea_ingress`
|
||||||
|
- **Add**: HTTPRoute with backendRef → Gitea Service
|
||||||
|
- Use `BackendTLSPolicy` if Gitea serves HTTPS internally
|
||||||
|
|
||||||
|
#### M3UProxy module (`terraform/modules/apps/m3uproxy/main.tf`)
|
||||||
|
|
||||||
|
- **Remove**: `kubernetes_ingress_v1.m3uproxy_ingress_http` (lines 241-274)
|
||||||
|
- **Add**: HTTPRoute with backendRef → m3uproxy Service
|
||||||
|
|
||||||
|
#### dev-01 modules
|
||||||
|
|
||||||
|
| Module | Current | Change |
|
||||||
|
|---|---|---|
|
||||||
|
| `open-webui/ingress.tf` | kubernetes_ingress_v1 | HTTPRoute |
|
||||||
|
| `grafana/grafana.tf` | kubernetes_ingress_v1 | HTTPRoute |
|
||||||
|
| `mstream/ingress.tf` | kubernetes_ingress_v1 | HTTPRoute |
|
||||||
|
| `searxng/ingress.tf` | kubernetes_ingress_v1 | HTTPRoute |
|
||||||
|
| `vaultwarden/ingress.tf` | kubernetes_ingress_v1 | HTTPRoute |
|
||||||
|
| `terraform-mcp/ingress.tf` | kubernetes_ingress_v1 | HTTPRoute |
|
||||||
|
| `nextcloud/ingress.tf` | kubernetes_ingress_v1 | HTTPRoute |
|
||||||
|
| `proxy/main.tf` | kubernetes_ingress_v1 | HTTPRoute (opencode, vscode) |
|
||||||
|
|
||||||
|
#### Annotation → Gateway API Mapping
|
||||||
|
|
||||||
|
| Current nginx annotation | Gateway API equivalent |
|
||||||
|
|---|---|
|
||||||
|
| `nginx.org/ssl-redirect: true` | HTTPRoute with `RequestRedirect` filter |
|
||||||
|
| `nginx.org/redirect-to-https: true` | HTTPRoute on HTTP listener with redirect |
|
||||||
|
| `nginx.ingress.kubernetes.io/backend-protocol: HTTPS` | `BackendTLSPolicy` resource |
|
||||||
|
| `nginx.ingress.kubernetes.io/proxy-body-size` | `UpstreamSettings` policy (NGF custom) |
|
||||||
|
| `nginx.ingress.kubernetes.io/proxy-request-buffering` | `ProxySettings` policy (NGF custom) |
|
||||||
|
| `nginx.ingress.kubernetes.io/proxy-buffer-size` | `UpstreamSettings` policy |
|
||||||
|
| `cert-manager.io/cluster-issuer` | cert-manager annotation on Gateway |
|
||||||
|
|
||||||
|
### 1D. cert-manager Integration
|
||||||
|
|
||||||
|
Use **approach B**: Keep existing Certificate resources, reference secrets in Gateway TLS config.
|
||||||
|
|
||||||
|
- Each module's `kubernetes_manifest` Certificate resource stays unchanged
|
||||||
|
- Gateway HTTPS listener references cert secrets via `certificateRefs`
|
||||||
|
- This avoids alpha cert-manager Gateway integration
|
||||||
|
|
||||||
|
### 1E. Migration Execution Order
|
||||||
|
|
||||||
|
1. Apply new Ansible role (installs Gateway Fabric alongside nginx-ingress)
|
||||||
|
2. Deploy Gateway resource to both clusters
|
||||||
|
3. Migrate modules on **dev-01 first** (lower risk):
|
||||||
|
- Deploy HTTPRoutes for each module, one at a time
|
||||||
|
- Verify each service works
|
||||||
|
- Remove old Ingress resources
|
||||||
|
4. Migrate modules on **prod-01**:
|
||||||
|
- Same process, one module at a time
|
||||||
|
5. Disable nginx-ingress plugin on both clusters
|
||||||
|
6. Clean up IngressClass, old LoadBalancer Service
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Cross-Cluster Connectivity
|
||||||
|
|
||||||
|
### 2A. Improve WireGuard
|
||||||
|
|
||||||
|
**Modify: `terraform/apps/prod-01/config.tf`** (WireGuard config):
|
||||||
|
- Change `allowed_ips` from `[10.19.4.106/32, 10.19.4.136/32]` → `["10.19.4.136/32"]` (dev-01 only)
|
||||||
|
- Remove gpu-01 (10.19.4.106) - ollama proxy being removed, no need to reach gpu-01
|
||||||
|
- `persistent_keepalive` already set to 25
|
||||||
|
|
||||||
|
**Modify: `terraform/modules/utils/proxy/resources/wireguard/wireguard.conf.tpl`**:
|
||||||
|
- Template `PersistentKeepalive` from config variable
|
||||||
|
- Template `AllowedIPs` from config list
|
||||||
|
|
||||||
|
**Also clean up**:
|
||||||
|
- Remove `"ollama"` entry from `proxy_services` in prod-01 config.tf
|
||||||
|
- Remove `ollama` CNAME from dev-01 DNS zones in dev-01 config.tf
|
||||||
|
|
||||||
|
**Modify: `terraform/modules/utils/proxy/main.tf`** (WireGuard container):
|
||||||
|
- Improve liveness probe: `wg show` instead of `wg show | grep -q transfer`
|
||||||
|
- Add readiness probe
|
||||||
|
- Add resource limits (memory cap)
|
||||||
|
|
||||||
|
### 2B. DNS Resolution
|
||||||
|
|
||||||
|
No changes needed. The proxy's per-location resolver in `default.conf.tpl` already handles DNS correctly for cross-cluster backends.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Cloudflare Tunnels
|
||||||
|
|
||||||
|
### 3A. Cloudflare Setup
|
||||||
|
|
||||||
|
- Create/verify Cloudflare account with `alexpires.me` domain
|
||||||
|
- Keep DNS with ClouDNS (Cloudflare acts as reverse proxy only)
|
||||||
|
- Create Cloudflare Tunnel for prod-01
|
||||||
|
- Configure route rules: `https://*.alexpires.me` → Gateway Service
|
||||||
|
- Set up origin rules (TLS terminates at Cloudflare edge → HTTP to cloudflared)
|
||||||
|
|
||||||
|
### 3B. Deploy cloudflared
|
||||||
|
|
||||||
|
**New module: `terraform/modules/utils/cloudflare-tunnel/`**
|
||||||
|
- DaemonSet of `cloudflare/cloudflared` pods
|
||||||
|
- ConfigMap with tunnel credentials and route definitions
|
||||||
|
- Each public FQDN mapped to `http://<gateway-service>.<namespace>:80`
|
||||||
|
|
||||||
|
### 3C. Gateway Configuration Changes
|
||||||
|
|
||||||
|
- Gateway uses HTTP (:80) NodePort listener for Cloudflare Tunnel traffic
|
||||||
|
- Cloudflare Tunnel connects to `http://<node_ip>:<nodeport>`
|
||||||
|
- No HTTPS listener needed since all traffic comes through Cloudflare Tunnel
|
||||||
|
|
||||||
|
### 3D. Firewall Changes
|
||||||
|
|
||||||
|
- Close UFW ports 80/443 (cloudflared uses ephemeral outbound ports)
|
||||||
|
- Keep SSH + WireGuard ports open
|
||||||
|
|
||||||
|
### 3E. Security Benefits
|
||||||
|
|
||||||
|
With Cloudflare in front:
|
||||||
|
- No open ports on prod-01 (80, 443, etc. closed)
|
||||||
|
- DDoS protection (automatic with Cloudflare)
|
||||||
|
- WAF rules (OWASP Top 10, custom rules)
|
||||||
|
- Rate limiting on public endpoints
|
||||||
|
- Bot management
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: Cleanup
|
||||||
|
|
||||||
|
- Remove nginx-ingress plugin from MicroK8s (`microk8s.disable ingress`)
|
||||||
|
- Remove unused IngressClass resources
|
||||||
|
- Remove existing MetalLB (delete metallb-system namespace, IPAddressPool, L2Advertisement)
|
||||||
|
- Delete orphaned `tasks/loadbalancer.yml` and `tasks/loadbalancer/metallb.yaml`
|
||||||
|
- Remove MetalLB vars from `defaults/main.yml`
|
||||||
|
- Update documentation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Change Summary
|
||||||
|
|
||||||
|
### New Files
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `ansible/roles/microk8s/tasks/gateway-api.yml` | NGINX Gateway Fabric install |
|
||||||
|
| `terraform/modules/utils/gateway/main.tf` | Gateway + ReferenceGrant resources |
|
||||||
|
| `terraform/modules/utils/gateway/variables.tf` | Gateway configuration |
|
||||||
|
| `terraform/modules/utils/gateway/outputs.tf` | Gateway outputs |
|
||||||
|
| `terraform/modules/utils/cloudflare-tunnel/main.tf` | cloudflared DaemonSet |
|
||||||
|
| `terraform/modules/utils/cloudflare-tunnel/variables.tf` | Tunnel config |
|
||||||
|
|
||||||
|
### Deleted Files
|
||||||
|
| File | Reason |
|
||||||
|
|---|---|
|
||||||
|
| `ansible/roles/microk8s/tasks/loadbalancer.yml` | Not wired into main.yml, was for nginx-ingress LB |
|
||||||
|
| `ansible/roles/microk8s/tasks/loadbalancer/metallb.yaml` | MetalLB no longer needed (single-node, NodePort) |
|
||||||
|
|
||||||
|
### Modified Files
|
||||||
|
| File | Change |
|
||||||
|
|---|---|
|
||||||
|
| `ansible/roles/microk8s/tasks/main.yml` | Include gateway-api.yml |
|
||||||
|
| `ansible/roles/microk8s/defaults/main.yml` | Add Gateway Fabric defaults, remove MetalLB vars |
|
||||||
|
| `terraform/modules/utils/proxy/main.tf` | Ingress → HTTPRoute + TCPRoute + UDPRoute |
|
||||||
|
| `terraform/modules/utils/proxy/variables.tf` | Update for Gateway API |
|
||||||
|
| `terraform/modules/utils/proxy/resources/wireguard/wireguard.conf.tpl` | Add PersistentKeepalive |
|
||||||
|
| `terraform/modules/apps/nginx/main.tf` | Ingress → HTTPRoute |
|
||||||
|
| `terraform/modules/apps/gitea/ingress.tf` | Ingress → HTTPRoute |
|
||||||
|
| `terraform/modules/apps/m3uproxy/main.tf` | Ingress → HTTPRoute |
|
||||||
|
| `terraform/modules/apps/open-webui/ingress.tf` | Ingress → HTTPRoute |
|
||||||
|
| `terraform/modules/apps/grafana/grafana.tf` | Ingress → HTTPRoute |
|
||||||
|
| `terraform/modules/apps/mstream/ingress.tf` | Ingress → HTTPRoute |
|
||||||
|
| `terraform/modules/apps/searxng/ingress.tf` | Ingress → HTTPRoute |
|
||||||
|
| `terraform/modules/apps/vaultwarden/ingress.tf` | Ingress → HTTPRoute |
|
||||||
|
| `terraform/modules/apps/terraform-mcp/ingress.tf` | Ingress → HTTPRoute |
|
||||||
|
| `terraform/modules/apps/nextcloud/ingress.tf` | Ingress → HTTPRoute |
|
||||||
|
| `terraform/modules/apps/ssh_locker_web/` | Ingress → HTTPRoute |
|
||||||
|
| `terraform/apps/prod-01/apps.tf` | Add gateway module |
|
||||||
|
| `terraform/apps/prod-01/config.tf` | WireGuard (remove gpu-01), remove ollama proxy |
|
||||||
|
| `terraform/apps/dev-01/config.tf` | Remove ollama DNS CNAME |
|
||||||
|
| `terraform/apps/dev-01/apps.tf` | Add gateway module |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risk Assessment
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|---|---|
|
||||||
|
| TCPRoute/UDPRoute experimental in NGF | Start with HTTPRoute only, keep proxy streams temporarily |
|
||||||
|
| Cloudflare Tunnel adds latency | Monitor, acceptable for reverse proxy traffic |
|
||||||
|
| cert-manager Gateway integration alpha | Use approach B (separate Certificate resources) |
|
||||||
|
| Downtime during migration | Run both controllers in parallel, switch via DNS TTL |
|
||||||
|
| Removing gpu-01 from WireGuard allowed_ips | Verify no active proxy depends on gpu-01 reachability |
|
||||||
|
| Removing existing MetalLB on prod-01 | Do after Cloudflare Tunnel is fully operational |
|
||||||
|
|
||||||
|
## Timeline Estimate
|
||||||
|
|
||||||
|
| Phase | Duration | Dependencies |
|
||||||
|
|---|---|---|
|
||||||
|
| Phase 1 (Gateway API) | 2-3 weeks | None |
|
||||||
|
| Phase 2 (WireGuard) | 1-2 weeks | Can run parallel with Phase 1 |
|
||||||
|
| Phase 3 (Cloudflare) | 1-2 weeks | After Phase 1 |
|
||||||
|
| Phase 4 (Cleanup) | 1 week | After Phase 3 |
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
# Gitea Actions Migration Plan — Validate-Only Phase
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Migrate validate-only workflows from `.github/workflows/` (all `.disabled`) to `.gitea/workflows/` on the `alexpires.me/a13labs.infra` repo. Runs on `cicd-base` runner label. Terraform removed entirely — OpenTofu only.
|
||||||
|
|
||||||
|
## Status: IMPLEMENTED
|
||||||
|
|
||||||
|
All files created, secrets configured as placeholders. Ready for push and testing.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### Workflows In Scope (6)
|
||||||
|
|
||||||
|
| Workflow | Trigger | Path Filter | What It Does |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `ansible-validate-on-pr` | PR (opened/synchronize/reopened) → `main` | `ansible/**` | ansible-lint + syntax-check |
|
||||||
|
| `aws-validate-on-pr` | PR → `main` | `terraform/iac/aws/**` | tofu init, validate, fmt -check, plan |
|
||||||
|
| `cloudns-validate-on-pr` | PR → `main` | `terraform/iac/cloudns/**` | tofu init, validate, fmt -check, plan |
|
||||||
|
| `contabo-validate-on-pr` | PR → `main` | `terraform/iac/contabo/**` | tofu init, validate, fmt -check, plan |
|
||||||
|
| `scaleway-validate-on-pr` | PR → `main` | `terraform/iac/scaleway/**` | tofu init, validate, fmt -check, plan |
|
||||||
|
| `k8s-apps-validate` | PR → `main` | `terraform/apps/**` | ansible+k8s env + tofu validate dev-01 & prod-01 |
|
||||||
|
|
||||||
|
### Workflows Deferred (8)
|
||||||
|
|
||||||
|
All apply workflows, `deploy-websites`, `actions-validate`, `ansible-validate-playbook-on-pr` — out of scope.
|
||||||
|
|
||||||
|
## Directory Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
.gitea/
|
||||||
|
├── actions/
|
||||||
|
│ ├── setup-sectool/
|
||||||
|
│ │ └── action.yml
|
||||||
|
│ ├── setup-env/
|
||||||
|
│ │ └── action.yml
|
||||||
|
│ ├── setup-ansible-env/
|
||||||
|
│ │ └── action.yml
|
||||||
|
│ ├── setup-k8s-env/
|
||||||
|
│ │ └── action.yml
|
||||||
|
│ ├── setup-opentofu-env/
|
||||||
|
│ │ └── action.yml
|
||||||
|
│ └── setup-scaleway-registry/
|
||||||
|
│ └── action.yml
|
||||||
|
└── workflows/
|
||||||
|
├── ansible-validate-on-pr.yml
|
||||||
|
├── aws-validate-on-pr.yml
|
||||||
|
├── build-and-push-images.yml
|
||||||
|
├── cloudns-validate-on-pr.yml
|
||||||
|
├── contabo-validate-on-pr.yml
|
||||||
|
├── k8s-apps-validate.yml
|
||||||
|
├── scaleway-validate-on-pr.yml
|
||||||
|
└── weekly-image-rebuild.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Action Migration Details
|
||||||
|
|
||||||
|
### `setup-sectool` (New)
|
||||||
|
|
||||||
|
Replaces `uses: a13labs/setup-sectool@v1` (external GitHub action). Local composite action that downloads the sectool binary from `a13labs/sectool` GitHub releases.
|
||||||
|
|
||||||
|
- Fetches latest release from `https://api.github.com/repos/a13labs/sectool/releases/latest`
|
||||||
|
- Downloads Linux amd64 binary (tar.gz)
|
||||||
|
- Installs to `/usr/local/bin/sectool`
|
||||||
|
- Runs `sectool version` to verify
|
||||||
|
|
||||||
|
### `setup-env` (Converted)
|
||||||
|
|
||||||
|
Changes from `.github/actions/setup-env/action.yml`:
|
||||||
|
- `outputs.changed_files` → uses `$GITEA_OUTPUT` instead of `$GITHUB_OUTPUT`
|
||||||
|
- `git diff --name-only ${{ github.event.before }} ${{ github.sha }}` → `${{ gitea.event.before }} ${{ gitea.sha }}`
|
||||||
|
- `uses: a13labs/setup-sectool@v1` → `uses: ./.gitea/actions/setup-sectool`
|
||||||
|
|
||||||
|
### `setup-ansible-env` (Converted)
|
||||||
|
|
||||||
|
Changes from `.github/actions/setup-ansible-env/action.yml`:
|
||||||
|
- `outputs.ssh-auth-sock` and `outputs.hosts` → use `$GITEA_OUTPUT`
|
||||||
|
- No `github.event` references — minimal conversion needed
|
||||||
|
|
||||||
|
### `setup-opentofu-env` (Unchanged)
|
||||||
|
|
||||||
|
No changes needed. `opentofu/setup-opentofu@v1` stays as-is (Gitea pulls from GitHub).
|
||||||
|
|
||||||
|
### `setup-k8s-env` (Unchanged)
|
||||||
|
|
||||||
|
Carried over as-is. No GitHub-specific variables in this action.
|
||||||
|
|
||||||
|
### `setup-terraform-env` (Not Migrated)
|
||||||
|
|
||||||
|
Dropped entirely. Terraform is no longer used in this project.
|
||||||
|
|
||||||
|
### `setup-scaleway-registry` (New)
|
||||||
|
|
||||||
|
Local composite action for image build workflows. Creates `~/.docker/config.json` with registry auth and configures buildah storage/network settings for rootless operation.
|
||||||
|
|
||||||
|
- Inputs: `registry-endpoint`, `registry-username`, `registry-password`
|
||||||
|
- Used by: `build-and-push-images.yml`, `weekly-image-rebuild.yml`
|
||||||
|
|
||||||
|
## New Workflows (Image Build & Push)
|
||||||
|
|
||||||
|
### `build-and-push-images.yml` (New)
|
||||||
|
|
||||||
|
Builds and pushes runner profile Docker images to Scaleway registry.
|
||||||
|
|
||||||
|
| Trigger | Tags | Scanning |
|
||||||
|
|---|---|---|
|
||||||
|
| Push to main (dockerfiles changed) | `{sha}` + `latest` | Yes (Trivy, warn only) |
|
||||||
|
| Push tag (`v*`) | `{tag}` + `{sha}` + `latest` | Yes (Trivy, warn only) |
|
||||||
|
| Pull request | `pr-{number}` | No |
|
||||||
|
|
||||||
|
Jobs: `check-paths` → `auth` → `build-base` → `build-images` (matrix: 6 images)
|
||||||
|
|
||||||
|
### `weekly-image-rebuild.yml` (New)
|
||||||
|
|
||||||
|
Scheduled weekly (Monday 6AM). Rebuilds all images against updated upstream bases, tags only `:latest`, full Trivy scanning.
|
||||||
|
|
||||||
|
See `plans/image-build-push.md` for full details.
|
||||||
|
|
||||||
|
## Path Corrections
|
||||||
|
|
||||||
|
GitHub workflows referenced stale paths from a previous repo layout:
|
||||||
|
|
||||||
|
| Source (GitHub) | Actual | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `terraform/aws-iac/` | `terraform/iac/aws/` | Restructured |
|
||||||
|
| `terraform/cloudns-iac/` | `terraform/iac/cloudns/` | Restructured |
|
||||||
|
| `terraform/contabo-iac/` | `terraform/iac/contabo/` | Restructured |
|
||||||
|
| `terraform/scaleway-iac/` | `terraform/iac/scaleway/` | Restructured |
|
||||||
|
| `terraform/k8sapps/` | `terraform/apps/dev-01/` + `terraform/apps/prod-01/` | Renamed + split |
|
||||||
|
|
||||||
|
## Gitea Syntax Conversions
|
||||||
|
|
||||||
|
| GitHub | Gitea |
|
||||||
|
|---|---|
|
||||||
|
| `$GITHUB_OUTPUT` | `$GITEA_OUTPUT` |
|
||||||
|
| `${{ github.event.before }}` | `${{ gitea.event.before }}` |
|
||||||
|
| `${{ github.sha }}` | `${{ gitea.sha }}` |
|
||||||
|
| `runs-on: ubuntu-latest` | `runs-on: cicd-base` |
|
||||||
|
| `uses: ./.github/actions/...` | `uses: ./.gitea/actions/...` |
|
||||||
|
|
||||||
|
## Repo Secrets
|
||||||
|
|
||||||
|
| Secret | Used By | Purpose | Status |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `BW_PROJECT_ID` | All workflows | Bitwarden project ID | **Configured** |
|
||||||
|
| `BW_ORGANIZATION_ID` | All workflows | Bitwarden org ID | **Configured** |
|
||||||
|
| `BW_ACCESS_TOKEN` | All workflows | Bitwarden API token | **Configured** |
|
||||||
|
| `ANSIBLE_USER` | Ansible + K8s workflows | SSH user for remote hosts | **Configured** |
|
||||||
|
| `SCALEWAY_REGISTRY_ENDPOINT` | Image build workflows | Scaleway registry endpoint | **Needs setup** |
|
||||||
|
| `SCALEWAY_CI_ACCESS_KEY` | Image build workflows | CI IAM API access key | **Needs setup** |
|
||||||
|
| `SCALEWAY_CI_SECRET_KEY` | Image build workflows | CI IAM API secret key | **Needs setup** |
|
||||||
|
|
||||||
|
## Phase B: Scaleway IaC Changes
|
||||||
|
|
||||||
|
IaC changes in `terraform/iac/scaleway/` add CI IAM application and outputs. See `plans/image-build-push.md` for details.
|
||||||
|
|
||||||
|
- `iam.tf` — added `ci_app` IAM application, policy, and API key
|
||||||
|
- `outputs.tf` — added `registry_endpoint`, `ci_app_api_access_key`, `ci_app_api_secret_key`
|
||||||
|
- `sectool.env` — no changes needed (CI app resources are hardcoded)
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. **Push** all changes to `alexpires.me/a13labs.infra` on Gitea (note: `registry.tf` changed `is_public` from `false` to `true` on `a13labs` namespace)
|
||||||
|
2. **SSH to ci-01** — rebuild `cicd-base` image locally, update Gitea runner container — DONE
|
||||||
|
3. **Add Bitwarden secrets** — `SCALEWAY_CI_ACCESS_KEY` and `SCALEWAY_CI_SECRET_KEY` (populated after IaC apply)
|
||||||
|
4. **Apply Scaleway IaC** — `sectool exec tofu apply` in `terraform/iac/scaleway/`
|
||||||
|
5. **Extract IaC outputs** — `registry_endpoint`, `ci_app_api_access_key`, `ci_app_api_secret_key`
|
||||||
|
6. **Configure Gitea secrets** — `SCALEWAY_REGISTRY_ENDPOINT`, `SCALEWAY_CI_ACCESS_KEY`, `SCALEWAY_CI_SECRET_KEY`
|
||||||
|
7. **Test validate workflows** — PR touching `ansible/` or `terraform/iac/` paths
|
||||||
|
8. **Test build workflow** — PR touching `ansible/files/dockerfiles/linux-runners/`
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
# Image Build & Push to Scaleway Registry
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Build runner profile container images from `ansible/files/dockerfiles/linux-runners/`, scan for vulnerabilities, and push to the Scaleway container registry (`a13labs` namespace).
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
| Decision | Choice | Rationale |
|
||||||
|
|---|---|---|
|
||||||
|
| Registry namespace | `a13labs` (existing) | Clean project branding |
|
||||||
|
| Tagging strategy | Git tag + SHA + latest | Full traceability and history |
|
||||||
|
| Initial scope | Runner profiles only (7 → 4) | Consolidated image-build, infra-tools, security-scan into cicd-base |
|
||||||
|
| Trivy scan severity | Warn only | Review manually, never block builds |
|
||||||
|
| Update consumers | Not this iteration | Build+push pipeline first, consumers later |
|
||||||
|
| Path filtering | In-workflow check | Gitea paths filter unreliable with tags |
|
||||||
|
| PR scanning | Disabled | Keep PR builds fast |
|
||||||
|
|
||||||
|
## Image Inventory & Dependencies
|
||||||
|
|
||||||
|
```
|
||||||
|
docker.io/gitea/runner:latest
|
||||||
|
↓
|
||||||
|
gitea-runner-base (build stage 1, pushed first)
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────┐
|
||||||
|
│ cicd-base, cpp-build, go-build │ (build stage 2, all parallel)
|
||||||
|
└─────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
| Image | Dockerfile | Base |
|
||||||
|
|---|---|---|
|
||||||
|
| gitea-runner-base | `Dockerfile.gitea-runner-base` | `docker.io/gitea/runner:latest` |
|
||||||
|
| cicd-base | `Dockerfile.cicd-base` | `local/gitea-runner-base:latest` |
|
||||||
|
| cpp-build | `Dockerfile.cpp-build` | `local/gitea-runner-base:latest` |
|
||||||
|
| go-build | `Dockerfile.go-build` | `local/gitea-runner-base:latest` |
|
||||||
|
|
||||||
|
> **Note:** `image-build`, `infra-tools`, and `security-scan` runners were decommissioned and their tooling consolidated into `cicd-base`. All workflows now run on `cicd-base` or the language-specific runners (`cpp-build`, `go-build`).
|
||||||
|
|
||||||
|
## Registry Naming
|
||||||
|
|
||||||
|
Images pushed as `fr-par-2.registry.scaleway.com/a13labs/{image-name}:{tag}`
|
||||||
|
|
||||||
|
## Phases
|
||||||
|
|
||||||
|
### Phase A: Prepare CI Runner (DONE)
|
||||||
|
|
||||||
|
**Step 1 — Add build tools to `Dockerfile.cicd-base`**
|
||||||
|
|
||||||
|
DONE. Added the following packages to the existing cicd-base image:
|
||||||
|
- Build packages: `buildah`, `fuse-overlayfs`, `podman`, `shadow-uidmap`, `skopeo`, `slirp4netns`, `iptables` (from `Dockerfile.image-build`)
|
||||||
|
- Storage config: `storage.conf` (vfs driver) and `containers.conf` (from `Dockerfile.image-build`)
|
||||||
|
- Trivy binary download (from `Dockerfile.security-scan`, v0.72.0)
|
||||||
|
|
||||||
|
**Step 2 — Register updated cicd-base on Gitea runner** (DONE)
|
||||||
|
|
||||||
|
cicd-base image rebuilt on `ci-01` and Gitea runner container registration updated.
|
||||||
|
|
||||||
|
### Phase B: Scaleway IaC Changes (DONE - code changes, manual apply needed)
|
||||||
|
|
||||||
|
**Step 3 — Add CI IAM app** (`terraform/iac/scaleway/iam.tf`) - DONE
|
||||||
|
- New `scaleway_iam_application` "ci_app" with `RegistryFullAccess` permission set
|
||||||
|
- New `scaleway_iam_policy` "registry_full_access" granting ci_app registry access
|
||||||
|
- New `scaleway_iam_api_key` "ci_app_api" for ci_app credentials
|
||||||
|
|
||||||
|
**Step 4 — Add registry outputs** (`terraform/iac/scaleway/outputs.tf`) - DONE
|
||||||
|
- `registry_endpoint` — from `scaleway_registry_namespace.a13labs.endpoint`
|
||||||
|
- `ci_app_api_access_key`, `ci_app_api_secret_key` — sensitive
|
||||||
|
|
||||||
|
**Step 5 — No `sectool.env` changes needed** - DONE
|
||||||
|
The CI app resources are hardcoded, no TF_VAR mappings required.
|
||||||
|
|
||||||
|
**Step 6 — Apply and extract secrets** (MANUAL)
|
||||||
|
- Add `SCALEWAY_CI_ACCESS_KEY` and `SCALEWAY_CI_SECRET_KEY` to Bitwarden
|
||||||
|
- Run `sectool exec tofu apply` in `terraform/iac/scaleway/`
|
||||||
|
- Add `SCALEWAY_REGISTRY_ENDPOINT`, `SCALEWAY_CI_ACCESS_KEY`, `SCALEWAY_CI_SECRET_KEY` as Gitea repo secrets
|
||||||
|
|
||||||
|
### Phase C: Gitea Actions & Workflow (DONE)
|
||||||
|
|
||||||
|
**Step 7 — Create `.gitea/actions/setup-scaleway-registry/action.yml`** - DONE
|
||||||
|
Composite action that:
|
||||||
|
- Creates `~/.docker/config.json` with Scaleway registry auth
|
||||||
|
- Configures buildah storage and network settings for rootless operation
|
||||||
|
- Inputs: `registry-endpoint`, `registry-username`, `registry-password`
|
||||||
|
|
||||||
|
**Step 8 — Create `.gitea/workflows/build-and-push-images.yml`** - DONE
|
||||||
|
|
||||||
|
Jobs:
|
||||||
|
- `check-paths`: Determines if dockerfiles changed (handles push, PR, tag events)
|
||||||
|
- `auth`: Sets up registry authentication (gates on path check)
|
||||||
|
- `build-base`: Builds and pushes `gitea-runner-base`
|
||||||
|
- `build-images`: Matrix build of 6 dependent images
|
||||||
|
|
||||||
|
Tagging strategy:
|
||||||
|
- Push to main: `{sha}` + `latest`
|
||||||
|
- Push tag (v*): `{tag}` + `{sha}` + `latest`
|
||||||
|
- PR: `pr-{number}` (no scanning)
|
||||||
|
|
||||||
|
**Step 9 — Create `.gitea/workflows/weekly-image-rebuild.yml`** - DONE
|
||||||
|
Scheduled weekly (`cron: "0 6 * * 1"` — Monday 6AM):
|
||||||
|
- Same build jobs but only tag `:latest`
|
||||||
|
- Uses `--pull-always` to force rebuild against updated upstream base images
|
||||||
|
- Full Trivy scan (warn only, artifacts)
|
||||||
|
|
||||||
|
## Build Order & Dependency Resolution
|
||||||
|
|
||||||
|
Each dependent Dockerfile says `FROM local/gitea-runner-base:latest`. The workflow replaces `local/gitea-runner-base` with the full registry URL in a temp copy before building, so buildah pulls the freshly pushed base image.
|
||||||
|
|
||||||
|
## Manual Setup Checklist
|
||||||
|
|
||||||
|
Before the workflows can run successfully, complete these steps:
|
||||||
|
|
||||||
|
- [ ] Push code changes to repo (note: `registry.tf` changed `is_public` from `false` to `true` on `a13labs` namespace)
|
||||||
|
- [x] SSH to `ci-01`, rebuild `cicd-base` image locally, update Gitea runner container — DONE
|
||||||
|
- [ ] Add `SCALEWAY_CI_ACCESS_KEY` and `SCALEWAY_CI_SECRET_KEY` to Bitwarden (leave blank initially, they'll be populated after IaC apply)
|
||||||
|
- [ ] Apply Scaleway IaC: `sectool exec tofu apply` in `terraform/iac/scaleway/`
|
||||||
|
- [ ] From IaC outputs, extract `registry_endpoint`, `ci_app_api_access_key`, `ci_app_api_secret_key`
|
||||||
|
- [ ] Add as Gitea repo secrets: `SCALEWAY_REGISTRY_ENDPOINT`, `SCALEWAY_CI_ACCESS_KEY`, `SCALEWAY_CI_SECRET_KEY`
|
||||||
|
- [ ] Push a test commit touching `ansible/files/dockerfiles/linux-runners/` to trigger workflow
|
||||||
|
- [ ] Verify workflow runs and images appear in Scaleway registry
|
||||||
|
|
||||||
|
## Gitea Secrets Required
|
||||||
|
|
||||||
|
| Secret | Source | Used by |
|
||||||
|
|---|---|---|
|
||||||
|
| `SCALEWAY_REGISTRY_ENDPOINT` | IaC output `registry_endpoint` | build-and-push, weekly-rebuild |
|
||||||
|
| `SCALEWAY_CI_ACCESS_KEY` | IaC output `ci_app_api_access_key` | build-and-push, weekly-rebuild |
|
||||||
|
| `SCALEWAY_CI_SECRET_KEY` | IaC output `ci_app_api_secret_key` | build-and-push, weekly-rebuild |
|
||||||
|
|
||||||
|
## File Summary
|
||||||
|
|
||||||
|
| Action | File | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| Modify | `ansible/files/dockerfiles/linux-runners/Dockerfile.cicd-base` | DONE |
|
||||||
|
| Modify | `terraform/iac/scaleway/iam.tf` | DONE |
|
||||||
|
| Modify | `terraform/iac/scaleway/outputs.tf` | DONE |
|
||||||
|
| Modify | `terraform/iac/scaleway/sectool.env` | DONE |
|
||||||
|
| Create | `.gitea/actions/setup-scaleway-registry/action.yml` | DONE |
|
||||||
|
| Create | `.gitea/workflows/build-and-push-images.yml` | DONE |
|
||||||
|
| Create | `.gitea/workflows/weekly-image-rebuild.yml` | DONE |
|
||||||
|
|
||||||
|
## Post-Iteration (NOT now)
|
||||||
|
|
||||||
|
- Update runner Dockerfiles to use registry base URLs
|
||||||
|
- Update Ansible playbooks to `pull` from registry instead of `build`
|
||||||
|
- Add PR close job to clean up `pr-{number}` tags from registry
|
||||||
|
- Expand to AI service Dockerfiles (comfyui, ollama, llamacpp variants)
|
||||||
|
|
||||||
|
## Consolidation
|
||||||
|
|
||||||
|
`image-build`, `infra-tools`, and `security-scan` runner profiles have been decommissioned. Their tooling was already absorbed into `cicd-base`:
|
||||||
|
- **image-build** tools (buildah, podman, storage config) → merged into `Dockerfile.cicd-base`
|
||||||
|
- **security-scan** tools (trivy) → merged into `Dockerfile.cicd-base`
|
||||||
|
- **infra-tools** tools (ansible, pip packages) → installed at workflow runtime via `requirements/ansible.txt`
|
||||||
|
|
||||||
|
Unused Dockerfiles removed: `Dockerfile.image-build`, `Dockerfile.infra-tools`, `Dockerfile.security-scan`
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
# Plan: Refactor llama_server role — template-based container command
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
The `llama_server` role deploys `llama.cpp` server via Podman (EL) or NSSB (Windows) in router mode.
|
||||||
|
|
||||||
|
**Existing structure:**
|
||||||
|
|
||||||
|
| Component | Location |
|
||||||
|
|---|---|
|
||||||
|
| `defaults/main.yml` | User/group, image, tag, port, bind, models list, `argv_extra`, exporter config, Windows config |
|
||||||
|
| `tasks/el/main.yml` | User/group creation → podman image pull → **container created inline** with hardcoded flags → systemd wrapper → HF model sync → exporter |
|
||||||
|
| `tasks/windows/main.yml` | Binary download → NSSM install with same hardcoded flags → HF sync → firewall → exporter |
|
||||||
|
| `templates/podman.service.j2` | Thin systemd wrapper: `podman start/stop llamacpp` |
|
||||||
|
| `templates/llama_exporter.service.j2` | Exporter systemd service |
|
||||||
|
| `vars/main.yml` | Internal non-overridable vars |
|
||||||
|
| `files/scripts/` | `llama_hf_sync.py` (Linux + Windows), `llama_exporter.py`, tests |
|
||||||
|
|
||||||
|
**Current container command** (`tasks/el/main.yml:142-152`):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
command: >-
|
||||||
|
{{
|
||||||
|
[
|
||||||
|
'--metrics',
|
||||||
|
'--models-preset',
|
||||||
|
'/models/.router/models.ini',
|
||||||
|
'--models-max',
|
||||||
|
(llama_server_models_max | string)
|
||||||
|
]
|
||||||
|
+ (llama_server_argv_extra | default([]))
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Current Windows command** (`tasks/windows/main.yml:220-238`):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
argv: >-
|
||||||
|
{{
|
||||||
|
[
|
||||||
|
'nssm.exe',
|
||||||
|
'install',
|
||||||
|
'llama-server',
|
||||||
|
(llama_server_windows_install_dir ~ '\\' ~ llama_server_windows_bin_name),
|
||||||
|
'--port',
|
||||||
|
(llama_server_port | default(11434) | string),
|
||||||
|
'--host',
|
||||||
|
(llama_server_host | default('127.0.0.1') | string),
|
||||||
|
'--metrics',
|
||||||
|
'--models-preset',
|
||||||
|
(__llama_preset_file | string),
|
||||||
|
'--models-max',
|
||||||
|
(llama_server_models_max | string)
|
||||||
|
]
|
||||||
|
+ (llama_server_argv_extra | default([]))
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem:** Both commands are hardcoded inline. The only extensibility is `llama_server_argv_extra` (append-only list), which has no validation, no conditional logic, and no self-documentation.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Replace hardcoded command construction with Jinja2 templates for both EL and Windows paths.
|
||||||
|
- Add role variables for all major llama.cpp server flags with sensible defaults and conditional rendering.
|
||||||
|
- Maintain backward compatibility: `llama_server_argv_extra` still works as a suffix.
|
||||||
|
- Enable future configuration of MCP servers, SSL/TLS, API keys, chat templates, and performance tuning without manual playbook edits.
|
||||||
|
|
||||||
|
## Changes Required
|
||||||
|
|
||||||
|
### 1. Add new role variables to `defaults/main.yml`
|
||||||
|
|
||||||
|
Add a new section after the existing defaults:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# --- Server configuration ---
|
||||||
|
llama_server_bind: "0.0.0.0"
|
||||||
|
llama_server_host: "127.0.0.1"
|
||||||
|
llama_server_port: 11434
|
||||||
|
|
||||||
|
# SSL/TLS
|
||||||
|
llama_server_ssl_enabled: false
|
||||||
|
llama_server_ssl_cert: ""
|
||||||
|
llama_server_ssl_key: ""
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
llama_server_api_key: ""
|
||||||
|
|
||||||
|
# Chat
|
||||||
|
llama_server_chat_template: ""
|
||||||
|
|
||||||
|
# Performance
|
||||||
|
llama_server_ctx_size: 4096
|
||||||
|
llama_server_n_gpu_layers: 35
|
||||||
|
llama_server_batch_size: 512
|
||||||
|
llama_server_ubatch_size: 512
|
||||||
|
llama_server_threads: 0
|
||||||
|
llama_server_threads_batch: 0
|
||||||
|
|
||||||
|
# Features
|
||||||
|
llama_server_embeddings: false
|
||||||
|
llama_server_cors: false
|
||||||
|
llama_server_log_timestamps: false
|
||||||
|
llama_server_log_level: "warn"
|
||||||
|
llama_server_log_print: false
|
||||||
|
llama_server_slot_save_context: false
|
||||||
|
|
||||||
|
# Web UI
|
||||||
|
llama_server_webui: ""
|
||||||
|
llama_server_webui_author: ""
|
||||||
|
|
||||||
|
# MCP
|
||||||
|
llama_server_mcp_servers: []
|
||||||
|
|
||||||
|
# KV cache quantization
|
||||||
|
llama_server_cache_type_q4: false
|
||||||
|
|
||||||
|
# Speculative decoding
|
||||||
|
llama_server_speculative_model: ""
|
||||||
|
llama_server_draft: 0
|
||||||
|
|
||||||
|
# RoPE scaling
|
||||||
|
llama_server_rope_freq_scale: 0.0
|
||||||
|
|
||||||
|
# GPU offload
|
||||||
|
llama_server_tensor_split: 0
|
||||||
|
llama_server_split_mode: "layer"
|
||||||
|
llama_server_main_gpu: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Create EL container command template
|
||||||
|
|
||||||
|
**File:** `templates/llama_server_command_el.j2`
|
||||||
|
|
||||||
|
```jinja2
|
||||||
|
{{
|
||||||
|
[
|
||||||
|
'--metrics'
|
||||||
|
]
|
||||||
|
+ (llama_server_models_preset_global_path | default('/models/.router/models.ini') | ternary(['--models-preset', llama_server_models_preset_global_path], []))
|
||||||
|
+ (llama_server_models_max | default(1) | string | ternary(['--models-max', llama_server_models_max | string], []))
|
||||||
|
+ (llama_server_host | default('127.0.0.1') | ternary(['--host', llama_server_host], []))
|
||||||
|
+ (llama_server_ssl_enabled | default(false) | bool | ternary(
|
||||||
|
['--ssl', '--ssl-cert', llama_server_ssl_cert, '--ssl-key', llama_server_ssl_key] if llama_server_ssl_cert and llama_server_ssl_key else [], []))
|
||||||
|
+ (llama_server_api_key | default('') | ternary(['--api-key', llama_server_api_key], []))
|
||||||
|
+ (llama_server_chat_template | default('') | ternary(['--chat-template', llama_server_chat_template], []))
|
||||||
|
+ (llama_server_ctx_size | default(4096) | ternary(['--ctx-size', llama_server_ctx_size | string], []))
|
||||||
|
+ (llama_server_n_gpu_layers | default(35) | ternary(['--n-gpu-layers', llama_server_n_gpu_layers | string], []))
|
||||||
|
+ (llama_server_batch_size | default(512) | ternary(['--batch-size', llama_server_batch_size | string], []))
|
||||||
|
+ (llama_server_ubatch_size | default(512) | ternary(['--ubatch-size', llama_server_ubatch_size | string], []))
|
||||||
|
+ (llama_server_threads | default(0) | ternary(['--threads', llama_server_threads | string], []))
|
||||||
|
+ (llama_server_threads_batch | default(0) | ternary(['--threads-batch', llama_server_threads_batch | string], []))
|
||||||
|
+ (llama_server_embeddings | default(false) | bool | ternary(['--embedding'], []))
|
||||||
|
+ (llama_server_cors | default(false) | bool | ternary(['--cors'], []))
|
||||||
|
+ (llama_server_log_timestamps | default(false) | bool | ternary(['--log-timestamps'], []))
|
||||||
|
+ (llama_server_log_level | default('warn') | ternary(['--log-level', llama_server_log_level], []))
|
||||||
|
+ (llama_server_log_print | default(false) | bool | ternary(['--log-print'], []))
|
||||||
|
+ (llama_server_slot_save_context | default(false) | bool | ternary(['--slot-save-context'], []))
|
||||||
|
+ (llama_server_webui | default('') | ternary(['--webui-name', llama_server_webui], []))
|
||||||
|
+ (llama_server_webui_author | default('') | ternary(['--webui-author', llama_server_webui_author], []))
|
||||||
|
+ (llama_server_mcp_servers | default([]) | length | ternary(['--mcp-servers', (llama_server_mcp_servers | to_json)], []))
|
||||||
|
+ (llama_server_cache_type_q4 | default(false) | bool | ternary(['--cache-type-q4'], []))
|
||||||
|
+ (llama_server_speculative_model | default('') | ternary(['--speculative-model', llama_server_speculative_model], []))
|
||||||
|
+ (llama_server_draft | default(0) | ternary(['--draft', llama_server_draft | string], []))
|
||||||
|
+ (llama_server_rope_freq_scale | default(0.0) | ternary(['--rope-freq-scale', llama_server_rope_freq_scale | string], []))
|
||||||
|
+ (llama_server_tensor_split | default(0) | ternary(['--tensor-split', llama_server_tensor_split | string], []))
|
||||||
|
+ (llama_server_split_mode | default('layer') | ternary(['--split-mode', llama_server_split_mode], []))
|
||||||
|
+ (llama_server_main_gpu | default(0) | ternary(['--main-gpu', llama_server_main_gpu | string], []))
|
||||||
|
+ (llama_server_argv_extra | default([]))
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** The above uses a Jinja2 expression that produces a JSON array string. The `ternary()` calls handle conditional inclusion. The `llama_server_mcp_servers` serializes to JSON for the `--mcp-servers` flag.
|
||||||
|
|
||||||
|
### 3. Create Windows service command template
|
||||||
|
|
||||||
|
**File:** `templates/llama_server_command_windows.j2`
|
||||||
|
|
||||||
|
Same logic as EL but output as a flat list suitable for NSSM `nssm.exe install` argv (each flag and value as separate list elements).
|
||||||
|
|
||||||
|
### 4. Refactor `tasks/el/main.yml`
|
||||||
|
|
||||||
|
Replace the hardcoded command block (lines 142-152) in the `podman_container` task:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Set fact llama_server_el_command
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
llama_server_el_command: "{{ lookup('template', 'templates/llama_server_command_el.j2') }}"
|
||||||
|
|
||||||
|
- name: Create pods
|
||||||
|
containers.podman.podman_container:
|
||||||
|
name: "llamacpp"
|
||||||
|
image: "{{ llama_server_image }}:{{ llama_server_tag }}"
|
||||||
|
state: started
|
||||||
|
device: "{{ llama_server_devices | default(omit) }}"
|
||||||
|
env: "{{ llama_server_env | default(omit) }}"
|
||||||
|
ports: "{{ llama_server_bind }}:{{ llama_server_port }}:8080/tcp"
|
||||||
|
volumes:
|
||||||
|
- "{{ llama_server_home }}/models:/models:Z"
|
||||||
|
- "{{ llama_server_home }}/.cache:/app/.cache:Z"
|
||||||
|
command: "{{ llama_server_el_command }}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Refactor `tasks/windows/main.yml`
|
||||||
|
|
||||||
|
Replace the hardcoded `nssm.exe install` argv block (lines 220-238):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Set fact llama_server_windows_argv
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
llama_server_windows_argv: "{{ lookup('template', 'templates/llama_server_command_windows.j2') }}"
|
||||||
|
|
||||||
|
- name: Install llama-server service via NSSM
|
||||||
|
ansible.windows.win_command:
|
||||||
|
argv: "{{ llama_server_windows_argv }}"
|
||||||
|
args:
|
||||||
|
chdir: "{{ llama_server_windows_install_dir }}"
|
||||||
|
register: __llama_nssm_install
|
||||||
|
changed_when: __llama_nssm_install.rc == 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Create MCP servers preset file template (optional, for future)
|
||||||
|
|
||||||
|
**File:** `templates/mcp_servers.json.j2`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{{ llama_server_mcp_servers | to_nice_json }}
|
||||||
|
```
|
||||||
|
|
||||||
|
Used when MCP servers need to be written to a file and passed via `--mcp-servers` flag pointing to a path.
|
||||||
|
|
||||||
|
### 7. Add tasks to write SSL/TLS and API key files (conditional)
|
||||||
|
|
||||||
|
When `llama_server_ssl_cert` or `llama_server_api_key` are set, ensure the referenced files exist on the remote host. This may require:
|
||||||
|
- A task to create the API key file if provided as a variable (not file path)
|
||||||
|
- A task to validate SSL cert/key paths exist
|
||||||
|
|
||||||
|
## Files to modify
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `ansible/roles/llama_server/defaults/main.yml` | Add new role variables section |
|
||||||
|
| `ansible/roles/llama_server/templates/llama_server_command_el.j2` | **New** — EL container command template |
|
||||||
|
| `ansible/roles/llama_server/templates/llama_server_command_windows.j2` | **New** — Windows NSSM command template |
|
||||||
|
| `ansible/roles/llama_server/templates/mcp_servers.json.j2` | **New** — MCP servers JSON preset (optional) |
|
||||||
|
| `ansible/roles/llama_server/tasks/el/main.yml` | Replace hardcoded command with `lookup('template')` + `set_fact` |
|
||||||
|
| `ansible/roles/llama_server/tasks/windows/main.yml` | Replace hardcoded nssm argv with `lookup('template')` + `set_fact` |
|
||||||
|
| `ansible/roles/llama_server/tasks/el/llama_exporter.yml` | No change |
|
||||||
|
| `ansible/roles/llama_server/files/scripts/` | No change |
|
||||||
|
|
||||||
|
## Rollback plan
|
||||||
|
|
||||||
|
- The `llama_server_argv_extra` variable continues to work as a suffix in both templates.
|
||||||
|
- If template rendering fails, revert `tasks/el/main.yml` and `tasks/windows/main.yml` to the hardcoded command blocks.
|
||||||
|
- Delete the new template files.
|
||||||
|
- No data loss — templates only affect command construction, not state.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
1. **Baseline test:** Run role with existing defaults — should produce identical command to current hardcoded version.
|
||||||
|
2. **Variable test:** Set individual variables (e.g., `llama_server_ctx_size: 8192`, `llama_server_embeddings: true`) and verify they appear in the rendered command.
|
||||||
|
3. **Conditional test:** Set `llama_server_ssl_enabled: true` with cert/key paths — verify `--ssl`, `--ssl-cert`, `--ssl-key` flags are included.
|
||||||
|
4. **MCP test:** Set `llama_server_mcp_servers` to a list of dicts — verify JSON serialization in `--mcp-servers` flag.
|
||||||
|
5. **Backward compat test:** Use `llama_server_argv_extra` alongside new variables — verify both are present.
|
||||||
|
6. **Windows test:** Same scenarios on Windows target with NSSM.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
1. **Should `--models-preset` and `--models-max` remain in the template or stay hardcoded?** They're core to router mode and unlikely to change. Keeping them in the template makes the command self-contained but adds complexity.
|
||||||
|
2. **For `mcp_servers`** — does llama.cpp accept the flag as `--mcp-servers '{"name":"..."}'` (JSON string) or `--mcp-servers /path/to/config.json` (file path)? This affects whether we need a separate file template.
|
||||||
|
3. **Should defaults be opinionated** (e.g., `llama_server_ctx_size: 4096`) or minimal (only required flags)? Opinionated defaults reduce host var boilerplate but may not suit all use cases.
|
||||||
|
4. **Should we add assertions** for mutually exclusive flags (e.g., `--split-mode` values)?
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
# Plan: Replace OpenWebUI with llama.cpp behind nginx proxy
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
- **llama.cpp** runs on `gpu-01.lab.alexpires.me` via Podman on port `11434` (bound to `127.0.0.1` with API key)
|
||||||
|
- **nginx proxy** role runs on `dev-02.lab.alexpires.me` and `gpu-01.lab.alexpires.me`
|
||||||
|
- **OpenWebUI** has been decommissioned (removed from K8s, DNS, inventory)
|
||||||
|
- **DNS** for `lab.alexpires.me` is managed via Terraform (`terraform/apps/dev-01/config.tf` zones)
|
||||||
|
- **oauth2-proxy** template uses OIDC provider (`code.alexpires.me`), `set_xauthrequest = true`
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Deploy llama.cpp behind nginx proxy at `llm.lab.alexpires.me` ✅
|
||||||
|
- API paths (`/api/*`) accessible without OAuth2 (token auth enforced via `--api-key`) ✅
|
||||||
|
- All other paths (chat UI, etc.) protected with OAuth2 via oauth2-proxy ✅
|
||||||
|
- Issue SSL certificate for `llm.lab.alexpires.me` via CloudNS DNS ✅
|
||||||
|
- OpenWebUI decommissioned ✅
|
||||||
|
|
||||||
|
## Important Constraint
|
||||||
|
|
||||||
|
> **Resolved.** Phase 2 completed: llama.cpp now bound to `127.0.0.1` with API key. OpenWebUI decommissioned.
|
||||||
|
|
||||||
|
## Changes Required
|
||||||
|
|
||||||
|
> All changes have been implemented. See execution order below for completed steps.
|
||||||
|
|
||||||
|
### 1. Add DNS record for llm.lab.alexpires.me ✅
|
||||||
|
|
||||||
|
**File:** `terraform/apps/dev-01/config.tf`
|
||||||
|
|
||||||
|
Add A record to the `lab.alexpires.me` zone:
|
||||||
|
```hcl
|
||||||
|
{
|
||||||
|
name = "llm"
|
||||||
|
type = "A"
|
||||||
|
content = "10.19.4.106"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Run `tofu plan/apply` on `terraform/apps/dev-01/` before Phase 1 so DNS resolves.
|
||||||
|
|
||||||
|
### 2. Add gpu-01 to `[nginx]` inventory group ✅
|
||||||
|
|
||||||
|
### 3. Update nginx-server.conf.j2 — per-site `no_auth` conditional ✅
|
||||||
|
|
||||||
|
### 4. Configure gpu-01 host vars for nginx proxy ✅
|
||||||
|
|
||||||
|
### 5. Add firewall rules for HTTPS ✅
|
||||||
|
|
||||||
|
### 6. Update llama_server role — support configurable bind address ✅
|
||||||
|
|
||||||
|
### 7. Two-phase transition ✅
|
||||||
|
|
||||||
|
**Phase 1 — Deploy nginx proxy (llama.cpp still on 0.0.0.0, no API key)**
|
||||||
|
|
||||||
|
- Keep `llama_server_bind: "0.0.0.0"` (current behavior, default)
|
||||||
|
- Do NOT set API key
|
||||||
|
- nginx proxies to `127.0.0.1:11434` (container is bound to `0.0.0.0:11434`, reachable from localhost)
|
||||||
|
- OpenWebUI continues to work (still connects to `gpu-01:11434` directly)
|
||||||
|
- Test: `llm.lab.alexpires.me/` requires OAuth2 login, `llm.lab.alexpires.me/api/*` is open
|
||||||
|
|
||||||
|
**Phase 2 — Switch to localhost binding + enable API key (after validation)**
|
||||||
|
|
||||||
|
- Change `llama_server_bind: "127.0.0.1"` in host vars
|
||||||
|
- Add `--api-key` to `llama_server_argv_extra` in host vars:
|
||||||
|
```yaml
|
||||||
|
llama_server_argv_extra:
|
||||||
|
- "--api-key"
|
||||||
|
- "{{ lookup('env', 'LLAMA_SERVER_API_KEY') }}"
|
||||||
|
# ... existing args ...
|
||||||
|
```
|
||||||
|
- OpenWebUI will break (can no longer reach llama.cpp on `gpu-01:11434`) — decommission OpenWebUI
|
||||||
|
- Verify all consumers migrate to `llm.lab.alexpires.me/api/*` with `Authorization: Bearer <key>`
|
||||||
|
- Remove old firewall rules for port 11434 (no longer externally reachable)
|
||||||
|
|
||||||
|
## Execution Order
|
||||||
|
|
||||||
|
### Phase 1 — Deploy nginx proxy (llama.cpp unchanged, OpenWebUI still works)
|
||||||
|
|
||||||
|
> **Status: COMPLETE** — All steps executed and verified.
|
||||||
|
|
||||||
|
1. **Update Terraform DNS** — add `llm` A record in `terraform/apps/dev-01/config.tf`, run `tofu apply` ✅
|
||||||
|
2. **Update inventory** — add gpu-01 to `[nginx]` group, clean up duplicate `[certbot]` group ✅
|
||||||
|
3. **Update nginx-server.conf.j2** — add `no_auth` per-site conditional (template stays generic) ✅
|
||||||
|
4. **Update llama_server role** — add `llama_server_bind` variable (default `0.0.0.0`) ✅
|
||||||
|
5. **Update host vars** — add nginx sites (with `extra_locations` for `/api/`), OAuth2 creds, firewall rules ✅
|
||||||
|
6. **Run nginx proxy playbook** — installs nginx + oauth2-proxy + certbot + deploys config ✅
|
||||||
|
7. **Fix SELinux** — add `nginx_connect_any` boolean + port 4180 to `http_port_t` (manually applied, added to role for persistence) ✅
|
||||||
|
8. **Verify chat UI** — `llm.lab.alexpires.me/` returns oauth2-proxy login page (200) ✅
|
||||||
|
9. **Verify API** — `llm.lab.alexpires.me/api/v1/models` returns 200 with models list ✅
|
||||||
|
10. **Verify OpenWebUI still works** — llama.cpp still bound to `0.0.0.0:11434` ✅
|
||||||
|
|
||||||
|
1. **Update Terraform DNS** — add `llm` A record in `terraform/apps/dev-01/config.tf`, run `tofu apply`
|
||||||
|
2. **Update inventory** — add gpu-01 to `[nginx]` group, clean up duplicate `[certbot]` group
|
||||||
|
3. **Update nginx-server.conf.j2** — add `no_auth` per-site conditional (template stays generic)
|
||||||
|
4. **Update llama_server role** — add `llama_server_bind` variable (default `0.0.0.0`)
|
||||||
|
5. **Update host vars** — add nginx sites (with `extra_locations` for `/api/`), OAuth2 creds, firewall rules
|
||||||
|
6. **Run nginx proxy playbook** — installs nginx + oauth2-proxy + certbot + deploys config (all in one run)
|
||||||
|
7. **Verify chat UI** — test `llm.lab.alexpires.me/` with OAuth2 login
|
||||||
|
8. **Verify API** — test `llm.lab.alexpires.me/api/*` (no auth yet, same as current direct access)
|
||||||
|
9. **Verify OpenWebUI still works** — confirm dev-01 still connects to gpu-01:11434
|
||||||
|
|
||||||
|
### Phase 2 — Switch to localhost + API key (after validation, decommission OpenWebUI)
|
||||||
|
|
||||||
|
> **Status: COMPLETE** — All steps executed and verified.
|
||||||
|
|
||||||
|
10. **Update host vars** — change `llama_server_bind: "127.0.0.1"`, add `--api-key` to `llama_server_argv_extra` ✅
|
||||||
|
11. **Update firewall** — remove port 11434 rules from `fw_allowed_ports` ✅
|
||||||
|
12. **Run llama_server playbook** — restart container with new binding + API key ✅
|
||||||
|
13. **Run hardening playbook** — `playbook_hardening_cis.yml --limit gpu-01.lab.alexpires.me -t ufw` to apply firewall changes ✅
|
||||||
|
14. **Verify API** — `llm.lab.alexpires.me/api/v1/chat/completions` returns 401 without key, 200 with key ✅
|
||||||
|
15. **Decommission OpenWebUI** — removed from `terraform/apps/dev-01/apps.tf` ✅
|
||||||
|
16. **Remove OpenWebUI DNS records** — removed `open-webui` CNAME from `terraform/apps/dev-01/config.tf` zones ✅
|
||||||
|
17. **Remove OpenWebUI locals** — removed `open_webui_fqdn`, `open_webui_log_level`, `llm_proxy` from config.tf ✅
|
||||||
|
18. **Remove `webui_secret_key` variable** — removed from `terraform/apps/dev-01/secrets.tf` ✅
|
||||||
|
19. **Remove `[openwebui]` group** — cleaned up `inventory/hosts` ✅
|
||||||
|
20. **Run `tofu apply`** on `terraform/apps/dev-01/` — destroyed 8 OpenWebUI K8s resources (namespace, deployment, service, ingress, secret, service accounts) ✅
|
||||||
|
|
||||||
|
### Phase 2 — Switch to localhost + API key (after validation, decommission OpenWebUI)
|
||||||
|
|
||||||
|
10. **Update host vars** — change `llama_server_bind: "127.0.0.1"`, add `--api-key` to `llama_server_argv_extra`
|
||||||
|
11. **Update firewall** — remove port 11434 rules from `fw_allowed_ports`
|
||||||
|
12. **Run llama_server playbook** — restart container with new binding + API key
|
||||||
|
13. **Run hardening playbook** — `playbook_hardening_cis.yml --limit gpu-01.lab.alexpires.me -t ufw` to apply firewall changes
|
||||||
|
14. **Verify API** — test `llm.lab.alexpires.me/api/*` requires API key
|
||||||
|
15. **Decommission OpenWebUI** — remove Terraform module from `terraform/apps/dev-01/apps.tf` and `config.tf`
|
||||||
|
16. **Remove OpenWebUI DNS records** — clean up `open-webui` CNAME in `terraform/apps/dev-01/config.tf` zones
|
||||||
|
17. **Remove `[openwebui]` group** — clean up `inventory/hosts`
|
||||||
|
18. **Run `tofu apply`** on `terraform/apps/dev-01/` — destroys OpenWebUI K8s resources
|
||||||
|
|
||||||
|
## Playbooks to run
|
||||||
|
|
||||||
|
### Phase 1
|
||||||
|
|
||||||
|
> **COMPLETE** — All steps executed and verified.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd ansible
|
||||||
|
sectool exec ansible-playbook -u $ANSIBLE_USER playbook_nginx_proxy.yml # nginx + oauth2 + certbot (all-in-one)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 2
|
||||||
|
|
||||||
|
> **COMPLETE** — All steps executed and verified.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd ansible
|
||||||
|
sectool exec ansible-playbook -u $ANSIBLE_USER playbook_llama_server.yml # container binding + API key
|
||||||
|
sectool exec ansible-playbook -u $ANSIBLE_USER playbook_hardening_cis.yml -t ufw # remove port 11434 rules
|
||||||
|
```
|
||||||
|
|
||||||
|
Then in terraform:
|
||||||
|
```sh
|
||||||
|
cd terraform/apps/dev-01
|
||||||
|
sectool exec tofu plan # review OpenWebUI destruction
|
||||||
|
sectool exec tofu apply --auto-approve # apply
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files to modify
|
||||||
|
|
||||||
|
| File | Phase | Change |
|
||||||
|
|------|-------|--------|
|
||||||
|
| `terraform/apps/dev-01/config.tf` | 1 | Add `llm` A record to zones |
|
||||||
|
| `inventory/hosts` | 1 | Add gpu-01 to `[nginx]` group, clean up duplicate `[certbot]` |
|
||||||
|
| `ansible/roles/nginx_proxy/templates/nginx-server.conf.j2` | 1 | Add `no_auth` per-site conditional (3 blocks) |
|
||||||
|
| `ansible/roles/llama_server/defaults/main.yml` | 1 | Add `llama_server_bind` variable (default `0.0.0.0`) |
|
||||||
|
| `ansible/roles/llama_server/tasks/el/main.yml` | 1 | Use `llama_server_bind` in podman port mapping |
|
||||||
|
| `ansible/roles/nginx_proxy/tasks/nginx.yml` | 1 | Add SELinux tasks (`nginx_connect_any` boolean + oauth2-proxy port to `http_port_t`) |
|
||||||
|
| `ansible/host_vars/gpu-01.lab.alexpires.me.yml` | 1 | Add nginx sites (with `extra_locations` for `/api/`), OAuth2 creds, firewall rules |
|
||||||
|
| `ansible/host_vars/gpu-01.lab.alexpires.me.yml` | 2 | Change `llama_server_bind` to `127.0.0.1`, add `--api-key` to `llama_server_argv_extra`, remove port 11434 fw rules ✅ |
|
||||||
|
| `terraform/apps/dev-01/apps.tf` | 2 | Remove `module "open-webui"` block ✅ |
|
||||||
|
| `terraform/apps/dev-01/config.tf` | 2 | Remove `open_webui_fqdn`, `open_webui_log_level`, `llm_proxy` locals; remove `open-webui` DNS record from zones ✅ |
|
||||||
|
| `terraform/apps/dev-01/secrets.tf` | 2 | Remove `webui_secret_key` variable ✅ |
|
||||||
|
| `inventory/hosts` | 2 | Remove `[openwebui]` group ✅ |
|
||||||
|
|
||||||
|
## Rollback plan
|
||||||
|
|
||||||
|
### Phase 1 rollback (nginx proxy broken)
|
||||||
|
|
||||||
|
- Remove gpu-01 from `[nginx]` group in `inventory/hosts`
|
||||||
|
- Re-run hardening playbook on gpu-01 to clean up nginx/oauth2-proxy packages
|
||||||
|
- Revert `nginx-server.conf.j2` template changes
|
||||||
|
- llama.cpp continues running on `0.0.0.0:11434` — no impact
|
||||||
|
- OpenWebUI continues to work
|
||||||
|
|
||||||
|
## Issues Encountered & Resolved
|
||||||
|
|
||||||
|
### 1. nginx `copy` module missing `remote_src: true`
|
||||||
|
**File:** `ansible/roles/nginx_proxy/tasks/oauth2_proxy.yml:16`
|
||||||
|
The `copy` module was trying to copy from the controller instead of the remote host. Added `remote_src: true`.
|
||||||
|
|
||||||
|
### 2. SELinux blocking nginx → oauth2-proxy / llama.cpp connections
|
||||||
|
**Symptom:** `connect() to 127.0.0.1:4180 failed (13: Permission denied)` in nginx error log
|
||||||
|
**Fix:**
|
||||||
|
- Set SELinux boolean `nginx_connect_any` to `true` (persistent)
|
||||||
|
- Added port 4180 to `http_port_t` via `semanage port`
|
||||||
|
- Added both as Ansible tasks in `nginx.yml` for persistence
|
||||||
|
|
||||||
|
### 3. Template `no_auth` attribute error
|
||||||
|
**Symptom:** `object of type 'dict' has no attribute 'no_auth'`
|
||||||
|
**Fix:** Changed `{% if not item.no_auth %}` to `{% if not (item.no_auth | default(false) | bool) %}` to handle missing attribute on dicts.
|
||||||
|
|
||||||
|
### 4. Playbook failed on model query after container restart (Phase 2)
|
||||||
|
**Symptom:** `urlopen error [Errno 111] Connection refused` when querying `http://127.0.0.1:11434/models?reload=1`
|
||||||
|
**Cause:** The playbook's "Query llama router model list" task ran before the container was fully started. The container did start correctly with `127.0.0.1:11434` binding and API key active.
|
||||||
|
**Impact:** Non-blocking — the container was running correctly; only the model list query task failed.
|
||||||
|
|
||||||
|
## Known Warnings (non-blocking)
|
||||||
|
|
||||||
|
- `types_hash_bucket_size: 64` — nginx warning, non-blocking
|
||||||
|
- oauth2-proxy PKCE warning (`--code-challenge-method`) — non-blocking, S256 is auto-negotiated
|
||||||
|
|
||||||
|
## Rollback plan
|
||||||
|
|
||||||
|
### Phase 1 rollback (nginx proxy broken)
|
||||||
|
|
||||||
|
- Revert `llama_server_bind` to `0.0.0.0` in host vars
|
||||||
|
- Remove `--api-key` from `llama_server_argv_extra`
|
||||||
|
- Re-add port 11434 firewall rules
|
||||||
|
- Re-run `playbook_llama_server.yml` + `playbook_hardening_cis.yml -t ufw` — container restarts with old settings
|
||||||
|
- OpenWebUI still works (llama.cpp is back on `0.0.0.0` without API key)
|
||||||
|
- Fix issues and re-attempt Phase 2
|
||||||
|
|
||||||
|
### Phase 2 rollback (after OpenWebUI decommission)
|
||||||
|
|
||||||
|
- Restore `llama_server_bind: "0.0.0.0"` in host vars
|
||||||
|
- Remove `--api-key` from `llama_server_argv_extra`
|
||||||
|
- Re-add port 11434 firewall rules
|
||||||
|
- Restore `module "open-webui"` in `terraform/apps/dev-01/apps.tf`
|
||||||
|
- Restore `open_webui_fqdn`, `open_webui_log_level`, `llm_proxy` locals in `config.tf`
|
||||||
|
- Restore `open-webui` DNS CNAME record in `config.tf` zones
|
||||||
|
- Restore `webui_secret_key` variable in `secrets.tf`
|
||||||
|
- Re-add `[openwebui]` group in `inventory/hosts`
|
||||||
|
- Run `tofu apply` to recreate OpenWebUI K8s resources
|
||||||
@@ -0,0 +1,605 @@
|
|||||||
|
# TLS + OAuth2 SSO for dev-02
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add HTTPS, TLS termination, and OAuth2/OIDC SSO to dev-02 services (code-server + opencode),
|
||||||
|
exposed directly on the internal network for VPN/office access.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────┐
|
||||||
|
│ dev-02.lab.alexpires.me │
|
||||||
|
│ (Fedora bare metal) │
|
||||||
|
│ │
|
||||||
|
│ ┌──────┐ auth_request ┌───────────┐ │
|
||||||
|
│ │nginx │─────────────────►│oauth2- │ │
|
||||||
|
│ │:443 │◄──────────────────│proxy:4180 │ │
|
||||||
|
│ │TLS │ │(session) │ │
|
||||||
|
│ │SSO │ │ │ │
|
||||||
|
│ └──┬───┘ └─────┬─────┘ │
|
||||||
|
│ │ proxy_pass (trusts auth) │ │
|
||||||
|
│ │ │OIDC │
|
||||||
|
│ ┌──▼──────┐ ┌──────▼────┐ │
|
||||||
|
│ │ide:3000 │ │Gitea IdP │ │
|
||||||
|
│ │(127.0.0.1) │code. │ │
|
||||||
|
│ │code- │ │alexpires. │ │
|
||||||
|
│ │server │ │me (pub) │ │
|
||||||
|
│ └────────┘ └───────────┘ │
|
||||||
|
│ ┌────────┐ │
|
||||||
|
│ │agent:4096│ │
|
||||||
|
│ │(127.0.0.1) │
|
||||||
|
│ │opencode │ │
|
||||||
|
│ └────────┘ │
|
||||||
|
└──────────────────────────────────────────┘
|
||||||
|
│ │
|
||||||
|
HTTPS 443 │ │ OIDC
|
||||||
|
(internal) │ │
|
||||||
|
▼ ▼
|
||||||
|
┌──────────────────────────────────────────┐
|
||||||
|
│ Browser (VPN/office) │ code.alexpires.me (public)
|
||||||
|
│ ide.lab.alexpires.me:443 ──────────► Gitea IdP
|
||||||
|
│ agent.lab.alexpires.me:443 ──────────►
|
||||||
|
└──────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## SSO Flow
|
||||||
|
|
||||||
|
1. User visits `https://ide.lab.alexpires.me:443` (direct, requires VPN/office)
|
||||||
|
2. nginx detects no valid session cookie → `auth_request` to oauth2-proxy
|
||||||
|
3. oauth2-proxy returns 401 → nginx redirects browser to Gitea login
|
||||||
|
4. Browser → `code.alexpires.me` (Gitea, public, always reachable)
|
||||||
|
5. User logs in → Gitea redirects back to `https://ide.lab.alexpires.me/oauth2/callback`
|
||||||
|
6. oauth2-proxy exchanges code → access_token → creates session cookie
|
||||||
|
7. Cookie scoped to `*.lab.alexpires.me` → SSO across lab subdomains
|
||||||
|
8. Subsequent requests: nginx trusts cookie → proxies to backend (no re-login)
|
||||||
|
|
||||||
|
**Access constraint:** SSO only works when the user can reach `lab.alexpires.me` (VPN or office network).
|
||||||
|
This is inherent to the architecture — the OAuth2 callback URL (`ide.lab.alexpires.me/oauth2/callback`)
|
||||||
|
must be reachable by the browser after Gitea redirects back. All `lab.alexpires.me` services
|
||||||
|
follow this same constraint.
|
||||||
|
|
||||||
|
## Certificate Strategy
|
||||||
|
|
||||||
|
- **SAN cert** for `ide.lab.alexpires.me` and `agent.lab.alexpires.me`
|
||||||
|
- Obtained via certbot DNS-01 challenge (cloudns plugin)
|
||||||
|
- Auto-renewal via cron with `--deploy-hook "systemctl reload nginx"`
|
||||||
|
- No port 80/443 exposure needed (DNS-01 challenge doesn't need HTTP)
|
||||||
|
- Certbot stores certs in `/etc/letsencrypt/live/` (symlinked from `/etc/letsencrypt/archive/`)
|
||||||
|
- nginx references:
|
||||||
|
- `ssl_certificate /etc/letsencrypt/live/ide.lab.alexpires.me/fullchain.pem;`
|
||||||
|
- `ssl_certificate_key /etc/letsencrypt/live/ide.lab.alexpires.me/privkey.pem;`
|
||||||
|
|
||||||
|
## Service Binding
|
||||||
|
|
||||||
|
| Service | Before | After |
|
||||||
|
|---------|--------|-------|
|
||||||
|
| code-server | `0.0.0.0:3000` | `127.0.0.1:3000` |
|
||||||
|
| opencode | `0.0.0.0:4096` | `127.0.0.1:4096` |
|
||||||
|
| oauth2-proxy | N/A | `127.0.0.1:4180` |
|
||||||
|
| nginx | N/A | `0.0.0.0:443` (only public port) |
|
||||||
|
|
||||||
|
## Pre-requisites
|
||||||
|
|
||||||
|
Before applying, register an OAuth2 app in Gitea:
|
||||||
|
- **Name:** `lab-services`
|
||||||
|
- **Redirect URI:** `https://ide.lab.alexpires.me/oauth2/callback`
|
||||||
|
- **Scopes:** `openid`, `profile`, `email`
|
||||||
|
- **Result:** `client_id` + `client_secret` → stored in `sectool` / env vars
|
||||||
|
|
||||||
|
Generate a cookie secret:
|
||||||
|
```sh
|
||||||
|
openssl rand -base64 32
|
||||||
|
```
|
||||||
|
Store via `sectool` as `OAUTH2_PROXY_COOKIE_SECRET`, `OAUTH2_PROXY_CLIENT_ID`, `OAUTH2_PROXY_CLIENT_SECRET`.
|
||||||
|
|
||||||
|
## Changes Required
|
||||||
|
|
||||||
|
### 1. New Role: `nginx_proxy`
|
||||||
|
|
||||||
|
```
|
||||||
|
ansible/roles/nginx_proxy/
|
||||||
|
├── defaults/main.yml # certbot, nginx, oauth2 config, upstreams
|
||||||
|
├── tasks/
|
||||||
|
│ ├── main.yml # Entry point (includes certbot, nginx, oauth2_proxy)
|
||||||
|
│ ├── certbot.yml # Install certbot, DNS credentials, obtain cert, cron renewal
|
||||||
|
│ ├── nginx.yml # Install nginx, deploy config, systemd, SELinux context
|
||||||
|
│ └── oauth2_proxy.yml # Download binary, config, systemd
|
||||||
|
├── templates/
|
||||||
|
│ ├── nginx.conf.j2 # Main nginx config (worker processes, logging, etc.)
|
||||||
|
│ ├── nginx-server.conf.j2 # Server blocks (SAN domains, TLS, auth_request, proxy)
|
||||||
|
│ └── oauth2-proxy.cfg.j2 # OAuth2/OIDC config for Gitea
|
||||||
|
└── files/
|
||||||
|
└── .gitkeep
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `defaults/main.yml`
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
nginx_proxy_certbot_enabled: true
|
||||||
|
nginx_proxy_certbot_email: "c.alexandre.pires@alexpires.me"
|
||||||
|
nginx_proxy_certbot_domains:
|
||||||
|
- "ide.lab.alexpires.me"
|
||||||
|
- "agent.lab.alexpires.me"
|
||||||
|
nginx_proxy_certbot_cron_minute: "0"
|
||||||
|
nginx_proxy_certbot_cron_hour: "3"
|
||||||
|
nginx_proxy_certbot_credential_file: "/etc/letsencrypt/cloudns.ini"
|
||||||
|
nginx_proxy_certbot_credential_mode: "0600"
|
||||||
|
nginx_proxy_certbot_nginx_reload: true
|
||||||
|
|
||||||
|
nginx_proxy_nginx_enabled: true
|
||||||
|
nginx_proxy_nginx_bind: "0.0.0.0"
|
||||||
|
nginx_proxy_nginx_port: 443
|
||||||
|
nginx_proxy_nginx_worker_connections: 1024
|
||||||
|
nginx_proxy_nginx_log_format: "main"
|
||||||
|
nginx_proxy_nginx_access_log: "/var/log/nginx/access.log"
|
||||||
|
nginx_proxy_nginx_error_log: "/var/log/nginx/error.log warn"
|
||||||
|
nginx_proxy_nginx_ssl_protocols: "TLSv1.2 TLSv1.3"
|
||||||
|
nginx_proxy_nginx_ssl_ciphers: "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384"
|
||||||
|
nginx_proxy_nginx_ssl_prefer_server_ciphers: "on"
|
||||||
|
nginx_proxy_nginx_hsts_max_age: "31536000"
|
||||||
|
nginx_proxy_nginx_hsts_include_subdomains: true
|
||||||
|
|
||||||
|
nginx_proxy_oauth2_proxy_enabled: true
|
||||||
|
nginx_proxy_oauth2_proxy_bind: "127.0.0.1"
|
||||||
|
nginx_proxy_oauth2_proxy_port: 4180
|
||||||
|
nginx_proxy_oauth2_proxy_version: "2.13.0"
|
||||||
|
nginx_proxy_oauth2_proxy_cookie_domain: ".lab.alexpires.me"
|
||||||
|
nginx_proxy_oauth2_proxy_cookie_name: "_alexpires_oauth2_proxy"
|
||||||
|
nginx_proxy_oauth2_proxy_cookie_secret: "{{ lookup('env', 'OAUTH2_PROXY_COOKIE_SECRET', default='') }}"
|
||||||
|
nginx_proxy_oauth2_proxy_client_id: "{{ lookup('env', 'OAUTH2_PROXY_CLIENT_ID', default='') }}"
|
||||||
|
nginx_proxy_oauth2_proxy_client_secret: "{{ lookup('env', 'OAUTH2_PROXY_CLIENT_SECRET', default='') }}"
|
||||||
|
nginx_proxy_oauth2_proxy_redirect_url: "https://ide.lab.alexpires.me/oauth2/callback"
|
||||||
|
nginx_proxy_oauth2_proxy_provider: "gitea"
|
||||||
|
nginx_proxy_oauth2_proxy_provider_url: "https://code.alexpires.me"
|
||||||
|
nginx_proxy_oauth2_proxy_scopes: "openid profile email"
|
||||||
|
nginx_proxy_oauth2_proxy_email_domains: []
|
||||||
|
nginx_proxy_oauth2_proxy_upstream: "static://200"
|
||||||
|
nginx_proxy_oauth2_proxy_skip_jwt: false
|
||||||
|
nginx_proxy_oauth2_proxy_set_basic_auth: false
|
||||||
|
nginx_proxy_oauth2_proxy_set_xauthrequest: true
|
||||||
|
nginx_proxy_oauth2_proxy_ssl_insecure_skip_verify: false
|
||||||
|
nginx_proxy_oauth2_proxy_login_button: "Sign in with Gitea"
|
||||||
|
nginx_proxy_oauth2_proxy_proxy_prefix: "/oauth2"
|
||||||
|
nginx_proxy_oauth2_proxy_silent_login: false
|
||||||
|
nginx_proxy_oauth2_proxy_skip_provider_whoami: false
|
||||||
|
nginx_proxy_oauth2_proxy_cookie_refresh: "0s"
|
||||||
|
nginx_proxy_oauth2_proxy_cookie_expire: "168h"
|
||||||
|
nginx_proxy_oauth2_proxy_session_storage_type: "memory"
|
||||||
|
nginx_proxy_oauth2_proxy_request_logging: true
|
||||||
|
nginx_proxy_oauth2_proxy_log_file: "/var/log/oauth2-proxy.log"
|
||||||
|
nginx_proxy_oauth2_proxy_log_level: "info"
|
||||||
|
|
||||||
|
nginx_proxy_upstreams:
|
||||||
|
ide:
|
||||||
|
host: "127.0.0.1"
|
||||||
|
port: 3000
|
||||||
|
agent:
|
||||||
|
host: "127.0.0.1"
|
||||||
|
port: 4096
|
||||||
|
|
||||||
|
nginx_proxy_cloudns_auth_id: "{{ lookup('env', 'CLOUDNS_AUTH_ID') }}"
|
||||||
|
nginx_proxy_cloudns_auth_password: "{{ lookup('env', 'CLOUDNS_PASSWORD') }}"
|
||||||
|
nginx_proxy_cloudns_nameserver: "109.201.133.111"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `tasks/certbot.yml`
|
||||||
|
|
||||||
|
- Install certbot + certbot-dns-cloudns via pip
|
||||||
|
- Create CloudDNS credentials file at `/etc/letsencrypt/cloudns.ini`:
|
||||||
|
```ini
|
||||||
|
dns_cloudns_auth_id = {{ nginx_proxy_cloudns_auth_id }}
|
||||||
|
dns_cloudns_auth_password = {{ nginx_proxy_cloudns_auth_password }}
|
||||||
|
```
|
||||||
|
- Permissions: `0600`, owner: `root:root`
|
||||||
|
- Obtain SAN cert:
|
||||||
|
```sh
|
||||||
|
certbot certonly --dns-cloudns \
|
||||||
|
-d ide.lab.alexpires.me \
|
||||||
|
-d agent.lab.alexpires.me \
|
||||||
|
--non-interactive \
|
||||||
|
--agree-tos \
|
||||||
|
-m {{ nginx_proxy_certbot_email }}
|
||||||
|
```
|
||||||
|
- Create cron job for renewal (daily at 3:00 AM):
|
||||||
|
```sh
|
||||||
|
certbot renew --deploy-hook "systemctl reload nginx"
|
||||||
|
```
|
||||||
|
- Certs stored in `/etc/letsencrypt/live/ide.lab.alexpires.me/`
|
||||||
|
|
||||||
|
#### `tasks/nginx.yml`
|
||||||
|
|
||||||
|
- Install nginx package
|
||||||
|
- Render `nginx.conf` (global):
|
||||||
|
```nginx
|
||||||
|
user nginx;
|
||||||
|
worker_processes auto;
|
||||||
|
error_log {{ nginx_proxy_nginx_error_log }};
|
||||||
|
pid /run/nginx.pid;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections {{ nginx_proxy_nginx_worker_connections }};
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||||
|
'$status $body_bytes_sent "$http_referer" '
|
||||||
|
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||||
|
|
||||||
|
access_log {{ nginx_proxy_nginx_access_log }} main;
|
||||||
|
|
||||||
|
sendfile on;
|
||||||
|
tcp_nopush on;
|
||||||
|
tcp_nodelay on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
types_hash_max_size 2048;
|
||||||
|
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
# WebSocket upgrade mapping
|
||||||
|
map $http_upgrade $connection_upgrade {
|
||||||
|
default upgrade;
|
||||||
|
'' close;
|
||||||
|
}
|
||||||
|
|
||||||
|
include /etc/nginx/conf.d/*.conf;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Render `nginx-server.conf` (server blocks):
|
||||||
|
```nginx
|
||||||
|
# IDE (code-server)
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name ide.lab.alexpires.me;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/ide.lab.alexpires.me/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/ide.lab.alexpires.me/privkey.pem;
|
||||||
|
ssl_protocols {{ nginx_proxy_nginx_ssl_protocols }};
|
||||||
|
ssl_ciphers {{ nginx_proxy_nginx_ssl_ciphers }};
|
||||||
|
ssl_prefer_server_ciphers {{ nginx_proxy_nginx_ssl_prefer_server_ciphers }};
|
||||||
|
|
||||||
|
add_header Strict-Transport-Security "max-age={{ nginx_proxy_nginx_hsts_max_age }}; {{ nginx_proxy_nginx_hsts_include_subdomains ? 'includeSubDomains' : '' }}" always;
|
||||||
|
|
||||||
|
# OAuth2 auth_request
|
||||||
|
location = /oauth2/ {
|
||||||
|
proxy_pass http://127.0.0.1:{{ nginx_proxy_oauth2_proxy_port }};
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Scheme $scheme;
|
||||||
|
proxy_set_header X-Auth-Request-Redirect $request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /oauth2/callback {
|
||||||
|
proxy_pass http://127.0.0.1:{{ nginx_proxy_oauth2_proxy_port }}/oauth2/callback;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Scheme $scheme;
|
||||||
|
proxy_set_header X-Auth-Request-Redirect $request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /oauth2/ {
|
||||||
|
proxy_pass http://127.0.0.1:{{ nginx_proxy_oauth2_proxy_port }};
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Scheme $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
auth_request /oauth2/;
|
||||||
|
auth_request_set $auth_status $upstream_status;
|
||||||
|
auth_request_set $auth_cookie $upstream_http_set_cookie;
|
||||||
|
|
||||||
|
add_header Set-Cookie $auth_cookie;
|
||||||
|
|
||||||
|
proxy_pass http://127.0.0.1:{{ nginx_proxy_upstreams.ide.port }};
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Port $server_port;
|
||||||
|
|
||||||
|
# WebSocket support
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
|
||||||
|
# Timeouts for code-server
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Agent (opencode)
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name agent.lab.alexpires.me;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/ide.lab.alexpires.me/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/ide.lab.alexpires.me/privkey.pem;
|
||||||
|
ssl_protocols {{ nginx_proxy_nginx_ssl_protocols }};
|
||||||
|
ssl_ciphers {{ nginx_proxy_nginx_ssl_ciphers }};
|
||||||
|
ssl_prefer_server_ciphers {{ nginx_proxy_nginx_ssl_prefer_server_ciphers }};
|
||||||
|
|
||||||
|
add_header Strict-Transport-Security "max-age={{ nginx_proxy_nginx_hsts_max_age }}; {{ nginx_proxy_nginx_hsts_include_subdomains ? 'includeSubDomains' : '' }}" always;
|
||||||
|
|
||||||
|
# OAuth2 auth_request
|
||||||
|
location = /oauth2/ {
|
||||||
|
proxy_pass http://127.0.0.1:{{ nginx_proxy_oauth2_proxy_port }};
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Scheme $scheme;
|
||||||
|
proxy_set_header X-Auth-Request-Redirect $request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /oauth2/callback {
|
||||||
|
proxy_pass http://127.0.0.1:{{ nginx_proxy_oauth2_proxy_port }}/oauth2/callback;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Scheme $scheme;
|
||||||
|
proxy_set_header X-Auth-Request-Redirect $request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /oauth2/ {
|
||||||
|
proxy_pass http://127.0.0.1:{{ nginx_proxy_oauth2_proxy_port }};
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Scheme $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
auth_request /oauth2/;
|
||||||
|
auth_request_set $auth_status $upstream_status;
|
||||||
|
auth_request_set $auth_cookie $upstream_http_set_cookie;
|
||||||
|
|
||||||
|
add_header Set-Cookie $auth_cookie;
|
||||||
|
|
||||||
|
proxy_pass http://127.0.0.1:{{ nginx_proxy_upstreams.agent.port }};
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Port $server_port;
|
||||||
|
|
||||||
|
# WebSocket support
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
|
||||||
|
# Timeouts for opencode
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Enable + start nginx service
|
||||||
|
- Set SELinux context: `semanage fcontext -a -t httpd_cert_t "/etc/letsencrypt(/.*)?"` + `restorecon -Rv /etc/letsencrypt`
|
||||||
|
|
||||||
|
#### `tasks/oauth2_proxy.yml`
|
||||||
|
|
||||||
|
- Download latest release from GitHub (Go binary for linux/amd64):
|
||||||
|
```sh
|
||||||
|
wget -qO /tmp/oauth2-proxy.tar.gz "https://github.com/oauth2-proxy/oauth2-proxy/releases/download/v{{ nginx_proxy_oauth2_proxy_version }}/oauth2-proxy-v{{ nginx_proxy_oauth2_proxy_version }}.linux-amd64.tar.gz"
|
||||||
|
tar xzf /tmp/oauth2-proxy.tar.gz -C /tmp/
|
||||||
|
mv /tmp/oauth2-proxy-v{{ nginx_proxy_oauth2_proxy_version }}.linux-amd64/oauth2-proxy /usr/local/bin/
|
||||||
|
```
|
||||||
|
- Render config file at `/etc/oauth2-proxy/oauth2-proxy.cfg`:
|
||||||
|
```ini
|
||||||
|
provider = "{{ nginx_proxy_oauth2_proxy_provider }}"
|
||||||
|
provider_url = "{{ nginx_proxy_oauth2_proxy_provider_url }}"
|
||||||
|
client_id = "{{ nginx_proxy_oauth2_proxy_client_id }}"
|
||||||
|
client_secret = "{{ nginx_proxy_oauth2_proxy_client_secret }}"
|
||||||
|
cookie_domain = "{{ nginx_proxy_oauth2_proxy_cookie_domain }}"
|
||||||
|
cookie_name = "{{ nginx_proxy_oauth2_proxy_cookie_name }}"
|
||||||
|
cookie_secret = "{{ nginx_proxy_oauth2_proxy_cookie_secret }}"
|
||||||
|
cookie_expire = "{{ nginx_proxy_oauth2_proxy_cookie_expire | default('168h') }}"
|
||||||
|
cookie_refresh = "{{ nginx_proxy_oauth2_proxy_cookie_refresh | default('0s') }}"
|
||||||
|
redirect_url = "{{ nginx_proxy_oauth2_proxy_redirect_url }}"
|
||||||
|
login_url = "{{ nginx_proxy_oauth2_proxy_provider_url }}/login/oauth/authorize"
|
||||||
|
redeem_url = "{{ nginx_proxy_oauth2_proxy_provider_url }}/login/oauth/access_token"
|
||||||
|
openid_connectissuerurl = "{{ nginx_proxy_oauth2_proxy_provider_url }}"
|
||||||
|
scopes = "{{ nginx_proxy_oauth2_proxy_scopes }}"
|
||||||
|
email_domains = {{ nginx_proxy_oauth2_proxy_email_domains | to_json }}
|
||||||
|
upstream = "{{ nginx_proxy_oauth2_proxy_upstream }}"
|
||||||
|
skip_jwt_bearer_tokens = {{ nginx_proxy_oauth2_proxy_skip_jwt | lower }}
|
||||||
|
set_basic_auth = {{ nginx_proxy_oauth2_proxy_set_basic_auth | lower }}
|
||||||
|
set_xauthrequest = {{ nginx_proxy_oauth2_proxy_set_xauthrequest | lower }}
|
||||||
|
ssl_insecure_skip_verify = {{ nginx_proxy_oauth2_proxy_ssl_insecure_skip_verify | lower }}
|
||||||
|
proxy_prefix = "{{ nginx_proxy_oauth2_proxy_proxy_prefix }}"
|
||||||
|
silent_login = {{ nginx_proxy_oauth2_proxy_silent_login | lower }}
|
||||||
|
skip_provider_whoami = {{ nginx_proxy_oauth2_proxy_skip_provider_whoami | lower }}
|
||||||
|
listen = "{{ nginx_proxy_oauth2_proxy_bind }}:{{ nginx_proxy_oauth2_proxy_port }}"
|
||||||
|
{% if nginx_proxy_oauth2_proxy_request_logging | default(true) %}
|
||||||
|
logfile_filename = "{{ nginx_proxy_oauth2_proxy_log_file | default('/var/log/oauth2-proxy.log') }}"
|
||||||
|
{% endif %}
|
||||||
|
{% if nginx_proxy_oauth2_proxy_log_level | default('info') %}
|
||||||
|
log_level = "{{ nginx_proxy_oauth2_proxy_log_level | default('info') }}"
|
||||||
|
{% endif %}
|
||||||
|
cookie_secure = false
|
||||||
|
{% if nginx_proxy_oauth2_proxy_login_button | default('Sign in with Gitea') %}
|
||||||
|
custom_login_message = "Sign in with your Gitea account"
|
||||||
|
{% endif %}
|
||||||
|
```
|
||||||
|
- Create systemd service at `/etc/systemd/system/oauth2-proxy.service`:
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=oauth2-proxy for {{ nginx_proxy_oauth2_proxy_cookie_domain }}
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=nginx
|
||||||
|
ExecStart=/usr/local/bin/oauth2-proxy --config /etc/oauth2-proxy/oauth2-proxy.cfg
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
- Enable + start service
|
||||||
|
- Set config file permissions: `0640`, owner `root:nginx` (contains `client_secret`)
|
||||||
|
- Create log directory with proper permissions: `mkdir -p /var/log && chown nginx:nginx /var/log/oauth2-proxy.log`
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- **opencode without credentials** — Verify opencode starts and functions correctly when `OPENCODE_SERVER_USERNAME`/`OPENCODE_SERVER_PASSWORD` are not set. If opencode requires them to start, we may need to keep them but they will be ignored since nginx/oauth2-proxy handles auth.
|
||||||
|
|
||||||
|
### 2. Update `linux_dev_station` Role
|
||||||
|
|
||||||
|
#### `defaults/main.yml`
|
||||||
|
|
||||||
|
- Change `linux_dev_station_code_server_host` default to `127.0.0.1`
|
||||||
|
- Change `linux_dev_station_opencode_server_host` default to `127.0.0.1`
|
||||||
|
|
||||||
|
#### `templates/code-server.service.j2`
|
||||||
|
|
||||||
|
- Change `--bind-addr` to `127.0.0.1:3000`
|
||||||
|
- **Remove** `Environment="PASSWORD={{ linux_dev_station_code_server_password }}"` — SSO handles auth, no password needed
|
||||||
|
|
||||||
|
#### `templates/opencode.service.j2`
|
||||||
|
|
||||||
|
- Change `--hostname` to `127.0.0.1`
|
||||||
|
- **Remove** `Environment="OPENCODE_SERVER_USERNAME={{ linux_dev_station_opencode_server_username }}"` — SSO handles auth
|
||||||
|
- **Remove** `Environment="OPENCODE_SERVER_PASSWORD={{ linux_dev_station_opencode_server_password }}"` — SSO handles auth
|
||||||
|
|
||||||
|
### 3. Update dev-01 DNS & Remove Proxy Config
|
||||||
|
|
||||||
|
#### `terraform/apps/dev-01/config.tf`
|
||||||
|
|
||||||
|
**Add DNS records** for `ide` and `agent` pointing to dev-02 (`10.19.4.210`):
|
||||||
|
|
||||||
|
```hcl
|
||||||
|
{
|
||||||
|
name = "ide"
|
||||||
|
type = "A"
|
||||||
|
content = "10.19.4.210"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name = "agent"
|
||||||
|
type = "A"
|
||||||
|
content = "10.19.4.210"
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
**Remove** the `opencode` and `code-server` entries from `proxy_services` (no longer needed, dev-02 handles its own TLS).
|
||||||
|
|
||||||
|
**Remove** the `opencode` and `vscode` CNAME records from the `zones` list:
|
||||||
|
```hcl
|
||||||
|
// Remove these:
|
||||||
|
{
|
||||||
|
name = "opencode"
|
||||||
|
type = "CNAME"
|
||||||
|
content = "dev-01.${local.lab_domain}."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name = "vscode"
|
||||||
|
type = "CNAME"
|
||||||
|
content = "dev-01.${local.lab_domain}."
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Update Host Vars & Inventory
|
||||||
|
|
||||||
|
#### `ansible/host_vars/dev-02.lab.alexpires.me.yml`
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Certbot
|
||||||
|
nginx_proxy_certbot_enabled: true
|
||||||
|
nginx_proxy_certbot_email: "c.alexandre.pires@alexpires.me"
|
||||||
|
nginx_proxy_certbot_domains:
|
||||||
|
- "ide.lab.alexpires.me"
|
||||||
|
- "agent.lab.alexpires.me"
|
||||||
|
nginx_proxy_certbot_credential_file: "/etc/letsencrypt/cloudns.ini"
|
||||||
|
nginx_proxy_cloudns_auth_id: "{{ lookup('env', 'CLOUDNS_AUTH_ID') }}"
|
||||||
|
nginx_proxy_cloudns_auth_password: "{{ lookup('env', 'CLOUDNS_PASSWORD') }}"
|
||||||
|
nginx_proxy_cloudns_nameserver: "109.201.133.111"
|
||||||
|
|
||||||
|
# OAuth2 / Gitea
|
||||||
|
nginx_proxy_oauth2_proxy_cookie_secret: "{{ lookup('env', 'OAUTH2_PROXY_COOKIE_SECRET') }}"
|
||||||
|
nginx_proxy_oauth2_proxy_client_id: "{{ lookup('env', 'OAUTH2_PROXY_CLIENT_ID') }}"
|
||||||
|
nginx_proxy_oauth2_proxy_client_secret: "{{ lookup('env', 'OAUTH2_PROXY_CLIENT_SECRET') }}"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `inventory/hosts`
|
||||||
|
|
||||||
|
Add `dev-02.lab.alexpires.me` to `[certbot]` group:
|
||||||
|
```ini
|
||||||
|
[certbot]
|
||||||
|
dev-02.lab.alexpires.me
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Gitea OAuth2 Application
|
||||||
|
|
||||||
|
Register one OAuth2 app in Gitea:
|
||||||
|
- **Name:** `lab-services`
|
||||||
|
- **Redirect URI:** `https://ide.lab.alexpires.me/oauth2/callback`
|
||||||
|
- **Scopes:** `openid`, `profile`, `email`
|
||||||
|
- **Result:** `client_id` + `client_secret` → stored in `sectool` / env vars
|
||||||
|
|
||||||
|
### 6. Firewall Changes (handled by existing firewall role)
|
||||||
|
|
||||||
|
| Port | Before | After |
|
||||||
|
|------|--------|-------|
|
||||||
|
| 22 | `10.19.4.0/24` + VPN | Same |
|
||||||
|
| 3000 | `10.19.4.0/24` | **CLOSED** |
|
||||||
|
| 4096 | `10.19.4.0/24` | **CLOSED** |
|
||||||
|
| 443 | — | `10.19.4.0/24` + VPN |
|
||||||
|
|
||||||
|
The `linux_dev_station` role already manages firewall rules. Update `fw_allowed_ports` in host_vars:
|
||||||
|
```yaml
|
||||||
|
fw_allowed_ports:
|
||||||
|
- { rule: "allow", port: "22", proto: "tcp", from: "10.19.4.0/24" }
|
||||||
|
- { rule: "allow", port: "22", proto: "tcp", from: "10.5.5.5/32" }
|
||||||
|
- { rule: "allow", port: "443", proto: "tcp", from: "10.19.4.0/24" }
|
||||||
|
- { rule: "allow", port: "443", proto: "tcp", from: "10.5.5.5/32" }
|
||||||
|
```
|
||||||
|
|
||||||
|
## SELinux Considerations
|
||||||
|
|
||||||
|
- Fedora ships with SELinux enforcing by default
|
||||||
|
- nginx needs permission to read certs: `semanage fcontext -a -t httpd_cert_t "/etc/letsencrypt(/.*)?"` + `restorecon -Rv /etc/letsencrypt`
|
||||||
|
- oauth2-proxy runs as `nginx` user — ensure systemd service file sets `User=nginx`
|
||||||
|
- nginx needs outbound HTTP/HTTPS to Gitea and CloudDNS API — these are allowed by default for the `httpd_t` domain
|
||||||
|
|
||||||
|
## Playbook Structure
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# playbook_linux_dev_station.yml
|
||||||
|
- hosts: linux_dev
|
||||||
|
roles:
|
||||||
|
- linux_dev_station
|
||||||
|
|
||||||
|
# playbook_nginx_proxy.yml
|
||||||
|
- hosts: nginx
|
||||||
|
roles:
|
||||||
|
- nginx_proxy
|
||||||
|
```
|
||||||
|
|
||||||
|
The `nginx_proxy` role is a standalone, generic role — not tied to `linux_dev_station`.
|
||||||
|
|
||||||
|
## Apply Order
|
||||||
|
|
||||||
|
1. Register Gitea OAuth2 app → store creds in `sectool`
|
||||||
|
2. Run `playbook_nginx_proxy.yml` on dev-02 (Ansible)
|
||||||
|
3. Update `fw_allowed_ports` in dev-02 host_vars (close 3000/4096, add 443)
|
||||||
|
4. Update dev-01 DNS + remove proxy config (Terraform)
|
||||||
|
5. Verify: `https://ide.lab.alexpires.me` → SSO via Gitea
|
||||||
|
6. Verify: `https://agent.lab.alexpires.me` → SSO via Gitea
|
||||||
|
|
||||||
|
## Files to Create
|
||||||
|
|
||||||
|
| File | Action |
|
||||||
|
|------|--------|
|
||||||
|
| `ansible/roles/nginx_proxy/` | **NEW** role |
|
||||||
|
| `ansible/playbook_nginx_proxy.yml` | **NEW** standalone playbook (`hosts: certbot`) |
|
||||||
|
| `ansible/roles/linux_dev_station/defaults/main.yml` | Update bind to `127.0.0.1` |
|
||||||
|
| `ansible/roles/linux_dev_station/templates/code-server.service.j2` | `--bind-addr 127.0.0.1:3000` |
|
||||||
|
| `ansible/roles/linux_dev_station/templates/opencode.service.j2` | `--hostname 127.0.0.1` |
|
||||||
|
| `ansible/host_vars/dev-02.lab.alexpires.me.yml` | Add certbot + oauth2 vars |
|
||||||
|
| `inventory/hosts` | Add dev-02 to `[certbot]` |
|
||||||
|
| `terraform/apps/dev-01/config.tf` | Add DNS records for ide/agent, remove proxy config, remove CNAMEs |
|
||||||
|
| `implemented/linux_dev_station.md` | Update report |
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
## Repo Structure
|
## Repo Structure
|
||||||
|
|
||||||
- **`ansible/`** — Playbooks, roles, inventory. Runs against bare-metal servers.
|
- **`ansible/`** — Playbooks, roles, inventory. Runs against bare-metal servers.
|
||||||
|
- **`.opencode/plans/`** — Plans under execution
|
||||||
|
- **`implemented/`** — Reports of implemented features
|
||||||
- **`terraform/`** — IaC (OpenTofu for K8s app deployments + cloud provisioning).
|
- **`terraform/`** — IaC (OpenTofu for K8s app deployments + cloud provisioning).
|
||||||
- **`inventory/hosts`** — Ansible inventory. All hosts and groups.
|
- **`inventory/hosts`** — Ansible inventory. All hosts and groups.
|
||||||
- **`requirements/`** — pip requirements files: `ansible.txt`, `dev.txt`, `opentofu.txt`.
|
- **`requirements/`** — pip requirements files: `ansible.txt`, `dev.txt`, `opentofu.txt`.
|
||||||
@@ -26,7 +28,11 @@ export OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES # if on a MAC
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Running playbooks
|
### Running playbooks
|
||||||
```sh
|
|
||||||
|
**Note:** ```sectool``` just need to be used when secrets must be injected to the environment, if the command does not requires secrets injection do not use ```sectool```
|
||||||
|
|
||||||
|
```
|
||||||
|
sh
|
||||||
cd ansible
|
cd ansible
|
||||||
sectool exec ansible-playbook -u $ANSIBLE_USER playbook_<name>.yml
|
sectool exec ansible-playbook -u $ANSIBLE_USER playbook_<name>.yml
|
||||||
```
|
```
|
||||||
@@ -64,11 +70,33 @@ Excluded paths: `.git/`, `.github/`, `tests/`. Warns on: `line-length`, `yaml[li
|
|||||||
- `terraform/iac/cloudns/` — DNS (cloudns)
|
- `terraform/iac/cloudns/` — DNS (cloudns)
|
||||||
- `terraform/iac/scaleway/` — Scaleway infrastructure
|
- `terraform/iac/scaleway/` — Scaleway infrastructure
|
||||||
|
|
||||||
### Secrets
|
### Running Terraform / OpenTofu
|
||||||
|
|
||||||
Each terraform directory has its own `sectool.env`. Run `sectool` to load secrets before `tofu init/plan/apply`:
|
Each terraform directory has its own `sectool.env`. Run `sectool` to load secrets before `tofu init/plan/apply`:
|
||||||
|
To validate plane (mandatory before apply)
|
||||||
```sh
|
```sh
|
||||||
sectool env -d terraform/apps/dev-01/ -- tofu plan
|
sectool exec tofu plan
|
||||||
```
|
```
|
||||||
|
Apply:
|
||||||
|
```sh
|
||||||
|
sectool exec tofu apply --auto-approve
|
||||||
|
```
|
||||||
|
|
||||||
|
## Secrets
|
||||||
|
|
||||||
|
To list secrets:
|
||||||
|
```sh
|
||||||
|
sectool vault list
|
||||||
|
```
|
||||||
|
To set a secret:
|
||||||
|
```sh
|
||||||
|
sectool vault set <secret name> <secret value>
|
||||||
|
```
|
||||||
|
To set a secret:
|
||||||
|
```sh
|
||||||
|
sectool vault get <secret name> <secret value>
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
### Module structure
|
### Module structure
|
||||||
- `terraform/modules/apps/` — application modules (Nextcloud, etc.)
|
- `terraform/modules/apps/` — application modules (Nextcloud, etc.)
|
||||||
@@ -112,3 +140,8 @@ sectool exec cntb get instances --oauth2-clientid="$CONTABO_CLIENT_ID" ...
|
|||||||
- Ansible roles follow galaxy conventions: `defaults/`, `tasks/`, `meta/`, `files/`, `templates/`
|
- Ansible roles follow galaxy conventions: `defaults/`, `tasks/`, `meta/`, `files/`, `templates/`
|
||||||
- `tasks/main.yml` is the entrypoint for every role
|
- `tasks/main.yml` is the entrypoint for every role
|
||||||
- Host vars: `ansible/host_vars/<hostname>.yml`
|
- Host vars: `ansible/host_vars/<hostname>.yml`
|
||||||
|
|
||||||
|
|
||||||
|
## Troubleshooting and planning
|
||||||
|
|
||||||
|
- When troubleshooting and planning use deepwiki whenever possible to get documentation or information, deepwiki provides information about projects hosted on github to agents.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
[defaults]
|
[defaults]
|
||||||
inventory = ../inventory/hosts
|
inventory = ../inventory/hosts
|
||||||
|
allow_world_readable_tmpfiles = True
|
||||||
|
|
||||||
[ssh_connection]
|
[ssh_connection]
|
||||||
ssh_args = -o ControlMaster=auto -o ControlPersist=60s
|
ssh_args = -o ControlMaster=auto -o ControlPersist=60s
|
||||||
|
|||||||
@@ -1,18 +1,31 @@
|
|||||||
FROM local/gitea-runner-base:latest
|
FROM local/gitea-runner-base:latest
|
||||||
|
|
||||||
# cicd-base runner profile: common CI build and release tooling.
|
# Base image sets USER runner; switch back to root for package installs
|
||||||
USER root
|
USER root
|
||||||
RUN apk add --no-cache \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
bash \
|
buildah \
|
||||||
ca-certificates \
|
fuse-overlayfs \
|
||||||
curl \
|
iptables \
|
||||||
git \
|
podman \
|
||||||
jq \
|
skopeo \
|
||||||
make \
|
slirp4netns \
|
||||||
openssh-client \
|
uidmap \
|
||||||
python3 \
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
py3-pip \
|
|
||||||
rsync \
|
RUN mkdir -p /etc/containers /var/lib/containers /run/containers && \
|
||||||
unzip \
|
printf '%s\n' '[storage]' 'driver = "vfs"' > /etc/containers/storage.conf && \
|
||||||
yq \
|
printf '%s\n' '[engine]' > /etc/containers/containers.conf && \
|
||||||
zip
|
chown -R 1000:1000 /var/lib/containers
|
||||||
|
|
||||||
|
ENV BUILDAH_ISOLATION=chroot
|
||||||
|
ENV STORAGE_DRIVER=vfs
|
||||||
|
|
||||||
|
ARG TRIVY_VERSION=0.72.0
|
||||||
|
RUN curl -fsSL -o /tmp/trivy.tar.gz \
|
||||||
|
"https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz" && \
|
||||||
|
tar -xzf /tmp/trivy.tar.gz -C /tmp && \
|
||||||
|
install -m 0755 /tmp/trivy /usr/local/bin/trivy && \
|
||||||
|
rm -f /tmp/trivy.tar.gz /tmp/trivy
|
||||||
|
|
||||||
|
# Default to non-root execution
|
||||||
|
USER runner
|
||||||
|
|||||||
@@ -1,20 +1,11 @@
|
|||||||
FROM local/gitea-runner-base:latest
|
FROM local/gitea-runner-base:latest
|
||||||
|
|
||||||
# cpp-build runner profile: dedicated C/C++ build environment.
|
# Base image sets USER runner; switch back to root for package installs
|
||||||
USER root
|
USER root
|
||||||
RUN apk add --no-cache \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
bash \
|
build-essential \
|
||||||
build-base \
|
|
||||||
ca-certificates \
|
|
||||||
cmake \
|
cmake \
|
||||||
curl \
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
git \
|
|
||||||
jq \
|
# Default to non-root execution
|
||||||
make \
|
USER runner
|
||||||
openssh-client \
|
|
||||||
python3 \
|
|
||||||
py3-pip \
|
|
||||||
rsync \
|
|
||||||
unzip \
|
|
||||||
yq \
|
|
||||||
zip
|
|
||||||
|
|||||||
@@ -1,13 +1,38 @@
|
|||||||
FROM docker.io/gitea/runner:latest
|
# Stage 1: Extract the gitea-runner binary from the official Alpine-based image
|
||||||
|
FROM docker.io/gitea/runner:latest AS runner
|
||||||
|
|
||||||
USER root
|
# Stage 2: Debian-based image with native glibc support
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
RUN apk add --no-cache \
|
COPY --from=runner /usr/local/bin/gitea-runner /usr/local/bin/gitea-runner
|
||||||
|
COPY --from=runner /usr/local/bin/run.sh /usr/local/bin/run.sh
|
||||||
|
RUN chmod +x /usr/local/bin/run.sh
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/run.sh"]
|
||||||
|
|
||||||
|
# Create dedicated runner user (UID 1000) for non-root container execution
|
||||||
|
RUN groupadd -g 1000 runner && \
|
||||||
|
useradd -u 1000 -g 1000 -m -d /home/runner -s /bin/bash runner
|
||||||
|
|
||||||
|
# Install runner tooling
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
bash \
|
bash \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
curl \
|
curl \
|
||||||
git \
|
git \
|
||||||
jq \
|
jq \
|
||||||
|
make \
|
||||||
|
nodejs \
|
||||||
openssh-client \
|
openssh-client \
|
||||||
|
python3 \
|
||||||
|
python3-pip \
|
||||||
|
rsync \
|
||||||
unzip \
|
unzip \
|
||||||
zip
|
zip \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Set work directory
|
||||||
|
ENV RUNNER_WORKDIR=/home/runner
|
||||||
|
|
||||||
|
# Ensure container runs as non-root by default
|
||||||
|
USER runner
|
||||||
@@ -1,19 +1,10 @@
|
|||||||
FROM local/gitea-runner-base:latest
|
FROM local/gitea-runner-base:latest
|
||||||
|
|
||||||
# go-build runner profile: dedicated Go build environment.
|
# Base image sets USER runner; switch back to root for package installs
|
||||||
USER root
|
USER root
|
||||||
RUN apk add --no-cache \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
bash \
|
golang-go \
|
||||||
ca-certificates \
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
curl \
|
|
||||||
git \
|
# Default to non-root execution
|
||||||
go \
|
USER runner
|
||||||
jq \
|
|
||||||
make \
|
|
||||||
openssh-client \
|
|
||||||
python3 \
|
|
||||||
py3-pip \
|
|
||||||
rsync \
|
|
||||||
unzip \
|
|
||||||
yq \
|
|
||||||
zip
|
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
FROM local/gitea-runner-base:latest
|
|
||||||
|
|
||||||
# image-build runner profile: dedicated environment for container image builds.
|
|
||||||
USER root
|
|
||||||
RUN apk add --no-cache \
|
|
||||||
bash \
|
|
||||||
buildah \
|
|
||||||
ca-certificates \
|
|
||||||
curl \
|
|
||||||
fuse-overlayfs \
|
|
||||||
git \
|
|
||||||
iptables \
|
|
||||||
jq \
|
|
||||||
make \
|
|
||||||
openssh-client \
|
|
||||||
podman \
|
|
||||||
python3 \
|
|
||||||
py3-pip \
|
|
||||||
rsync \
|
|
||||||
shadow-uidmap \
|
|
||||||
skopeo \
|
|
||||||
slirp4netns \
|
|
||||||
unzip \
|
|
||||||
yq \
|
|
||||||
zip
|
|
||||||
|
|
||||||
RUN mkdir -p /etc/containers /var/lib/containers /run/containers && \
|
|
||||||
printf '%s\n' '[storage]' 'driver = "vfs"' > /etc/containers/storage.conf && \
|
|
||||||
printf '%s\n' '[engine]' > /etc/containers/containers.conf
|
|
||||||
|
|
||||||
ENV BUILDAH_ISOLATION=chroot
|
|
||||||
ENV STORAGE_DRIVER=vfs
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
FROM local/gitea-runner-base:latest
|
|
||||||
|
|
||||||
# infra-tools runner profile: Ansible and infrastructure automation tools.
|
|
||||||
USER root
|
|
||||||
RUN apk add --no-cache \
|
|
||||||
bash \
|
|
||||||
ca-certificates \
|
|
||||||
curl \
|
|
||||||
git \
|
|
||||||
jq \
|
|
||||||
make \
|
|
||||||
openssh-client \
|
|
||||||
python3 \
|
|
||||||
py3-pip \
|
|
||||||
rsync \
|
|
||||||
unzip \
|
|
||||||
yq \
|
|
||||||
zip && \
|
|
||||||
python3 -m venv /opt/infra-venv && \
|
|
||||||
/opt/infra-venv/bin/pip install --no-cache-dir \
|
|
||||||
ansible \
|
|
||||||
ansible-lint \
|
|
||||||
bcrypt \
|
|
||||||
fabric \
|
|
||||||
passlib \
|
|
||||||
pywinrm
|
|
||||||
|
|
||||||
ENV PATH="/opt/infra-venv/bin:${PATH}"
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
FROM local/gitea-runner-base:latest
|
|
||||||
|
|
||||||
# security-scan runner profile: image and dependency scanning tooling.
|
|
||||||
USER root
|
|
||||||
RUN apk add --no-cache \
|
|
||||||
bash \
|
|
||||||
ca-certificates \
|
|
||||||
curl \
|
|
||||||
git \
|
|
||||||
jq \
|
|
||||||
make \
|
|
||||||
openssh-client \
|
|
||||||
python3 \
|
|
||||||
py3-pip \
|
|
||||||
rsync \
|
|
||||||
unzip \
|
|
||||||
yq \
|
|
||||||
zip
|
|
||||||
|
|
||||||
ARG TRIVY_VERSION=0.72.0
|
|
||||||
RUN curl -fsSL -o /tmp/trivy.tar.gz \
|
|
||||||
"https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz" && \
|
|
||||||
tar -xzf /tmp/trivy.tar.gz -C /tmp && \
|
|
||||||
install -m 0755 /tmp/trivy /usr/local/bin/trivy && \
|
|
||||||
rm -f /tmp/trivy.tar.gz /tmp/trivy
|
|
||||||
@@ -119,24 +119,9 @@ windows_gitea_runner_podman_runners:
|
|||||||
config_file: "D:\\GiteaRunner\\cicd-base\\config.yaml"
|
config_file: "D:\\GiteaRunner\\cicd-base\\config.yaml"
|
||||||
data_dir: "D:\\GiteaRunner\\cicd-base\\data"
|
data_dir: "D:\\GiteaRunner\\cicd-base\\data"
|
||||||
log_dir: "D:\\Logs\\GiteaRunner\\cicd-base"
|
log_dir: "D:\\Logs\\GiteaRunner\\cicd-base"
|
||||||
- profile: infra-tools
|
run_args:
|
||||||
name: "gitea-runner-infra-tools"
|
- "--privileged"
|
||||||
container_name: "gitea-runner-infra-tools"
|
enable_nested_build: true
|
||||||
image: "local/gitea-runner:infra-tools"
|
|
||||||
dockerfile_src: "Dockerfile.infra-tools"
|
|
||||||
labels: "linux:host,infra-tools"
|
|
||||||
config_file: "D:\\GiteaRunner\\infra-tools\\config.yaml"
|
|
||||||
data_dir: "D:\\GiteaRunner\\infra-tools\\data"
|
|
||||||
log_dir: "D:\\Logs\\GiteaRunner\\infra-tools"
|
|
||||||
- profile: security-scan
|
|
||||||
name: "gitea-runner-security-scan"
|
|
||||||
container_name: "gitea-runner-security-scan"
|
|
||||||
image: "local/gitea-runner:security-scan"
|
|
||||||
dockerfile_src: "Dockerfile.security-scan"
|
|
||||||
labels: "linux:host,security-scan"
|
|
||||||
config_file: "D:\\GiteaRunner\\security-scan\\config.yaml"
|
|
||||||
data_dir: "D:\\GiteaRunner\\security-scan\\data"
|
|
||||||
log_dir: "D:\\Logs\\GiteaRunner\\security-scan"
|
|
||||||
- profile: cpp-build
|
- profile: cpp-build
|
||||||
name: "gitea-runner-cpp-build"
|
name: "gitea-runner-cpp-build"
|
||||||
container_name: "gitea-runner-cpp-build"
|
container_name: "gitea-runner-cpp-build"
|
||||||
@@ -155,18 +140,6 @@ windows_gitea_runner_podman_runners:
|
|||||||
config_file: "D:\\GiteaRunner\\go-build\\config.yaml"
|
config_file: "D:\\GiteaRunner\\go-build\\config.yaml"
|
||||||
data_dir: "D:\\GiteaRunner\\go-build\\data"
|
data_dir: "D:\\GiteaRunner\\go-build\\data"
|
||||||
log_dir: "D:\\Logs\\GiteaRunner\\go-build"
|
log_dir: "D:\\Logs\\GiteaRunner\\go-build"
|
||||||
- profile: image-build
|
|
||||||
name: "gitea-runner-image-build"
|
|
||||||
container_name: "gitea-runner-image-build"
|
|
||||||
image: "local/gitea-runner:image-build"
|
|
||||||
dockerfile_src: "Dockerfile.image-build"
|
|
||||||
labels: "linux:host,image-build"
|
|
||||||
config_file: "D:\\GiteaRunner\\image-build\\config.yaml"
|
|
||||||
data_dir: "D:\\GiteaRunner\\image-build\\data"
|
|
||||||
log_dir: "D:\\Logs\\GiteaRunner\\image-build"
|
|
||||||
run_args:
|
|
||||||
- "--privileged"
|
|
||||||
enable_nested_build: true
|
|
||||||
|
|
||||||
windows_gitea_runner_windows_instance_url: "https://code.alexpires.me"
|
windows_gitea_runner_windows_instance_url: "https://code.alexpires.me"
|
||||||
windows_gitea_runner_windows_registration_token: "{{ lookup('env', 'GITEA_RUNNER_REGISTRATION_TOKEN') }}"
|
windows_gitea_runner_windows_registration_token: "{{ lookup('env', 'GITEA_RUNNER_REGISTRATION_TOKEN') }}"
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
---
|
||||||
|
hostname: dev-02.lab.alexpires.me
|
||||||
|
ansible_user: "{{ lookup('env', 'ANSIBLE_USER') }}"
|
||||||
|
ansible_host: "10.19.4.210"
|
||||||
|
|
||||||
|
# Users
|
||||||
|
login_disable_history: false
|
||||||
|
login_users:
|
||||||
|
- username: "{{ ansible_user }}"
|
||||||
|
comment: "Managed by Ansible"
|
||||||
|
sudoer: true
|
||||||
|
sudoer_root_only: false
|
||||||
|
sudoer_no_password: true
|
||||||
|
pubkey: "{{ lookup('file', 'keys/ansible_user.pub') }}"
|
||||||
|
password_disabled: true
|
||||||
|
- username: devstation
|
||||||
|
comment: "Managed by Ansible"
|
||||||
|
pubkey: "{{ lookup('file', 'keys/localnetwork.pub') }}"
|
||||||
|
shell: "/bin/zsh"
|
||||||
|
groups:
|
||||||
|
- "users"
|
||||||
|
password_disabled: true
|
||||||
|
|
||||||
|
linux_dev_station_service_user_enabled: true
|
||||||
|
linux_dev_station_service_user: "devstation"
|
||||||
|
linux_dev_station_service_home: "/home/devstation"
|
||||||
|
linux_dev_station_workspace_dir: "/home/devstation/projects"
|
||||||
|
linux_dev_station_scripts_dir: "/home/devstation/scripts"
|
||||||
|
linux_dev_station_logs_dir: "/home/devstation/.local/state/logs"
|
||||||
|
|
||||||
|
linux_dev_station_code_server_port: 3000
|
||||||
|
linux_dev_station_code_server_auth: "none"
|
||||||
|
|
||||||
|
linux_dev_station_opencode_server_port: 4096
|
||||||
|
linux_dev_station_manage_systemd: true
|
||||||
|
linux_dev_station_oh_my_zsh_enabled: true
|
||||||
|
|
||||||
|
linux_dev_station_code_server_extensions_enabled: true
|
||||||
|
linux_dev_station_code_server_extensions:
|
||||||
|
- ms-python.python
|
||||||
|
- ms-python.vscode-pylance
|
||||||
|
- ms-python.debugpy
|
||||||
|
- golang.go
|
||||||
|
- ms-azuretools.vscode-docker
|
||||||
|
- hashicorp.terraform
|
||||||
|
- opentofu.vscode-opentofu
|
||||||
|
- redhat.ansible
|
||||||
|
- redhat.vscode-yaml
|
||||||
|
- eamodio.gitlens
|
||||||
|
|
||||||
|
linux_dev_station_ssh_agent_enabled: true
|
||||||
|
linux_dev_station_mcp_bootstrap_commands: []
|
||||||
|
|
||||||
|
linux_dev_station_mcp_installers_enabled: true
|
||||||
|
linux_dev_station_mcp_installers:
|
||||||
|
- name: "chrome-devtools-mcp"
|
||||||
|
kind: "npm"
|
||||||
|
package: "chrome-devtools-mcp"
|
||||||
|
version: "latest"
|
||||||
|
- name: "playwright-mcp"
|
||||||
|
kind: "npm"
|
||||||
|
package: "@playwright/mcp"
|
||||||
|
version: "latest"
|
||||||
|
- name: "aws-documentation-mcp"
|
||||||
|
kind: "uv"
|
||||||
|
package: "awslabs.aws-documentation-mcp-server"
|
||||||
|
version: "latest"
|
||||||
|
binary: "awslabs.aws-documentation-mcp-server"
|
||||||
|
- name: "gitea-mcp"
|
||||||
|
kind: "go"
|
||||||
|
module: "gitea.com/gitea/gitea-mcp"
|
||||||
|
version: "latest"
|
||||||
|
binary: "gitea-mcp"
|
||||||
|
|
||||||
|
linux_dev_station_opencode_extra_environment:
|
||||||
|
GITEA_MCP_HOST: "https://code.alexpires.me"
|
||||||
|
GITEA_APP_TOKEN: "{{ lookup('env', 'GITEA_APP_TOKEN', default='') }}"
|
||||||
|
|
||||||
|
linux_dev_station_opencode_config:
|
||||||
|
"$schema": "https://opencode.ai/config.json"
|
||||||
|
provider:
|
||||||
|
alexpires-me:
|
||||||
|
name: "alexpires.me"
|
||||||
|
npm: "@ai-sdk/openai-compatible"
|
||||||
|
options:
|
||||||
|
baseURL: "https://llm.lab.alexpires.me/api/v1"
|
||||||
|
apiKey: "{{ lookup('env', 'OPEN_WEBUI_API_KEY', default='') }}"
|
||||||
|
timeout: 3000000
|
||||||
|
chunkTimeout: 3000000
|
||||||
|
models:
|
||||||
|
qwen3-6-27b-mtp-q4-0:
|
||||||
|
name: "qwen3-6-27b-mtp-q4-0"
|
||||||
|
qwen3-6-35b-a3b-mtp-ud-q4-m:
|
||||||
|
name: "qwen3-6-35b-a3b-mtp-ud-q4-m"
|
||||||
|
scaleway:
|
||||||
|
name: "scaleway"
|
||||||
|
npm: "@ai-sdk/openai-compatible"
|
||||||
|
options:
|
||||||
|
baseURL: "https://api.scaleway.ai/{{ lookup('env', 'SCW_DEFAULT_ORGANIZATION_ID', default='') }}/v1"
|
||||||
|
apiKey: "{{ lookup('env', 'SCW_INFERENCE_API_KEY', default='') }}"
|
||||||
|
mcp:
|
||||||
|
deepwiki:
|
||||||
|
type: "remote"
|
||||||
|
url: "https://mcp.deepwiki.com/mcp"
|
||||||
|
enabled: true
|
||||||
|
terraform:
|
||||||
|
type: "remote"
|
||||||
|
url: "http://terraform-mcp.lab.alexpires.me/mcp"
|
||||||
|
enabled: true
|
||||||
|
opentofu:
|
||||||
|
type: "remote"
|
||||||
|
url: "https://mcp.opentofu.org/mcp"
|
||||||
|
enabled: true
|
||||||
|
microsoft_learn:
|
||||||
|
type: "remote"
|
||||||
|
url: "https://learn.microsoft.com/api/mcp"
|
||||||
|
enabled: true
|
||||||
|
mozilla_developer:
|
||||||
|
type: "remote"
|
||||||
|
url: "https://mcp.mdn.mozilla.net/"
|
||||||
|
enabled: true
|
||||||
|
chrome_devtools:
|
||||||
|
type: "local"
|
||||||
|
command:
|
||||||
|
- "npx"
|
||||||
|
- "-y"
|
||||||
|
- "chrome-devtools-mcp@latest"
|
||||||
|
- "--headless"
|
||||||
|
- "--isolated"
|
||||||
|
enabled: true
|
||||||
|
playwright:
|
||||||
|
type: "local"
|
||||||
|
command:
|
||||||
|
- "npx"
|
||||||
|
- "-y"
|
||||||
|
- "@playwright/mcp@latest"
|
||||||
|
- "--headless"
|
||||||
|
- "--isolated"
|
||||||
|
enabled: true
|
||||||
|
aws_documentation:
|
||||||
|
type: "local"
|
||||||
|
command:
|
||||||
|
- "uvx"
|
||||||
|
- "awslabs.aws-documentation-mcp-server@latest"
|
||||||
|
enabled: true
|
||||||
|
gitea:
|
||||||
|
type: "local"
|
||||||
|
command:
|
||||||
|
- "gitea-mcp"
|
||||||
|
- "-t"
|
||||||
|
- "stdio"
|
||||||
|
- "-H"
|
||||||
|
- "{env:GITEA_MCP_HOST}"
|
||||||
|
- "-T"
|
||||||
|
- "{env:GITEA_APP_TOKEN}"
|
||||||
|
enabled: true
|
||||||
|
compaction:
|
||||||
|
auto: true
|
||||||
|
prune: true
|
||||||
|
reserved: 32768
|
||||||
|
|
||||||
|
linux_dev_station_service_user_ssh_enabled: true
|
||||||
|
linux_dev_station_service_user_ssh_authorized_keys: []
|
||||||
|
|
||||||
|
# Bind services to localhost (nginx handles TLS+auth)
|
||||||
|
linux_dev_station_code_server_bind_to_localhost: true
|
||||||
|
linux_dev_station_opencode_bind_to_localhost: true
|
||||||
|
|
||||||
|
# nginx_proxy: TLS + OAuth2/OIDC SSO
|
||||||
|
nginx_proxy_certbot_email: "admin@alexpires.me"
|
||||||
|
nginx_proxy_oauth2_proxy_enabled: true
|
||||||
|
nginx_proxy_oauth2_proxy_provider: "oidc"
|
||||||
|
nginx_proxy_oauth2_proxy_provider_url: "https://code.alexpires.me"
|
||||||
|
nginx_proxy_oauth2_proxy_cookie_domain: ".lab.alexpires.me"
|
||||||
|
nginx_proxy_oauth2_proxy_redirect_urls:
|
||||||
|
- "https://ide.lab.alexpires.me/oauth2/callback"
|
||||||
|
- "https://agent.lab.alexpires.me/oauth2/callback"
|
||||||
|
nginx_proxy_oauth2_proxy_email_domains:
|
||||||
|
- "*"
|
||||||
|
nginx_proxy_oauth2_proxy_cookie_secret: "{{ lookup('env', 'OAUTH2_PROXY_COOKIE_SECRET', default='') }}"
|
||||||
|
nginx_proxy_oauth2_proxy_client_id: "{{ lookup('env', 'OAUTH2_PROXY_CLIENT_ID', default='') }}"
|
||||||
|
nginx_proxy_oauth2_proxy_client_secret: "{{ lookup('env', 'OAUTH2_PROXY_CLIENT_SECRET', default='') }}"
|
||||||
|
nginx_proxy_sites:
|
||||||
|
- name: ide
|
||||||
|
server_name: "ide.lab.alexpires.me"
|
||||||
|
upstream:
|
||||||
|
host: "127.0.0.1"
|
||||||
|
port: "{{ linux_dev_station_code_server_port }}"
|
||||||
|
websocket: true
|
||||||
|
- name: agent
|
||||||
|
server_name: "agent.lab.alexpires.me"
|
||||||
|
upstream:
|
||||||
|
host: "127.0.0.1"
|
||||||
|
port: "{{ linux_dev_station_opencode_server_port }}"
|
||||||
|
websocket: true
|
||||||
|
|
||||||
|
# Firewall ports
|
||||||
|
ufw_enable: true
|
||||||
|
fw_allowed_ports:
|
||||||
|
- { rule: "allow", port: "22", proto: "tcp", from: "10.19.4.0/24" } # SSH (local network)
|
||||||
|
- { rule: "allow", port: "443", proto: "tcp", from: "10.19.4.0/24" } # HTTPS (nginx reverse proxy)
|
||||||
|
- { rule: "allow", port: "22", proto: "tcp", from: "10.5.5.0/24" } # SSH (VPN devices)
|
||||||
|
- { rule: "allow", port: "443", proto: "tcp", from: "10.5.5.0/24" } # HTTPS (VPN devices)
|
||||||
|
- { rule: "allow", port: "9090", proto: "tcp", from: "10.19.4.0/24" } # Cockpit
|
||||||
|
|
||||||
|
linux_dev_station_oh_my_zsh_theme: "agnoster"
|
||||||
|
linux_dev_station_oh_my_zsh_plugins:
|
||||||
|
- git
|
||||||
|
- fzf
|
||||||
|
- common-aliases
|
||||||
|
- sudo
|
||||||
|
- tmux
|
||||||
|
- extract
|
||||||
|
- colored-man-pages
|
||||||
|
- history-substring-search
|
||||||
|
- kubectl
|
||||||
|
- ansible
|
||||||
|
- aws
|
||||||
|
- golang
|
||||||
|
- dnf
|
||||||
|
- bgnotify
|
||||||
|
linux_dev_station_zshrc_extra_env:
|
||||||
|
EDITOR: "nano"
|
||||||
|
MANPAGER: "less -R"
|
||||||
|
LESS: "-R"
|
||||||
|
BW_ORGANIZATION_ID: "{{ lookup('env', 'BW_ORGANIZATION_ID', default='') }}"
|
||||||
|
BW_ACCESS_TOKEN: "{{ lookup('env', 'BW_ACCESS_TOKEN', default='') }}"
|
||||||
|
ANSIBLE_USER: "{{ lookup('env', 'ANSIBLE_USER', default='') }}"
|
||||||
@@ -39,8 +39,8 @@ ufw_enable: true
|
|||||||
fw_allowed_ports:
|
fw_allowed_ports:
|
||||||
- { rule: "allow", port: "22", proto: "tcp", from: "10.5.5.5/32" } # Wireguard VPN (Laptop)
|
- { rule: "allow", port: "22", proto: "tcp", from: "10.5.5.5/32" } # Wireguard VPN (Laptop)
|
||||||
- { rule: "allow", port: "22", proto: "tcp", from: "10.19.4.0/24" } # local network
|
- { rule: "allow", port: "22", proto: "tcp", from: "10.19.4.0/24" } # local network
|
||||||
- { rule: "allow", port: "11434", proto: "tcp", from: "10.19.4.0/24" } # Ollama (local network)
|
- { rule: "allow", port: "443", proto: "tcp", from: "10.19.4.0/24" } # HTTPS (nginx reverse proxy)
|
||||||
- { rule: "allow", port: "11434", proto: "tcp", from: "10.5.5.5/32" } # Ollama (Laptop)
|
- { rule: "allow", port: "443", proto: "tcp", from: "10.5.5.5/32" } # HTTPS (nginx reverse proxy, VPN)
|
||||||
- { rule: "allow", port: "8880", proto: "tcp", from: "10.19.4.0/24" } # Kokoro-FastAPI (local network)
|
- { rule: "allow", port: "8880", proto: "tcp", from: "10.19.4.0/24" } # Kokoro-FastAPI (local network)
|
||||||
- { rule: "allow", port: "8880", proto: "tcp", from: "10.5.5.5/32" } # Kokoro-FastAPI (Laptop)
|
- { rule: "allow", port: "8880", proto: "tcp", from: "10.5.5.5/32" } # Kokoro-FastAPI (Laptop)
|
||||||
- { rule: "allow", port: "8188", proto: "tcp", from: "10.19.4.0/24" } # ComfyUI (local network)
|
- { rule: "allow", port: "8188", proto: "tcp", from: "10.19.4.0/24" } # ComfyUI (local network)
|
||||||
@@ -112,31 +112,32 @@ llama_server_home: "/mnt/data/llamacpp"
|
|||||||
llama_server_user_pubkey: "ecdsa-sha2-nistp521 AAAAE2VjZHNhLXNoYTItbmlzdHA1MjEAAAAIbmlzdHA1MjEAAACFBAHHOPBR9p9kq5Cqzpe4cr3jHnweaYrHPXv5sXNzt+sCXP54uc5rWUBhxW2OQVvQzJ47dEVhEKi4WSC7LcuKS2G5AQDzWXNiasHvYIYQU3F/EknVCZnsiXYqXphYkJA6rJCNRnISZCIC1mocq6PB7J08ONdRFCvjfUBuVRT8BNGXNmQ/zQ=="
|
llama_server_user_pubkey: "ecdsa-sha2-nistp521 AAAAE2VjZHNhLXNoYTItbmlzdHA1MjEAAAAIbmlzdHA1MjEAAACFBAHHOPBR9p9kq5Cqzpe4cr3jHnweaYrHPXv5sXNzt+sCXP54uc5rWUBhxW2OQVvQzJ47dEVhEKi4WSC7LcuKS2G5AQDzWXNiasHvYIYQU3F/EknVCZnsiXYqXphYkJA6rJCNRnISZCIC1mocq6PB7J08ONdRFCvjfUBuVRT8BNGXNmQ/zQ=="
|
||||||
|
|
||||||
llama_server_port: 11434
|
llama_server_port: 11434
|
||||||
|
llama_server_bind: "127.0.0.1"
|
||||||
llama_server_devices:
|
llama_server_devices:
|
||||||
- "nvidia.com/gpu=all"
|
- "nvidia.com/gpu=all"
|
||||||
- "/dev/kfd:/dev/kfd:rw"
|
- "/dev/kfd:/dev/kfd:rw"
|
||||||
- "/dev/dri:/dev/dri:rw"
|
- "/dev/dri:/dev/dri:rw"
|
||||||
|
|
||||||
llama_server_models:
|
llama_server_models:
|
||||||
# TODO: replace revision values with pinned commit SHAs for strict reproducibility.
|
# # TODO: replace revision values with pinned commit SHAs for strict reproducibility.
|
||||||
- name: "gemma-4-12b-q8-0"
|
- name: "gemma-4-12b-q8-0"
|
||||||
model_id: "unsloth/gemma-4-12b-it-GGUF"
|
model_id: "unsloth/gemma-4-12b-it-GGUF"
|
||||||
quant: "Q8_0"
|
quant: "Q8_0"
|
||||||
revision: "main"
|
revision: "main"
|
||||||
preset:
|
# preset:
|
||||||
ctx-size: 98304
|
# ctx-size: 98304
|
||||||
- name: "gemma-4-26b-a4b-ud-iq4-xs"
|
- name: "gemma-4-26b-a4b-ud-iq4-xs"
|
||||||
model_id: "unsloth/gemma-4-26B-A4B-it-GGUF"
|
model_id: "unsloth/gemma-4-26B-A4B-it-GGUF"
|
||||||
quant: "UD-IQ4_XS"
|
quant: "UD-IQ4_XS"
|
||||||
revision: "main"
|
revision: "main"
|
||||||
preset:
|
# preset:
|
||||||
ctx-size: 98304
|
# ctx-size: 98304
|
||||||
- name: "gemma-4-31b-iq4-xs"
|
- name: "gemma-4-31b-iq4-xs"
|
||||||
model_id: "unsloth/gemma-4-31B-it-GGUF"
|
model_id: "unsloth/gemma-4-31B-it-GGUF"
|
||||||
quant: "IQ4_XS"
|
quant: "IQ4_XS"
|
||||||
revision: "main"
|
revision: "main"
|
||||||
preset:
|
# preset:
|
||||||
ctx-size: 98304
|
# ctx-size: 98304
|
||||||
- name: "gpt-oss-20b-q4-0"
|
- name: "gpt-oss-20b-q4-0"
|
||||||
model_id: "unsloth/gpt-oss-20b-GGUF"
|
model_id: "unsloth/gpt-oss-20b-GGUF"
|
||||||
quant: "Q4_0"
|
quant: "Q4_0"
|
||||||
@@ -149,47 +150,61 @@ llama_server_models:
|
|||||||
model_id: "unsloth/Qwen3.5-27B-GGUF"
|
model_id: "unsloth/Qwen3.5-27B-GGUF"
|
||||||
quant: "Q4_K_M"
|
quant: "Q4_K_M"
|
||||||
revision: "main"
|
revision: "main"
|
||||||
preset:
|
# preset:
|
||||||
ctx-size: 98304
|
# ctx-size: 98304
|
||||||
- name: "qwen3-5-35b-a3b-q4-k-m"
|
- name: "qwen3-5-35b-a3b-q4-k-m"
|
||||||
model_id: "unsloth/Qwen3.5-35B-A3B-GGUF"
|
model_id: "unsloth/Qwen3.5-35B-A3B-GGUF"
|
||||||
quant: "Q4_K_M"
|
quant: "Q4_K_M"
|
||||||
revision: "main"
|
revision: "main"
|
||||||
preset:
|
# preset:
|
||||||
ctx-size: 98304
|
# ctx-size: 98304
|
||||||
- name: "qwen3-5-9b-q8-0"
|
- name: "qwen3-5-9b-q8-0"
|
||||||
model_id: "unsloth/Qwen3.5-9B-GGUF"
|
model_id: "unsloth/Qwen3.5-9B-GGUF"
|
||||||
quant: "Q8_0"
|
quant: "Q8_0"
|
||||||
revision: "main"
|
revision: "main"
|
||||||
preset:
|
# preset:
|
||||||
ctx-size: 98304
|
# ctx-size: 98304
|
||||||
- name: "qwen3-6-27b-q4-k-m"
|
- name: "qwen3-6-27b-q4-k-m"
|
||||||
model_id: "unsloth/Qwen3.6-27B-GGUF"
|
model_id: "unsloth/Qwen3.6-27B-GGUF"
|
||||||
quant: "Q4_K_M"
|
quant: "Q4_K_M"
|
||||||
revision: "main"
|
revision: "main"
|
||||||
preset:
|
# preset:
|
||||||
ctx-size: 98304
|
# ctx-size: 98304
|
||||||
|
- name: "qwen3-6-35b-a3b-ud-q4-k-m"
|
||||||
|
model_id: "unsloth/Qwen3.6-35B-A3B-GGUF"
|
||||||
|
quant: "UD-Q4_K_M"
|
||||||
|
revision: "main"
|
||||||
|
# preset:
|
||||||
|
# ctx-size: 98304
|
||||||
- name: "qwen3-6-27b-mtp-q4-0"
|
- name: "qwen3-6-27b-mtp-q4-0"
|
||||||
model_id: "unsloth/Qwen3.6-27B-MTP-GGUF"
|
model_id: "unsloth/Qwen3.6-27B-MTP-GGUF"
|
||||||
quant: "Q4_0"
|
quant: "Q4_0"
|
||||||
revision: "main"
|
revision: "main"
|
||||||
preset:
|
preset:
|
||||||
ctx-size: 98304
|
temperature: 0.6
|
||||||
|
top_p: 0.95
|
||||||
|
top_k: 20
|
||||||
|
min_p: 0.0
|
||||||
|
presence_penalty: 0.0
|
||||||
|
repeat_penalty: 1.0
|
||||||
|
# ctx-size: 98304
|
||||||
- name: "qwen3-6-35b-a3b-mtp-ud-q4-m"
|
- name: "qwen3-6-35b-a3b-mtp-ud-q4-m"
|
||||||
model_id: "unsloth/Qwen3.6-35B-A3B-MTP-GGUF"
|
model_id: "unsloth/Qwen3.6-35B-A3B-MTP-GGUF"
|
||||||
quant: "UD-Q4_K_M"
|
quant: "UD-Q4_K_M"
|
||||||
revision: "main"
|
revision: "main"
|
||||||
preset:
|
preset:
|
||||||
ctx-size: 98304
|
temperature: 0.6
|
||||||
- name: "qwen3-6-35b-a3b-ud-q4-k-m"
|
top_p: 0.95
|
||||||
model_id: "unsloth/Qwen3.6-35B-A3B-GGUF"
|
top_k: 20
|
||||||
quant: "UD-Q4_K_M"
|
min_p: 0.0
|
||||||
revision: "main"
|
presence_penalty: 0.0
|
||||||
preset:
|
repeat_penalty: 1.0
|
||||||
ctx-size: 98304
|
# ctx-size: 98304
|
||||||
|
|
||||||
llama_server_argv_extra:
|
llama_server_argv_extra:
|
||||||
[
|
[
|
||||||
|
"--api-key",
|
||||||
|
"{{ lookup('env', 'OPEN_WEBUI_API_KEY', default='') }}",
|
||||||
"-t",
|
"-t",
|
||||||
10,
|
10,
|
||||||
"-np",
|
"-np",
|
||||||
@@ -201,9 +216,9 @@ llama_server_argv_extra:
|
|||||||
"-fa",
|
"-fa",
|
||||||
"on",
|
"on",
|
||||||
"-ctk",
|
"-ctk",
|
||||||
"q4_0",
|
|
||||||
"-ctv",
|
|
||||||
"q8_0",
|
"q8_0",
|
||||||
|
"-ctv",
|
||||||
|
"q4_0",
|
||||||
"-cram",
|
"-cram",
|
||||||
-1,
|
-1,
|
||||||
"-sm",
|
"-sm",
|
||||||
@@ -216,11 +231,16 @@ llama_server_argv_extra:
|
|||||||
"off",
|
"off",
|
||||||
"-mg",
|
"-mg",
|
||||||
1,
|
1,
|
||||||
|
"--timeout",
|
||||||
|
1800,
|
||||||
"--mmap",
|
"--mmap",
|
||||||
"--spec-type",
|
"--spec-type",
|
||||||
"draft-mtp",
|
"draft-mtp",
|
||||||
"--spec-draft-n-max",
|
"--spec-draft-n-max",
|
||||||
2,
|
2,
|
||||||
|
"--swa-full",
|
||||||
|
"--cache-reuse",
|
||||||
|
256
|
||||||
]
|
]
|
||||||
|
|
||||||
auto_suspend_enabled: true
|
auto_suspend_enabled: true
|
||||||
@@ -232,3 +252,33 @@ auto_suspend_cpu_idle_threshold: 90.0
|
|||||||
# llama_server_preset_global:
|
# llama_server_preset_global:
|
||||||
# ctx-size: 98304
|
# ctx-size: 98304
|
||||||
# n-gpu-layers: all
|
# n-gpu-layers: all
|
||||||
|
|
||||||
|
# nginx_proxy: TLS + OAuth2/OIDC SSO for llm.lab.alexpires.me
|
||||||
|
nginx_proxy_certbot_email: "c.alexandre.pires@alexpires.me"
|
||||||
|
nginx_proxy_oauth2_proxy_enabled: true
|
||||||
|
nginx_proxy_oauth2_proxy_provider: "oidc"
|
||||||
|
nginx_proxy_oauth2_proxy_provider_url: "https://code.alexpires.me"
|
||||||
|
nginx_proxy_oauth2_proxy_redirect_urls:
|
||||||
|
- "https://llm.lab.alexpires.me/oauth2/callback"
|
||||||
|
nginx_proxy_oauth2_proxy_email_domains:
|
||||||
|
- "*"
|
||||||
|
nginx_proxy_oauth2_proxy_cookie_secret: "{{ lookup('env', 'OAUTH2_PROXY_COOKIE_SECRET', default='') }}"
|
||||||
|
nginx_proxy_oauth2_proxy_client_id: "{{ lookup('env', 'OAUTH2_PROXY_CLIENT_ID', default='') }}"
|
||||||
|
nginx_proxy_oauth2_proxy_client_secret: "{{ lookup('env', 'OAUTH2_PROXY_CLIENT_SECRET', default='') }}"
|
||||||
|
nginx_proxy_sites:
|
||||||
|
- name: llm
|
||||||
|
server_name: "llm.lab.alexpires.me"
|
||||||
|
upstream:
|
||||||
|
host: "127.0.0.1"
|
||||||
|
port: 11434
|
||||||
|
websocket: true
|
||||||
|
extra_locations:
|
||||||
|
- path: /api/
|
||||||
|
config:
|
||||||
|
proxy_pass: "http://127.0.0.1:11434/"
|
||||||
|
proxy_buffering: "off"
|
||||||
|
proxy_read_timeout: "1800s"
|
||||||
|
proxy_send_timeout: "1800s"
|
||||||
|
proxy_http_version: "1.1"
|
||||||
|
proxy_set_header Upgrade: "$http_upgrade"
|
||||||
|
proxy_set_header Connection: "$connection_upgrade"
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ macos_dev_station_opencode_config:
|
|||||||
options:
|
options:
|
||||||
baseURL: "https://open-webui.lab.alexpires.me/api"
|
baseURL: "https://open-webui.lab.alexpires.me/api"
|
||||||
apiKey: "{{ lookup('env', 'OPEN_WEBUI_API_KEY', default='') }}"
|
apiKey: "{{ lookup('env', 'OPEN_WEBUI_API_KEY', default='') }}"
|
||||||
|
timeout: 3000000
|
||||||
|
chunkTimeout: 3000000
|
||||||
models:
|
models:
|
||||||
gemma-4-12b-q8-0:
|
gemma-4-12b-q8-0:
|
||||||
name: "gemma-4-12b-q8-0"
|
name: "gemma-4-12b-q8-0"
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
- name: Configure Linux dev station
|
||||||
|
hosts: linux_dev
|
||||||
|
gather_facts: true
|
||||||
|
|
||||||
|
pre_tasks:
|
||||||
|
- name: Ensure target is Fedora
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- ansible_facts['distribution'] == "Fedora"
|
||||||
|
fail_msg: "This playbook supports Fedora hosts only"
|
||||||
|
|
||||||
|
roles:
|
||||||
|
- linux_dev_station
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
- name: Configure nginx reverse proxy with TLS and OAuth2
|
||||||
|
hosts: nginx
|
||||||
|
gather_facts: true
|
||||||
|
|
||||||
|
pre_tasks:
|
||||||
|
- name: Ensure target is Fedora
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- ansible_facts['distribution'] == "Fedora"
|
||||||
|
fail_msg: "This playbook supports Fedora hosts only"
|
||||||
|
|
||||||
|
roles:
|
||||||
|
- nginx_proxy
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
cis_logind_conf: {}
|
||||||
@@ -11,5 +11,4 @@
|
|||||||
notify:
|
notify:
|
||||||
- Reload systemd
|
- Reload systemd
|
||||||
tags:
|
tags:
|
||||||
- systemd
|
- roles::cis::logind
|
||||||
- logind
|
|
||||||
|
|||||||
@@ -6,3 +6,6 @@ KillExcludeUsers=root
|
|||||||
IdleAction=lock
|
IdleAction=lock
|
||||||
IdleActionSec=15min
|
IdleActionSec=15min
|
||||||
RemoveIPC=yes
|
RemoveIPC=yes
|
||||||
|
{% for key, value in cis_logind_conf.items() %}
|
||||||
|
{{ key }}={{ value }}
|
||||||
|
{% endfor %}
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
---
|
||||||
|
linux_dev_station_service_user_enabled: true
|
||||||
|
linux_dev_station_service_user: "devstation"
|
||||||
|
linux_dev_station_service_shell: "/usr/sbin/nologin"
|
||||||
|
linux_dev_station_service_user_ssh_enabled: false
|
||||||
|
linux_dev_station_service_user_ssh_shell: "/bin/zsh"
|
||||||
|
linux_dev_station_service_user_ssh_authorized_keys: []
|
||||||
|
linux_dev_station_service_home: "/home/{{ linux_dev_station_service_user }}"
|
||||||
|
|
||||||
|
linux_dev_station_workspace_dir: "{{ linux_dev_station_service_home }}/projects"
|
||||||
|
linux_dev_station_scripts_dir: "{{ linux_dev_station_service_home }}/scripts"
|
||||||
|
linux_dev_station_logs_dir: "{{ linux_dev_station_service_home }}/.local/state/logs"
|
||||||
|
linux_dev_station_systemd_unit_dir: "/etc/systemd/system"
|
||||||
|
linux_dev_station_path: >-
|
||||||
|
{{ linux_dev_station_service_home }}/.local/bin:{{ linux_dev_station_service_home }}/go/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/usr/local/bin
|
||||||
|
|
||||||
|
linux_dev_station_dnf_packages:
|
||||||
|
- git
|
||||||
|
- tmux
|
||||||
|
- wget
|
||||||
|
- curl
|
||||||
|
- jq
|
||||||
|
- openssl
|
||||||
|
- zsh
|
||||||
|
- golang
|
||||||
|
- ripgrep
|
||||||
|
- fzf
|
||||||
|
- sqlite3
|
||||||
|
- opentofu
|
||||||
|
- kubernetes1.36-client
|
||||||
|
- ansible
|
||||||
|
- python3-ansible-lint
|
||||||
|
|
||||||
|
linux_dev_station_tool_packages:
|
||||||
|
- name: uv
|
||||||
|
install_script_url: "https://astral.sh/uv/install.sh"
|
||||||
|
- name: node
|
||||||
|
manager: fnm
|
||||||
|
- name: rust
|
||||||
|
manager: rustup
|
||||||
|
- name: code-server
|
||||||
|
version: "4.127.0"
|
||||||
|
sha256: ""
|
||||||
|
- name: opencode
|
||||||
|
version: "1.17.18"
|
||||||
|
sha256: ""
|
||||||
|
- name: sectool
|
||||||
|
version: "1.1.2"
|
||||||
|
sha256: ""
|
||||||
|
|
||||||
|
linux_dev_station_code_server_bind_to_localhost: false
|
||||||
|
|
||||||
|
linux_dev_station_code_server_host: >-
|
||||||
|
{{ (linux_dev_station_code_server_bind_to_localhost | bool) | ternary('127.0.0.1', '0.0.0.0') }}
|
||||||
|
linux_dev_station_code_server_port: 3000
|
||||||
|
linux_dev_station_code_server_auth: "password"
|
||||||
|
linux_dev_station_code_server_password: "{{ lookup('env', 'CODE_SERVER_PASSWORD', default='') }}"
|
||||||
|
linux_dev_station_code_server_workspace: "{{ linux_dev_station_workspace_dir }}"
|
||||||
|
linux_dev_station_code_server_extensions_enabled: true
|
||||||
|
linux_dev_station_code_server_extensions_fail_on_error: false
|
||||||
|
linux_dev_station_code_server_extensions:
|
||||||
|
- ms-python.python
|
||||||
|
- ms-python.vscode-pylance
|
||||||
|
- ms-azuretools.vscode-docker
|
||||||
|
- hashicorp.terraform
|
||||||
|
- redhat.ansible
|
||||||
|
- eamodio.gitlens
|
||||||
|
|
||||||
|
linux_dev_station_copilot_enabled: false
|
||||||
|
linux_dev_station_copilot_extensions:
|
||||||
|
- GitHub.copilot
|
||||||
|
- GitHub.copilot-chat
|
||||||
|
linux_dev_station_copilot_fail_on_error: false
|
||||||
|
|
||||||
|
linux_dev_station_oh_my_zsh_enabled: true
|
||||||
|
linux_dev_station_oh_my_zsh_install_url: "https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh"
|
||||||
|
linux_dev_station_oh_my_zsh_theme: "agnoster"
|
||||||
|
linux_dev_station_oh_my_zsh_plugins:
|
||||||
|
- git
|
||||||
|
- fzf
|
||||||
|
- common-aliases
|
||||||
|
- sudo
|
||||||
|
- tmux
|
||||||
|
- extract
|
||||||
|
- colored-man-pages
|
||||||
|
- history-substring-search
|
||||||
|
- kubectl
|
||||||
|
- microk8s
|
||||||
|
- docker
|
||||||
|
- ansible
|
||||||
|
- aws
|
||||||
|
- golang
|
||||||
|
- dnf
|
||||||
|
- bgnotify
|
||||||
|
linux_dev_station_zshrc_extra_env: {}
|
||||||
|
|
||||||
|
linux_dev_station_npm_packages: []
|
||||||
|
|
||||||
|
linux_dev_station_git_init_default_branch: "main"
|
||||||
|
linux_dev_station_git_pull_rebase: false
|
||||||
|
|
||||||
|
linux_dev_station_ssh_key_path: "{{ ansible_facts['env']['HOME'] }}/.ssh/id_ed25519"
|
||||||
|
linux_dev_station_ssh_key_comment: "dev-02"
|
||||||
|
|
||||||
|
linux_dev_station_code_server_systemd_enabled: true
|
||||||
|
linux_dev_station_opencode_systemd_enabled: true
|
||||||
|
linux_dev_station_opencode_bind_to_localhost: false
|
||||||
|
|
||||||
|
linux_dev_station_opencode_server_host: >-
|
||||||
|
{{ (linux_dev_station_opencode_bind_to_localhost | bool) | ternary('127.0.0.1', '0.0.0.0') }}
|
||||||
|
linux_dev_station_opencode_server_port: 4096
|
||||||
|
linux_dev_station_opencode_server_username: "{{ lookup('env', 'OPENCODE_SERVER_USERNAME', default='') }}"
|
||||||
|
linux_dev_station_opencode_server_password: "{{ lookup('env', 'OPENCODE_SERVER_PASSWORD', default='') }}"
|
||||||
|
linux_dev_station_opencode_extra_environment: {}
|
||||||
|
linux_dev_station_opencode_command: "opencode serve --hostname {{ linux_dev_station_opencode_server_host }} --port {{ linux_dev_station_opencode_server_port }}"
|
||||||
|
linux_dev_station_opencode_config: {}
|
||||||
|
linux_dev_station_opencode_config_path: "{{ linux_dev_station_service_home }}/.config/opencode/opencode.json"
|
||||||
|
|
||||||
|
# MCP installer definitions
|
||||||
|
# Supported kinds:
|
||||||
|
# - npm: package + optional version
|
||||||
|
# - uv: package + optional version + binary
|
||||||
|
# - go: module + optional version + binary
|
||||||
|
linux_dev_station_mcp_installers_enabled: true
|
||||||
|
linux_dev_station_mcp_installers: []
|
||||||
|
|
||||||
|
# Legacy ad-hoc hook kept for backward compatibility.
|
||||||
|
linux_dev_station_mcp_bootstrap_commands: []
|
||||||
|
linux_dev_station_manage_systemd: true
|
||||||
|
|
||||||
|
# SSH agent
|
||||||
|
linux_dev_station_ssh_agent_enabled: false
|
||||||
|
linux_dev_station_ssh_agent_keys:
|
||||||
|
- "{{ linux_dev_station_service_home }}/.ssh/id_ed25519"
|
||||||
|
linux_dev_station_ssh_agent_passphrase: "{{ lookup('env', 'SSH_KEY_PASSPHRASE', default='') }}"
|
||||||
|
linux_dev_station_ssh_agent_socket: "{{ linux_dev_station_service_home }}/.ssh/ssh-agent.sock"
|
||||||
+164
@@ -0,0 +1,164 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ansible: linux_dev_station install-copilot.sh
|
||||||
|
# Installs GitHub Copilot and Copilot Chat extensions into code-server
|
||||||
|
# with engine-compatible versions resolved from the VS Marketplace API.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
get_engine_version() {
|
||||||
|
local version_output
|
||||||
|
version_output=$(code-server --version | head -n1)
|
||||||
|
if echo "$version_output" | grep -q "with Code"; then
|
||||||
|
echo "$version_output" | sed -n 's/.*with Code \([0-9.]*\).*/\1/p'
|
||||||
|
else
|
||||||
|
echo "$version_output" | awk '{print $1}'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
get_user_data_dir() {
|
||||||
|
local process_info
|
||||||
|
process_info=$(ps aux 2>/dev/null | grep -v grep | grep "code-server" | head -n 1) ||
|
||||||
|
process_info=$(ps -ef 2>/dev/null | grep -v grep | grep "code-server" | head -n 1)
|
||||||
|
if [ -n "$process_info" ]; then
|
||||||
|
local dir
|
||||||
|
dir=$(echo "$process_info" | grep -o -- '--user-data-dir=[^ ]*' | sed 's/--user-data-dir=//')
|
||||||
|
if [ -n "$dir" ]; then
|
||||||
|
echo "$dir"; return
|
||||||
|
fi
|
||||||
|
dir=$(echo "$process_info" | grep -o -- '--user-data-dir [^ ]*' | sed 's/--user-data-dir //')
|
||||||
|
if [ -n "$dir" ]; then
|
||||||
|
echo "$dir"; return
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
find_compatible_version() {
|
||||||
|
local extension_id="$1"
|
||||||
|
local engine_version="$2"
|
||||||
|
local response
|
||||||
|
response=$(curl -s -X POST "https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Accept: application/json;api-version=3.0-preview.1" \
|
||||||
|
-d "{
|
||||||
|
\"filters\": [{
|
||||||
|
\"criteria\": [
|
||||||
|
{\"filterType\": 7, \"value\": \"$extension_id\"},
|
||||||
|
{\"filterType\": 12, \"value\": \"4096\"}
|
||||||
|
],
|
||||||
|
\"pageSize\": 50
|
||||||
|
}],
|
||||||
|
\"flags\": 4112
|
||||||
|
}")
|
||||||
|
echo "$response" | jq -r --arg engine_version "$engine_version" '
|
||||||
|
.results[0].extensions[0].versions[] |
|
||||||
|
select(.version | test("^[0-9]+\\.[0-9]+\\.[0-9]*$")) |
|
||||||
|
select(all(.properties[]; .key != "Microsoft.VisualStudio.Code.PreRelease" or .value != "true")) |
|
||||||
|
{
|
||||||
|
version: .version,
|
||||||
|
engine: (.properties[] | select(.key == "Microsoft.VisualStudio.Code.Engine") | .value)
|
||||||
|
} |
|
||||||
|
select(.engine | ltrimstr("^") | split(".") |
|
||||||
|
map(split("-")[0] | tonumber? // 0) as $engine_parts |
|
||||||
|
($engine_version | split(".") | map(split("-")[0] | tonumber? // 0)) as $server_parts |
|
||||||
|
(
|
||||||
|
($engine_parts[0] // 0) < $server_parts[0] or
|
||||||
|
(($engine_parts[0] // 0) == $server_parts[0] and ($engine_parts[1] // 0) < $server_parts[1]) or
|
||||||
|
(($engine_parts[0] // 0) == $server_parts[0] and ($engine_parts[1] // 0) == $server_parts[1] and ($engine_parts[2] // 0) <= $server_parts[2])
|
||||||
|
)
|
||||||
|
) |
|
||||||
|
.version' | head -n 1
|
||||||
|
}
|
||||||
|
|
||||||
|
install_extension() {
|
||||||
|
local extension_id="$1"
|
||||||
|
local version="$2"
|
||||||
|
local user_data_dir="$3"
|
||||||
|
local extension_name
|
||||||
|
extension_name=$(echo "$extension_id" | cut -d'.' -f2)
|
||||||
|
local temp_dir="/tmp/code-extensions"
|
||||||
|
echo "Installing $extension_id v$version..."
|
||||||
|
mkdir -p "$temp_dir"
|
||||||
|
echo " Downloading..."
|
||||||
|
curl -L "https://marketplace.visualstudio.com/_apis/public/gallery/publishers/GitHub/vsextensions/$extension_name/$version/vspackage" \
|
||||||
|
-o "$temp_dir/$extension_name.vsix.gz"
|
||||||
|
if [ ! -f "$temp_dir/$extension_name.vsix.gz" ]; then
|
||||||
|
echo " [SKIP] Download failed for $extension_id"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if command -v gunzip >/dev/null 2>&1; then
|
||||||
|
gunzip -f "$temp_dir/$extension_name.vsix.gz"
|
||||||
|
else
|
||||||
|
gzip -df "$temp_dir/$extension_name.vsix.gz"
|
||||||
|
fi
|
||||||
|
if [ -n "$user_data_dir" ]; then
|
||||||
|
code-server --user-data-dir="$user_data_dir" --force --install-extension "$temp_dir/$extension_name.vsix"
|
||||||
|
else
|
||||||
|
code-server --force --install-extension "$temp_dir/$extension_name.vsix"
|
||||||
|
fi
|
||||||
|
rm -f "$temp_dir/$extension_name.vsix"
|
||||||
|
echo " [OK] $extension_id installed successfully!"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
check_dependencies() {
|
||||||
|
local missing_deps=()
|
||||||
|
for cmd in curl jq code-server; do
|
||||||
|
if ! command -v "$cmd" >/dev/null 2>&1; then
|
||||||
|
missing_deps+=("$cmd")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if ! command -v gunzip >/dev/null 2>&1 && ! command -v gzip >/dev/null 2>&1; then
|
||||||
|
missing_deps+=("gunzip/gzip")
|
||||||
|
fi
|
||||||
|
if [ "${#missing_deps[@]}" -gt 0 ]; then
|
||||||
|
echo "Error: Missing required dependencies: ${missing_deps[*]}"
|
||||||
|
echo "Please install the missing dependencies and try again."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "========================================"
|
||||||
|
echo " GitHub Copilot Extensions Installer"
|
||||||
|
echo "========================================"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
check_dependencies
|
||||||
|
|
||||||
|
ENGINE_VERSION="$(get_engine_version)"
|
||||||
|
if [ -z "$ENGINE_VERSION" ]; then
|
||||||
|
echo "Error: Could not extract engine version from code-server"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Detected code-server engine version: $ENGINE_VERSION"
|
||||||
|
|
||||||
|
USER_DATA_DIR="$(get_user_data_dir)"
|
||||||
|
if [ -n "$USER_DATA_DIR" ]; then
|
||||||
|
echo "Detected user-data-dir: $USER_DATA_DIR"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
EXTENSIONS="GitHub.copilot GitHub.copilot-chat"
|
||||||
|
FAILED=0
|
||||||
|
|
||||||
|
for ext in $EXTENSIONS; do
|
||||||
|
echo "Processing $ext..."
|
||||||
|
version="$(find_compatible_version "$ext" "$ENGINE_VERSION")"
|
||||||
|
if [ -z "$version" ]; then
|
||||||
|
echo " [SKIP] No compatible version found for $ext"
|
||||||
|
FAILED="$((FAILED + 1))"
|
||||||
|
else
|
||||||
|
echo " Found compatible version: $version"
|
||||||
|
if ! install_extension "$ext" "$version" "$USER_DATA_DIR"; then
|
||||||
|
FAILED="$((FAILED + 1))"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "========================================"
|
||||||
|
if [ $FAILED -eq 0 ]; then
|
||||||
|
echo "[OK] All extensions installed successfully!"
|
||||||
|
rm -rf /tmp/code-extensions
|
||||||
|
else
|
||||||
|
echo "[WARN] Completed with $FAILED error(s)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
- name: Reload and restart code-server
|
||||||
|
become: true
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: code-server
|
||||||
|
state: restarted
|
||||||
|
daemon_reload: true
|
||||||
|
when:
|
||||||
|
- linux_dev_station_manage_systemd | bool
|
||||||
|
- linux_dev_station_code_server_systemd_enabled | bool
|
||||||
|
listen: Restart code-server
|
||||||
|
|
||||||
|
- name: Reload and restart opencode
|
||||||
|
become: true
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: opencode
|
||||||
|
state: restarted
|
||||||
|
daemon_reload: true
|
||||||
|
when:
|
||||||
|
- linux_dev_station_manage_systemd | bool
|
||||||
|
- linux_dev_station_opencode_systemd_enabled | bool
|
||||||
|
listen: Restart opencode
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,504 @@
|
|||||||
|
---
|
||||||
|
- name: Ensure host is Fedora
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- ansible_facts['distribution'] == "Fedora"
|
||||||
|
fail_msg: "Role linux_dev_station supports Fedora hosts only"
|
||||||
|
|
||||||
|
- name: Install dnf packages
|
||||||
|
become: true
|
||||||
|
ansible.builtin.dnf:
|
||||||
|
name: "{{ linux_dev_station_dnf_packages }}"
|
||||||
|
state: present
|
||||||
|
|
||||||
|
- name: Ensure dedicated service user exists
|
||||||
|
become: true
|
||||||
|
ansible.builtin.user:
|
||||||
|
name: "{{ linux_dev_station_service_user }}"
|
||||||
|
shell: >-
|
||||||
|
{{ (linux_dev_station_service_user_ssh_enabled | bool) |
|
||||||
|
ternary(linux_dev_station_service_user_ssh_shell, linux_dev_station_service_shell) }}
|
||||||
|
home: "{{ linux_dev_station_service_home }}"
|
||||||
|
create_home: true
|
||||||
|
state: present
|
||||||
|
when: linux_dev_station_service_user_enabled | bool
|
||||||
|
|
||||||
|
- name: Ensure service user cache directory exists for tool installs
|
||||||
|
become: true
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ linux_dev_station_service_home }}/.cache"
|
||||||
|
state: directory
|
||||||
|
mode: "0750"
|
||||||
|
owner: "{{ linux_dev_station_service_user }}"
|
||||||
|
group: "{{ linux_dev_station_service_user }}"
|
||||||
|
when: linux_dev_station_service_user_enabled | bool
|
||||||
|
|
||||||
|
- name: Install tool-managed packages
|
||||||
|
ansible.builtin.include_tasks: tool_packages.yml
|
||||||
|
|
||||||
|
- name: Discover npm executable path
|
||||||
|
become: true
|
||||||
|
become_user: "{{ linux_dev_station_service_user }}"
|
||||||
|
ansible.builtin.command: /bin/sh -lc "command -v npm"
|
||||||
|
register: __linux_dev_station_npm_path
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
environment:
|
||||||
|
PATH: "{{ linux_dev_station_path }}"
|
||||||
|
when:
|
||||||
|
- linux_dev_station_service_user_enabled | bool
|
||||||
|
- linux_dev_station_npm_packages | length > 0
|
||||||
|
|
||||||
|
- name: Install global npm packages
|
||||||
|
become: true
|
||||||
|
become_user: "{{ linux_dev_station_service_user }}"
|
||||||
|
community.general.npm:
|
||||||
|
name: "{{ item.name }}"
|
||||||
|
version: "{{ item.version }}"
|
||||||
|
executable: "{{ __linux_dev_station_npm_path.stdout | trim }}"
|
||||||
|
global: true
|
||||||
|
environment:
|
||||||
|
PATH: "{{ linux_dev_station_path }}"
|
||||||
|
loop: "{{ linux_dev_station_npm_packages }}"
|
||||||
|
when:
|
||||||
|
- linux_dev_station_service_user_enabled | bool
|
||||||
|
- linux_dev_station_npm_packages | length > 0
|
||||||
|
- __linux_dev_station_npm_path.rc | default(1) == 0
|
||||||
|
|
||||||
|
- name: Ensure required user directories exist
|
||||||
|
become: true
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ item }}"
|
||||||
|
state: directory
|
||||||
|
mode: "0750"
|
||||||
|
owner: "{{ linux_dev_station_service_user }}"
|
||||||
|
group: "{{ linux_dev_station_service_user }}"
|
||||||
|
loop:
|
||||||
|
- "{{ linux_dev_station_service_home }}"
|
||||||
|
- "{{ linux_dev_station_workspace_dir }}"
|
||||||
|
- "{{ linux_dev_station_scripts_dir }}"
|
||||||
|
- "{{ linux_dev_station_logs_dir }}"
|
||||||
|
|
||||||
|
- name: Ensure service home permissions are restrictive
|
||||||
|
become: true
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ linux_dev_station_service_home }}"
|
||||||
|
state: directory
|
||||||
|
owner: "{{ linux_dev_station_service_user }}"
|
||||||
|
group: "{{ linux_dev_station_service_user }}"
|
||||||
|
mode: "0700"
|
||||||
|
|
||||||
|
- name: Ensure service user config directories exist
|
||||||
|
become: true
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ item }}"
|
||||||
|
state: directory
|
||||||
|
mode: "0750"
|
||||||
|
owner: "{{ linux_dev_station_service_user }}"
|
||||||
|
group: "{{ linux_dev_station_service_user }}"
|
||||||
|
loop:
|
||||||
|
- "{{ linux_dev_station_service_home }}/.local"
|
||||||
|
- "{{ linux_dev_station_service_home }}/.local/bin"
|
||||||
|
- "{{ linux_dev_station_service_home }}/.local/lib"
|
||||||
|
- "{{ linux_dev_station_service_home }}/.local/lib/node_modules"
|
||||||
|
- "{{ linux_dev_station_service_home }}/.config"
|
||||||
|
- "{{ linux_dev_station_service_home }}/.config/opencode"
|
||||||
|
when: linux_dev_station_service_user_enabled | bool
|
||||||
|
|
||||||
|
- name: Ensure OpenCode config object is valid when provided
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- linux_dev_station_opencode_config is mapping
|
||||||
|
fail_msg: "linux_dev_station_opencode_config must be a mapping (dictionary)."
|
||||||
|
|
||||||
|
- name: Render OpenCode config from host vars
|
||||||
|
become: true
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: opencode.json.j2
|
||||||
|
dest: "{{ linux_dev_station_opencode_config_path }}"
|
||||||
|
owner: "{{ linux_dev_station_service_user }}"
|
||||||
|
group: "{{ linux_dev_station_service_user }}"
|
||||||
|
mode: "0600"
|
||||||
|
notify: Restart opencode
|
||||||
|
when:
|
||||||
|
- linux_dev_station_service_user_enabled | bool
|
||||||
|
- linux_dev_station_opencode_config | length > 0
|
||||||
|
|
||||||
|
- name: Download Oh My Zsh installer
|
||||||
|
become: true
|
||||||
|
ansible.builtin.get_url:
|
||||||
|
url: "{{ linux_dev_station_oh_my_zsh_install_url }}"
|
||||||
|
dest: "{{ linux_dev_station_service_home }}/.cache/ohmyzsh-install.sh"
|
||||||
|
owner: "{{ linux_dev_station_service_user }}"
|
||||||
|
group: "{{ linux_dev_station_service_user }}"
|
||||||
|
mode: "0755"
|
||||||
|
when:
|
||||||
|
- linux_dev_station_service_user_enabled | bool
|
||||||
|
- linux_dev_station_oh_my_zsh_enabled | bool
|
||||||
|
|
||||||
|
- name: Install Oh My Zsh for service user
|
||||||
|
become: true
|
||||||
|
become_user: "{{ linux_dev_station_service_user }}"
|
||||||
|
ansible.builtin.command: >-
|
||||||
|
/bin/sh {{ linux_dev_station_service_home }}/.cache/ohmyzsh-install.sh --unattended
|
||||||
|
args:
|
||||||
|
creates: "{{ linux_dev_station_service_home }}/.oh-my-zsh"
|
||||||
|
environment:
|
||||||
|
HOME: "{{ linux_dev_station_service_home }}"
|
||||||
|
USER: "{{ linux_dev_station_service_user }}"
|
||||||
|
RUNZSH: "no"
|
||||||
|
CHSH: "no"
|
||||||
|
KEEP_ZSHRC: "yes"
|
||||||
|
when:
|
||||||
|
- linux_dev_station_service_user_enabled | bool
|
||||||
|
- linux_dev_station_oh_my_zsh_enabled | bool
|
||||||
|
|
||||||
|
- name: Ensure .zshrc sets PATH for tool binaries
|
||||||
|
become: true
|
||||||
|
ansible.builtin.blockinfile:
|
||||||
|
path: "{{ linux_dev_station_service_home }}/.zshrc"
|
||||||
|
block: |
|
||||||
|
# ansible: linux_dev_station path
|
||||||
|
if ! [[ "$PATH" =~ "$HOME/.local/bin:" ]]; then
|
||||||
|
PATH="$HOME/.local/bin:$PATH"
|
||||||
|
fi
|
||||||
|
if ! [[ "$PATH" =~ "$HOME/bin:" ]]; then
|
||||||
|
PATH="$HOME/bin:$PATH"
|
||||||
|
fi
|
||||||
|
export PATH
|
||||||
|
marker: "# {mark} ansible: linux_dev_station path"
|
||||||
|
create: true
|
||||||
|
mode: "0644"
|
||||||
|
owner: "{{ linux_dev_station_service_user }}"
|
||||||
|
group: "{{ linux_dev_station_service_user }}"
|
||||||
|
when:
|
||||||
|
- linux_dev_station_service_user_enabled | bool
|
||||||
|
|
||||||
|
- name: Ensure .zshrc unsets history vars before Oh My Zsh
|
||||||
|
become: true
|
||||||
|
ansible.builtin.blockinfile:
|
||||||
|
path: "{{ linux_dev_station_service_home }}/.zshrc"
|
||||||
|
block: |
|
||||||
|
# ansible: linux_dev_station unset history before oh-my-zsh
|
||||||
|
unset HISTFILE HISTSIZE SAVEHIST
|
||||||
|
marker: "# {mark} ansible: linux_dev_station unset history before oh-my-zsh"
|
||||||
|
create: true
|
||||||
|
mode: "0644"
|
||||||
|
owner: "{{ linux_dev_station_service_user }}"
|
||||||
|
group: "{{ linux_dev_station_service_user }}"
|
||||||
|
when:
|
||||||
|
- linux_dev_station_service_user_enabled | bool
|
||||||
|
- linux_dev_station_oh_my_zsh_enabled | bool
|
||||||
|
|
||||||
|
- name: Ensure .zshrc sources Oh My Zsh
|
||||||
|
become: true
|
||||||
|
ansible.builtin.blockinfile:
|
||||||
|
path: "{{ linux_dev_station_service_home }}/.zshrc"
|
||||||
|
block: |
|
||||||
|
# ansible: linux_dev_station oh-my-zsh
|
||||||
|
export ZSH="$HOME/.oh-my-zsh"
|
||||||
|
ZSH_THEME="{{ linux_dev_station_oh_my_zsh_theme }}"
|
||||||
|
plugins=({{ linux_dev_station_oh_my_zsh_plugins | join(' ') }})
|
||||||
|
source "$ZSH/oh-my-zsh.sh"
|
||||||
|
marker: "# {mark} ansible: linux_dev_station oh-my-zsh"
|
||||||
|
create: true
|
||||||
|
mode: "0644"
|
||||||
|
owner: "{{ linux_dev_station_service_user }}"
|
||||||
|
group: "{{ linux_dev_station_service_user }}"
|
||||||
|
when:
|
||||||
|
- linux_dev_station_service_user_enabled | bool
|
||||||
|
- linux_dev_station_oh_my_zsh_enabled | bool
|
||||||
|
|
||||||
|
- name: Ensure .zshrc has extra environment variables
|
||||||
|
become: true
|
||||||
|
ansible.builtin.blockinfile:
|
||||||
|
path: "{{ linux_dev_station_service_home }}/.zshrc"
|
||||||
|
block: |
|
||||||
|
# ansible: linux_dev_station extra env vars
|
||||||
|
{% for env_key, env_value in linux_dev_station_zshrc_extra_env.items() %}
|
||||||
|
export {{ env_key }}="{{ env_value }}"
|
||||||
|
{% endfor %}
|
||||||
|
marker: "# {mark} ansible: linux_dev_station extra env vars"
|
||||||
|
create: true
|
||||||
|
mode: "0644"
|
||||||
|
owner: "{{ linux_dev_station_service_user }}"
|
||||||
|
group: "{{ linux_dev_station_service_user }}"
|
||||||
|
when:
|
||||||
|
- linux_dev_station_service_user_enabled | bool
|
||||||
|
- linux_dev_station_zshrc_extra_env | length > 0
|
||||||
|
|
||||||
|
- name: Ensure .zshrc has ssh-agent startup for devstation
|
||||||
|
become: true
|
||||||
|
ansible.builtin.blockinfile:
|
||||||
|
path: "{{ linux_dev_station_service_home }}/.zshrc"
|
||||||
|
block: |
|
||||||
|
# ansible: linux_dev_station ssh-agent
|
||||||
|
export SSH_AUTH_SOCK="{{ linux_dev_station_ssh_agent_socket }}"
|
||||||
|
if [ -S "$SSH_AUTH_SOCK" ] && ssh-add -l &>/dev/null; then
|
||||||
|
# Agent socket exists and has keys — nothing to do
|
||||||
|
true
|
||||||
|
else
|
||||||
|
if [ -S "$SSH_AUTH_SOCK" ]; then
|
||||||
|
# Socket exists but agent is dead or has no keys — restart
|
||||||
|
rm -f "$SSH_AUTH_SOCK"
|
||||||
|
eval "$(ssh-agent -a "$SSH_AUTH_SOCK" 2>/dev/null)" || true
|
||||||
|
else
|
||||||
|
# No socket — start fresh agent
|
||||||
|
eval "$(ssh-agent -a "$SSH_AUTH_SOCK" 2>/dev/null)" || true
|
||||||
|
fi
|
||||||
|
if [ -f "{{ linux_dev_station_scripts_dir }}/ssh-agent-start.sh" ]; then
|
||||||
|
source "{{ linux_dev_station_scripts_dir }}/ssh-agent-start.sh"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
marker: "# {mark} ansible: linux_dev_station ssh-agent"
|
||||||
|
create: true
|
||||||
|
mode: "0644"
|
||||||
|
owner: "{{ linux_dev_station_service_user }}"
|
||||||
|
group: "{{ linux_dev_station_service_user }}"
|
||||||
|
when:
|
||||||
|
- linux_dev_station_service_user_enabled | bool
|
||||||
|
- linux_dev_station_ssh_agent_enabled | bool
|
||||||
|
|
||||||
|
- name: Install managed MCP server runtimes
|
||||||
|
ansible.builtin.include_tasks: mcp_installers.yml
|
||||||
|
when:
|
||||||
|
- linux_dev_station_service_user_enabled | bool
|
||||||
|
- linux_dev_station_mcp_installers_enabled | bool
|
||||||
|
- linux_dev_station_mcp_installers | length > 0
|
||||||
|
|
||||||
|
- name: Run MCP bootstrap commands
|
||||||
|
become: true
|
||||||
|
become_user: "{{ linux_dev_station_service_user }}"
|
||||||
|
ansible.builtin.shell: "{{ item }}"
|
||||||
|
register: __linux_dev_station_mcp_bootstrap_result
|
||||||
|
changed_when: __linux_dev_station_mcp_bootstrap_result.rc == 0
|
||||||
|
args:
|
||||||
|
executable: /bin/sh
|
||||||
|
environment:
|
||||||
|
HOME: "{{ linux_dev_station_service_home }}"
|
||||||
|
USER: "{{ linux_dev_station_service_user }}"
|
||||||
|
PATH: "{{ linux_dev_station_path }}"
|
||||||
|
loop: "{{ linux_dev_station_mcp_bootstrap_commands }}"
|
||||||
|
when:
|
||||||
|
- linux_dev_station_service_user_enabled | bool
|
||||||
|
- linux_dev_station_mcp_bootstrap_commands | length > 0
|
||||||
|
|
||||||
|
- name: List installed code-server extensions for service user
|
||||||
|
become: true
|
||||||
|
become_user: "{{ linux_dev_station_service_user }}"
|
||||||
|
ansible.builtin.command: code-server --list-extensions
|
||||||
|
register: __linux_dev_station_code_server_extensions_installed
|
||||||
|
changed_when: false
|
||||||
|
environment:
|
||||||
|
HOME: "{{ linux_dev_station_service_home }}"
|
||||||
|
USER: "{{ linux_dev_station_service_user }}"
|
||||||
|
PATH: "{{ linux_dev_station_path }}"
|
||||||
|
when:
|
||||||
|
- linux_dev_station_service_user_enabled | bool
|
||||||
|
- linux_dev_station_code_server_extensions_enabled | bool
|
||||||
|
- linux_dev_station_code_server_extensions | length > 0
|
||||||
|
|
||||||
|
- name: Install base code-server extensions for service user
|
||||||
|
become: true
|
||||||
|
become_user: "{{ linux_dev_station_service_user }}"
|
||||||
|
ansible.builtin.command: >-
|
||||||
|
code-server --install-extension {{ item }}
|
||||||
|
register: __linux_dev_station_code_server_extension_install
|
||||||
|
changed_when: __linux_dev_station_code_server_extension_install.rc == 0
|
||||||
|
failed_when: >-
|
||||||
|
(linux_dev_station_code_server_extensions_fail_on_error | bool)
|
||||||
|
and (__linux_dev_station_code_server_extension_install.rc != 0)
|
||||||
|
environment:
|
||||||
|
HOME: "{{ linux_dev_station_service_home }}"
|
||||||
|
USER: "{{ linux_dev_station_service_user }}"
|
||||||
|
PATH: "{{ linux_dev_station_path }}"
|
||||||
|
notify: Restart code-server
|
||||||
|
loop: "{{ linux_dev_station_code_server_extensions }}"
|
||||||
|
when:
|
||||||
|
- linux_dev_station_service_user_enabled | bool
|
||||||
|
- linux_dev_station_code_server_extensions_enabled | bool
|
||||||
|
- item not in (__linux_dev_station_code_server_extensions_installed.stdout_lines | default([]))
|
||||||
|
|
||||||
|
- name: Report skipped code-server extensions that are unavailable
|
||||||
|
ansible.builtin.debug:
|
||||||
|
msg: >-
|
||||||
|
Skipping unavailable code-server extension '{{ item.item }}':
|
||||||
|
{{ (item.stderr_lines | default([])) | join(' ') }}
|
||||||
|
loop: "{{ __linux_dev_station_code_server_extension_install.results | default([]) }}"
|
||||||
|
when:
|
||||||
|
- linux_dev_station_service_user_enabled | bool
|
||||||
|
- linux_dev_station_code_server_extensions_enabled | bool
|
||||||
|
- not (linux_dev_station_code_server_extensions_fail_on_error | bool)
|
||||||
|
- item.rc is defined
|
||||||
|
- item.rc != 0
|
||||||
|
|
||||||
|
- name: Copy Copilot installer script
|
||||||
|
become: true
|
||||||
|
ansible.builtin.copy:
|
||||||
|
src: install-copilot.sh
|
||||||
|
dest: "{{ linux_dev_station_service_home }}/.cache/install-copilot.sh"
|
||||||
|
owner: "{{ linux_dev_station_service_user }}"
|
||||||
|
group: "{{ linux_dev_station_service_user }}"
|
||||||
|
mode: "0755"
|
||||||
|
when:
|
||||||
|
- linux_dev_station_service_user_enabled | bool
|
||||||
|
- linux_dev_station_copilot_enabled | bool
|
||||||
|
|
||||||
|
- name: Install GitHub Copilot extensions via compatible VSIX
|
||||||
|
become: true
|
||||||
|
become_user: "{{ linux_dev_station_service_user }}"
|
||||||
|
ansible.builtin.command: "{{ linux_dev_station_service_home }}/.cache/install-copilot.sh"
|
||||||
|
register: __linux_dev_station_copilot_install_result
|
||||||
|
changed_when: __linux_dev_station_copilot_install_result.rc == 0
|
||||||
|
failed_when: >-
|
||||||
|
(linux_dev_station_copilot_fail_on_error | bool)
|
||||||
|
and (__linux_dev_station_copilot_install_result.rc != 0)
|
||||||
|
environment:
|
||||||
|
HOME: "{{ linux_dev_station_service_home }}"
|
||||||
|
USER: "{{ linux_dev_station_service_user }}"
|
||||||
|
PATH: "{{ linux_dev_station_path }}"
|
||||||
|
when:
|
||||||
|
- linux_dev_station_service_user_enabled | bool
|
||||||
|
- linux_dev_station_copilot_enabled | bool
|
||||||
|
|
||||||
|
- name: Ensure devstation SSH directory exists when SSH access is enabled
|
||||||
|
become: true
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ linux_dev_station_service_home }}/.ssh"
|
||||||
|
state: directory
|
||||||
|
owner: "{{ linux_dev_station_service_user }}"
|
||||||
|
group: "{{ linux_dev_station_service_user }}"
|
||||||
|
mode: "0700"
|
||||||
|
when:
|
||||||
|
- linux_dev_station_service_user_enabled | bool
|
||||||
|
- linux_dev_station_service_user_ssh_enabled | bool
|
||||||
|
|
||||||
|
- name: Ensure devstation authorized keys are present when SSH access is enabled
|
||||||
|
become: true
|
||||||
|
ansible.posix.authorized_key:
|
||||||
|
user: "{{ linux_dev_station_service_user }}"
|
||||||
|
key: "{{ item }}"
|
||||||
|
state: present
|
||||||
|
loop: "{{ linux_dev_station_service_user_ssh_authorized_keys }}"
|
||||||
|
when:
|
||||||
|
- linux_dev_station_service_user_enabled | bool
|
||||||
|
- linux_dev_station_service_user_ssh_enabled | bool
|
||||||
|
|
||||||
|
- name: Render ssh-agent starter script
|
||||||
|
become: true
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: ssh-agent-start.sh.j2
|
||||||
|
dest: "{{ linux_dev_station_scripts_dir }}/ssh-agent-start.sh"
|
||||||
|
mode: "0755"
|
||||||
|
owner: "{{ linux_dev_station_service_user }}"
|
||||||
|
group: "{{ linux_dev_station_service_user }}"
|
||||||
|
when:
|
||||||
|
- linux_dev_station_service_user_enabled | bool
|
||||||
|
- linux_dev_station_ssh_agent_enabled | bool
|
||||||
|
|
||||||
|
- name: Ensure ssh_config has ssh-agent settings for devstation
|
||||||
|
become: true
|
||||||
|
ansible.builtin.blockinfile:
|
||||||
|
path: "{{ linux_dev_station_service_home }}/.ssh/config"
|
||||||
|
block: |
|
||||||
|
Host *
|
||||||
|
AddKeysToAgent yes
|
||||||
|
ServerAliveInterval 60
|
||||||
|
ServerAliveCountMax 3
|
||||||
|
marker: "# {mark} ansible: linux_dev_station ssh-agent"
|
||||||
|
create: true
|
||||||
|
mode: "0600"
|
||||||
|
owner: "{{ linux_dev_station_service_user }}"
|
||||||
|
group: "{{ linux_dev_station_service_user }}"
|
||||||
|
when:
|
||||||
|
- linux_dev_station_service_user_enabled | bool
|
||||||
|
- linux_dev_station_ssh_agent_enabled | bool
|
||||||
|
|
||||||
|
- name: Generate SSH key if missing
|
||||||
|
ansible.builtin.command: >-
|
||||||
|
ssh-keygen -t ed25519 -C {{ linux_dev_station_ssh_key_comment }}
|
||||||
|
-f {{ linux_dev_station_ssh_key_path }} -N ''
|
||||||
|
args:
|
||||||
|
creates: "{{ linux_dev_station_ssh_key_path }}"
|
||||||
|
|
||||||
|
- name: Configure Git init default branch
|
||||||
|
community.general.git_config:
|
||||||
|
name: init.defaultBranch
|
||||||
|
scope: global
|
||||||
|
value: "{{ linux_dev_station_git_init_default_branch }}"
|
||||||
|
|
||||||
|
- name: Configure Git pull.rebase
|
||||||
|
community.general.git_config:
|
||||||
|
name: pull.rebase
|
||||||
|
scope: global
|
||||||
|
value: "{{ linux_dev_station_git_pull_rebase | ternary('true', 'false') }}"
|
||||||
|
|
||||||
|
- name: Render code-server launcher script
|
||||||
|
become: true
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: start-ide.sh.j2
|
||||||
|
dest: "{{ linux_dev_station_scripts_dir }}/start-ide.sh"
|
||||||
|
mode: "0755"
|
||||||
|
owner: "{{ linux_dev_station_service_user }}"
|
||||||
|
group: "{{ linux_dev_station_service_user }}"
|
||||||
|
|
||||||
|
- name: Render opencode launcher script
|
||||||
|
become: true
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: start-opencode.sh.j2
|
||||||
|
dest: "{{ linux_dev_station_scripts_dir }}/start-opencode.sh"
|
||||||
|
mode: "0755"
|
||||||
|
owner: "{{ linux_dev_station_service_user }}"
|
||||||
|
group: "{{ linux_dev_station_service_user }}"
|
||||||
|
|
||||||
|
- name: Render tmux configuration
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: "{{ ansible_facts['env']['HOME'] }}/.tmux.conf"
|
||||||
|
mode: "0644"
|
||||||
|
content: |
|
||||||
|
set -g mouse on
|
||||||
|
set -g history-limit 100000
|
||||||
|
|
||||||
|
- name: Render code-server systemd unit
|
||||||
|
become: true
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: code-server.service.j2
|
||||||
|
dest: "{{ linux_dev_station_systemd_unit_dir }}/code-server.service"
|
||||||
|
mode: "0644"
|
||||||
|
when: linux_dev_station_code_server_systemd_enabled | bool
|
||||||
|
notify: Restart code-server
|
||||||
|
|
||||||
|
- name: Render opencode systemd unit
|
||||||
|
become: true
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: opencode.service.j2
|
||||||
|
dest: "{{ linux_dev_station_systemd_unit_dir }}/opencode.service"
|
||||||
|
mode: "0644"
|
||||||
|
when: linux_dev_station_opencode_systemd_enabled | bool
|
||||||
|
notify: Restart opencode
|
||||||
|
|
||||||
|
- name: Ensure code-server service is started and enabled
|
||||||
|
become: true
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: code-server
|
||||||
|
state: started
|
||||||
|
enabled: true
|
||||||
|
daemon_reload: true
|
||||||
|
when:
|
||||||
|
- linux_dev_station_manage_systemd | bool
|
||||||
|
- linux_dev_station_code_server_systemd_enabled | bool
|
||||||
|
|
||||||
|
- name: Ensure opencode service is started and enabled
|
||||||
|
become: true
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: opencode
|
||||||
|
state: started
|
||||||
|
enabled: true
|
||||||
|
daemon_reload: true
|
||||||
|
when:
|
||||||
|
- linux_dev_station_manage_systemd | bool
|
||||||
|
- linux_dev_station_opencode_systemd_enabled | bool
|
||||||
|
|
||||||
|
- name: Ensure systemd services are started
|
||||||
|
ansible.builtin.meta: flush_handlers
|
||||||
|
when: linux_dev_station_manage_systemd | bool
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
---
|
||||||
|
- name: Ensure MCP installer definitions list is valid
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- linux_dev_station_mcp_installers is iterable
|
||||||
|
fail_msg: "linux_dev_station_mcp_installers must be a list of installer definitions."
|
||||||
|
|
||||||
|
- name: Validate MCP installer kind values
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- item.kind is defined
|
||||||
|
- item.kind in ['npm', 'uv', 'go']
|
||||||
|
fail_msg: >-
|
||||||
|
MCP installer '{{ item.name | default('unnamed') }}'
|
||||||
|
has unsupported kind '{{ item.kind | default('undefined') }}'.
|
||||||
|
Supported: npm, uv, go.
|
||||||
|
loop: "{{ linux_dev_station_mcp_installers }}"
|
||||||
|
when: item.enabled | default(true) | bool
|
||||||
|
|
||||||
|
- name: Validate npm MCP installers
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- item.package is defined
|
||||||
|
- item.package | length > 0
|
||||||
|
fail_msg: "npm MCP installer '{{ item.name | default('unnamed') }}' requires 'package'."
|
||||||
|
loop: "{{ linux_dev_station_mcp_installers }}"
|
||||||
|
when:
|
||||||
|
- item.enabled | default(true) | bool
|
||||||
|
- item.kind == 'npm'
|
||||||
|
|
||||||
|
- name: Validate uv MCP installers
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- item.package is defined
|
||||||
|
- item.package | length > 0
|
||||||
|
- item.binary is defined
|
||||||
|
- item.binary | length > 0
|
||||||
|
fail_msg: "uv MCP installer '{{ item.name | default('unnamed') }}' requires 'package' and 'binary'."
|
||||||
|
loop: "{{ linux_dev_station_mcp_installers }}"
|
||||||
|
when:
|
||||||
|
- item.enabled | default(true) | bool
|
||||||
|
- item.kind == 'uv'
|
||||||
|
|
||||||
|
- name: Validate go MCP installers
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- item.module is defined
|
||||||
|
- item.module | length > 0
|
||||||
|
- item.binary is defined
|
||||||
|
- item.binary | length > 0
|
||||||
|
fail_msg: "go MCP installer '{{ item.name | default('unnamed') }}' requires 'module' and 'binary'."
|
||||||
|
loop: "{{ linux_dev_station_mcp_installers }}"
|
||||||
|
when:
|
||||||
|
- item.enabled | default(true) | bool
|
||||||
|
- item.kind == 'go'
|
||||||
|
|
||||||
|
- name: Discover npm executable path for MCP installers
|
||||||
|
become: true
|
||||||
|
become_user: "{{ linux_dev_station_service_user }}"
|
||||||
|
ansible.builtin.command: /bin/sh -lc "command -v npm"
|
||||||
|
register: __linux_dev_station_mcp_npm_path
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
environment:
|
||||||
|
PATH: "{{ linux_dev_station_path }}"
|
||||||
|
when: (linux_dev_station_mcp_installers | selectattr('kind', 'equalto', 'npm') | list | length) > 0
|
||||||
|
|
||||||
|
- name: Install npm MCP packages for service user
|
||||||
|
become: true
|
||||||
|
become_user: "{{ linux_dev_station_service_user }}"
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv:
|
||||||
|
- "{{ __linux_dev_station_mcp_npm_path.stdout | trim }}"
|
||||||
|
- install
|
||||||
|
- --prefix
|
||||||
|
- "{{ linux_dev_station_service_home }}/.local"
|
||||||
|
- --global
|
||||||
|
- "{{ item.package }}@{{ item.version | default('latest') }}"
|
||||||
|
args:
|
||||||
|
creates: "{{ linux_dev_station_service_home }}/.local/lib/node_modules/{{ item.package }}"
|
||||||
|
register: __linux_dev_station_mcp_npm_install
|
||||||
|
changed_when: __linux_dev_station_mcp_npm_install.rc == 0
|
||||||
|
environment:
|
||||||
|
HOME: "{{ linux_dev_station_service_home }}"
|
||||||
|
USER: "{{ linux_dev_station_service_user }}"
|
||||||
|
PATH: "{{ linux_dev_station_path }}"
|
||||||
|
notify: Restart opencode
|
||||||
|
loop: "{{ linux_dev_station_mcp_installers }}"
|
||||||
|
when:
|
||||||
|
- item.enabled | default(true) | bool
|
||||||
|
- item.kind == 'npm'
|
||||||
|
- __linux_dev_station_mcp_npm_path.rc | default(1) == 0
|
||||||
|
|
||||||
|
- name: Install uv MCP tools for service user
|
||||||
|
become: true
|
||||||
|
become_user: "{{ linux_dev_station_service_user }}"
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv:
|
||||||
|
- uv
|
||||||
|
- tool
|
||||||
|
- install
|
||||||
|
- "{{ item.package }}@{{ item.version | default('latest') }}"
|
||||||
|
args:
|
||||||
|
creates: "{{ linux_dev_station_service_home }}/.local/bin/{{ item.binary }}"
|
||||||
|
register: __linux_dev_station_mcp_uv_install
|
||||||
|
changed_when: __linux_dev_station_mcp_uv_install.rc == 0
|
||||||
|
environment:
|
||||||
|
HOME: "{{ linux_dev_station_service_home }}"
|
||||||
|
USER: "{{ linux_dev_station_service_user }}"
|
||||||
|
PATH: "{{ linux_dev_station_path }}"
|
||||||
|
notify: Restart opencode
|
||||||
|
loop: "{{ linux_dev_station_mcp_installers }}"
|
||||||
|
when:
|
||||||
|
- item.enabled | default(true) | bool
|
||||||
|
- item.kind == 'uv'
|
||||||
|
|
||||||
|
- name: Install go MCP binaries for service user
|
||||||
|
become: true
|
||||||
|
become_user: "{{ linux_dev_station_service_user }}"
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv:
|
||||||
|
- go
|
||||||
|
- install
|
||||||
|
- "{{ item.module }}@{{ item.version | default('latest') }}"
|
||||||
|
args:
|
||||||
|
creates: "{{ linux_dev_station_service_home }}/.local/bin/{{ item.binary }}"
|
||||||
|
register: __linux_dev_station_mcp_go_install
|
||||||
|
changed_when: __linux_dev_station_mcp_go_install.rc == 0
|
||||||
|
environment:
|
||||||
|
HOME: "{{ linux_dev_station_service_home }}"
|
||||||
|
USER: "{{ linux_dev_station_service_user }}"
|
||||||
|
PATH: "{{ linux_dev_station_path }}"
|
||||||
|
GOBIN: "{{ linux_dev_station_service_home }}/.local/bin"
|
||||||
|
notify: Restart opencode
|
||||||
|
loop: "{{ linux_dev_station_mcp_installers }}"
|
||||||
|
when:
|
||||||
|
- item.enabled | default(true) | bool
|
||||||
|
- item.kind == 'go'
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
---
|
||||||
|
- name: Ensure tool packages list is valid
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- linux_dev_station_tool_packages is iterable
|
||||||
|
fail_msg: "linux_dev_station_tool_packages must be a list of tool definitions."
|
||||||
|
|
||||||
|
- name: Download uv installer
|
||||||
|
become: true
|
||||||
|
ansible.builtin.get_url:
|
||||||
|
url: https://astral.sh/uv/install.sh
|
||||||
|
dest: "{{ linux_dev_station_service_home }}/.cache/uv-install.sh"
|
||||||
|
owner: "{{ linux_dev_station_service_user }}"
|
||||||
|
group: "{{ linux_dev_station_service_user }}"
|
||||||
|
mode: "0755"
|
||||||
|
when:
|
||||||
|
- linux_dev_station_service_user_enabled | bool
|
||||||
|
- linux_dev_station_tool_packages | selectattr('name', 'equalto', 'uv') | list | length > 0
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
become: true
|
||||||
|
become_user: "{{ linux_dev_station_service_user }}"
|
||||||
|
ansible.builtin.command: >-
|
||||||
|
/bin/sh {{ linux_dev_station_service_home }}/.cache/uv-install.sh
|
||||||
|
args:
|
||||||
|
creates: "{{ linux_dev_station_service_home }}/.local/bin/uv"
|
||||||
|
environment:
|
||||||
|
HOME: "{{ linux_dev_station_service_home }}"
|
||||||
|
USER: "{{ linux_dev_station_service_user }}"
|
||||||
|
PATH: "{{ linux_dev_station_path }}"
|
||||||
|
UV_UNINSTALL_MISSING_PACKAGES: "1"
|
||||||
|
when:
|
||||||
|
- linux_dev_station_service_user_enabled | bool
|
||||||
|
- linux_dev_station_tool_packages | selectattr('name', 'equalto', 'uv') | list | length > 0
|
||||||
|
|
||||||
|
- name: Download rustup installer
|
||||||
|
become: true
|
||||||
|
ansible.builtin.get_url:
|
||||||
|
url: https://sh.rustup.rs
|
||||||
|
dest: "{{ linux_dev_station_service_home }}/.cache/rustup-install.sh"
|
||||||
|
owner: "{{ linux_dev_station_service_user }}"
|
||||||
|
group: "{{ linux_dev_station_service_user }}"
|
||||||
|
mode: "0755"
|
||||||
|
when:
|
||||||
|
- linux_dev_station_service_user_enabled | bool
|
||||||
|
- linux_dev_station_tool_packages | selectattr('name', 'equalto', 'rust') | list | length > 0
|
||||||
|
|
||||||
|
- name: Install rustup
|
||||||
|
become: true
|
||||||
|
become_user: "{{ linux_dev_station_service_user }}"
|
||||||
|
ansible.builtin.command: >-
|
||||||
|
/bin/sh {{ linux_dev_station_service_home }}/.cache/rustup-install.sh -y --default-toolchain stable
|
||||||
|
args:
|
||||||
|
creates: "{{ linux_dev_station_service_home }}/.cargo/bin/rustc"
|
||||||
|
environment:
|
||||||
|
HOME: "{{ linux_dev_station_service_home }}"
|
||||||
|
USER: "{{ linux_dev_station_service_user }}"
|
||||||
|
PATH: "{{ linux_dev_station_path }}:{{ linux_dev_station_service_home }}/.cargo/bin"
|
||||||
|
when:
|
||||||
|
- linux_dev_station_service_user_enabled | bool
|
||||||
|
- linux_dev_station_tool_packages | selectattr('name', 'equalto', 'rust') | list | length > 0
|
||||||
|
|
||||||
|
- name: Download fnm (Fast Node Manager) binary
|
||||||
|
become: true
|
||||||
|
ansible.builtin.get_url:
|
||||||
|
url: "https://github.com/Schniz/fnm/releases/download/{{ fnm_version }}/fnm-linux.zip"
|
||||||
|
dest: "{{ linux_dev_station_service_home }}/.cache/fnm.zip"
|
||||||
|
owner: "{{ linux_dev_station_service_user }}"
|
||||||
|
group: "{{ linux_dev_station_service_user }}"
|
||||||
|
mode: "0644"
|
||||||
|
vars:
|
||||||
|
fnm_version: v1.38.1
|
||||||
|
when:
|
||||||
|
- linux_dev_station_service_user_enabled | bool
|
||||||
|
- linux_dev_station_tool_packages | selectattr('name', 'equalto', 'node') | list | length > 0
|
||||||
|
|
||||||
|
- name: Create .fnm directory
|
||||||
|
become: true
|
||||||
|
become_user: "{{ linux_dev_station_service_user }}"
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ linux_dev_station_service_home }}/.fnm"
|
||||||
|
state: directory
|
||||||
|
owner: "{{ linux_dev_station_service_user }}"
|
||||||
|
group: "{{ linux_dev_station_service_user }}"
|
||||||
|
mode: "0755"
|
||||||
|
when:
|
||||||
|
- linux_dev_station_service_user_enabled | bool
|
||||||
|
- linux_dev_station_tool_packages | selectattr('name', 'equalto', 'node') | list | length > 0
|
||||||
|
|
||||||
|
- name: Extract fnm to .fnm directory
|
||||||
|
become: true
|
||||||
|
become_user: "{{ linux_dev_station_service_user }}"
|
||||||
|
ansible.builtin.unarchive:
|
||||||
|
src: "{{ linux_dev_station_service_home }}/.cache/fnm.zip"
|
||||||
|
dest: "{{ linux_dev_station_service_home }}/.fnm"
|
||||||
|
remote_src: true
|
||||||
|
when:
|
||||||
|
- linux_dev_station_service_user_enabled | bool
|
||||||
|
- linux_dev_station_tool_packages | selectattr('name', 'equalto', 'node') | list | length > 0
|
||||||
|
|
||||||
|
- name: Ensure fnm binary is executable
|
||||||
|
become: true
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ linux_dev_station_service_home }}/.fnm/fnm"
|
||||||
|
mode: "0755"
|
||||||
|
owner: "{{ linux_dev_station_service_user }}"
|
||||||
|
group: "{{ linux_dev_station_service_user }}"
|
||||||
|
when:
|
||||||
|
- linux_dev_station_service_user_enabled | bool
|
||||||
|
- linux_dev_station_tool_packages | selectattr('name', 'equalto', 'node') | list | length > 0
|
||||||
|
|
||||||
|
- name: Install LTS Node.js version via fnm
|
||||||
|
become: true
|
||||||
|
become_user: "{{ linux_dev_station_service_user }}"
|
||||||
|
ansible.builtin.command: >-
|
||||||
|
{{ linux_dev_station_service_home }}/.fnm/fnm install --lts
|
||||||
|
args:
|
||||||
|
creates: "{{ linux_dev_station_service_home }}/.fnm/node-versions"
|
||||||
|
environment:
|
||||||
|
HOME: "{{ linux_dev_station_service_home }}"
|
||||||
|
USER: "{{ linux_dev_station_service_user }}"
|
||||||
|
PATH: "{{ linux_dev_station_path }}:{{ linux_dev_station_service_home }}/.fnm"
|
||||||
|
FNM_DIR: "{{ linux_dev_station_service_home }}/.fnm"
|
||||||
|
when:
|
||||||
|
- linux_dev_station_service_user_enabled | bool
|
||||||
|
- linux_dev_station_tool_packages | selectattr('name', 'equalto', 'node') | list | length > 0
|
||||||
|
|
||||||
|
- name: Get installed Node.js version from fnm node-versions directory
|
||||||
|
become: true
|
||||||
|
become_user: "{{ linux_dev_station_service_user }}"
|
||||||
|
ansible.builtin.command: ls "{{ linux_dev_station_service_home }}/.fnm/node-versions"
|
||||||
|
register: fnm_versions_list
|
||||||
|
changed_when: false
|
||||||
|
environment:
|
||||||
|
FNM_DIR: "{{ linux_dev_station_service_home }}/.fnm"
|
||||||
|
when:
|
||||||
|
- linux_dev_station_service_user_enabled | bool
|
||||||
|
- linux_dev_station_tool_packages | selectattr('name', 'equalto', 'node') | list | length > 0
|
||||||
|
|
||||||
|
- name: Set default Node.js version via fnm
|
||||||
|
become: true
|
||||||
|
become_user: "{{ linux_dev_station_service_user }}"
|
||||||
|
ansible.builtin.command: >-
|
||||||
|
{{ linux_dev_station_service_home }}/.fnm/fnm default {{ node_version }}
|
||||||
|
register: linux_dev_station_fnm_default_result
|
||||||
|
changed_when: "'default' in linux_dev_station_fnm_default_result.stderr or 'default' in linux_dev_station_fnm_default_result.stdout"
|
||||||
|
environment:
|
||||||
|
HOME: "{{ linux_dev_station_service_home }}"
|
||||||
|
USER: "{{ linux_dev_station_service_user }}"
|
||||||
|
PATH: "{{ linux_dev_station_path }}:{{ linux_dev_station_service_home }}/.fnm"
|
||||||
|
FNM_DIR: "{{ linux_dev_station_service_home }}/.fnm"
|
||||||
|
when:
|
||||||
|
- linux_dev_station_service_user_enabled | bool
|
||||||
|
- linux_dev_station_tool_packages | selectattr('name', 'equalto', 'node') | list | length > 0
|
||||||
|
- fnm_versions_list is defined
|
||||||
|
- fnm_versions_list.stdout | trim | length > 0
|
||||||
|
vars:
|
||||||
|
node_version: "{{ fnm_versions_list.stdout | trim }}"
|
||||||
|
|
||||||
|
- name: Download code-server tarball
|
||||||
|
become: true
|
||||||
|
ansible.builtin.get_url:
|
||||||
|
url: "https://github.com/coder/code-server/releases/download/v{{ tool.version }}/code-server-{{ tool.version }}-linux-amd64.tar.gz"
|
||||||
|
dest: "{{ linux_dev_station_service_home }}/.cache/code-server.tar.gz"
|
||||||
|
owner: "{{ linux_dev_station_service_user }}"
|
||||||
|
group: "{{ linux_dev_station_service_user }}"
|
||||||
|
mode: "0644"
|
||||||
|
vars:
|
||||||
|
tool: "{{ linux_dev_station_tool_packages | selectattr('name', 'equalto', 'code-server') | first }}"
|
||||||
|
when:
|
||||||
|
- linux_dev_station_service_user_enabled | bool
|
||||||
|
- linux_dev_station_tool_packages | selectattr('name', 'equalto', 'code-server') | list | length > 0
|
||||||
|
|
||||||
|
- name: Create code-server installation directories
|
||||||
|
become: true
|
||||||
|
become_user: "{{ linux_dev_station_service_user }}"
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ item }}"
|
||||||
|
state: directory
|
||||||
|
mode: "0755"
|
||||||
|
loop:
|
||||||
|
- "{{ linux_dev_station_service_home }}/.local/lib"
|
||||||
|
- "{{ linux_dev_station_service_home }}/.local/bin"
|
||||||
|
when:
|
||||||
|
- linux_dev_station_service_user_enabled | bool
|
||||||
|
- linux_dev_station_tool_packages | selectattr('name', 'equalto', 'code-server') | list | length > 0
|
||||||
|
|
||||||
|
- name: Extract code-server tarball to lib directory
|
||||||
|
become: true
|
||||||
|
become_user: "{{ linux_dev_station_service_user }}"
|
||||||
|
ansible.builtin.unarchive:
|
||||||
|
src: "{{ linux_dev_station_service_home }}/.cache/code-server.tar.gz"
|
||||||
|
dest: "{{ linux_dev_station_service_home }}/.local/lib"
|
||||||
|
remote_src: true
|
||||||
|
environment:
|
||||||
|
HOME: "{{ linux_dev_station_service_home }}"
|
||||||
|
USER: "{{ linux_dev_station_service_user }}"
|
||||||
|
when:
|
||||||
|
- linux_dev_station_service_user_enabled | bool
|
||||||
|
- linux_dev_station_tool_packages | selectattr('name', 'equalto', 'code-server') | list | length > 0
|
||||||
|
|
||||||
|
- name: Create symlink for code-server binary
|
||||||
|
become: true
|
||||||
|
become_user: "{{ linux_dev_station_service_user }}"
|
||||||
|
ansible.builtin.file:
|
||||||
|
src: "{{ linux_dev_station_service_home }}/.local/lib/code-server-{{ tool.version }}-linux-amd64/bin/code-server"
|
||||||
|
dest: "{{ linux_dev_station_service_home }}/.local/bin/code-server"
|
||||||
|
state: link
|
||||||
|
vars:
|
||||||
|
tool: "{{ linux_dev_station_tool_packages | selectattr('name', 'equalto', 'code-server') | first }}"
|
||||||
|
when:
|
||||||
|
- linux_dev_station_service_user_enabled | bool
|
||||||
|
- linux_dev_station_tool_packages | selectattr('name', 'equalto', 'code-server') | list | length > 0
|
||||||
|
|
||||||
|
- name: Create opencode installation directories
|
||||||
|
become: true
|
||||||
|
become_user: "{{ linux_dev_station_service_user }}"
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ item }}"
|
||||||
|
state: directory
|
||||||
|
mode: "0755"
|
||||||
|
loop:
|
||||||
|
- "{{ linux_dev_station_service_home }}/.local/lib"
|
||||||
|
- "{{ linux_dev_station_service_home }}/.local/lib/opencode-{{ tool.version }}"
|
||||||
|
- "{{ linux_dev_station_service_home }}/.local/bin"
|
||||||
|
vars:
|
||||||
|
tool: "{{ linux_dev_station_tool_packages | selectattr('name', 'equalto', 'opencode') | first }}"
|
||||||
|
when:
|
||||||
|
- linux_dev_station_service_user_enabled | bool
|
||||||
|
- linux_dev_station_tool_packages | selectattr('name', 'equalto', 'opencode') | list | length > 0
|
||||||
|
|
||||||
|
- name: Download opencode tarball
|
||||||
|
become: true
|
||||||
|
ansible.builtin.get_url:
|
||||||
|
url: "https://github.com/anomalyco/opencode/releases/download/v{{ tool.version }}/opencode-linux-x64.tar.gz"
|
||||||
|
dest: "{{ linux_dev_station_service_home }}/.cache/opencode-{{ tool.version }}.tar.gz"
|
||||||
|
owner: "{{ linux_dev_station_service_user }}"
|
||||||
|
group: "{{ linux_dev_station_service_user }}"
|
||||||
|
mode: "0644"
|
||||||
|
vars:
|
||||||
|
tool: "{{ linux_dev_station_tool_packages | selectattr('name', 'equalto', 'opencode') | first }}"
|
||||||
|
when:
|
||||||
|
- linux_dev_station_service_user_enabled | bool
|
||||||
|
- linux_dev_station_tool_packages | selectattr('name', 'equalto', 'opencode') | list | length > 0
|
||||||
|
|
||||||
|
- name: Extract opencode tarball to versioned directory
|
||||||
|
become: true
|
||||||
|
become_user: "{{ linux_dev_station_service_user }}"
|
||||||
|
ansible.builtin.unarchive:
|
||||||
|
src: "{{ linux_dev_station_service_home }}/.cache/opencode-{{ tool.version }}.tar.gz"
|
||||||
|
dest: "{{ linux_dev_station_service_home }}/.local/lib/opencode-{{ tool.version }}"
|
||||||
|
remote_src: true
|
||||||
|
vars:
|
||||||
|
tool: "{{ linux_dev_station_tool_packages | selectattr('name', 'equalto', 'opencode') | first }}"
|
||||||
|
when:
|
||||||
|
- linux_dev_station_service_user_enabled | bool
|
||||||
|
- linux_dev_station_tool_packages | selectattr('name', 'equalto', 'opencode') | list | length > 0
|
||||||
|
|
||||||
|
- name: Create symlink for opencode binary
|
||||||
|
become: true
|
||||||
|
become_user: "{{ linux_dev_station_service_user }}"
|
||||||
|
ansible.builtin.file:
|
||||||
|
src: "{{ linux_dev_station_service_home }}/.local/lib/opencode-{{ tool.version }}/opencode"
|
||||||
|
dest: "{{ linux_dev_station_service_home }}/.local/bin/opencode"
|
||||||
|
state: link
|
||||||
|
vars:
|
||||||
|
tool: "{{ linux_dev_station_tool_packages | selectattr('name', 'equalto', 'opencode') | first }}"
|
||||||
|
when:
|
||||||
|
- linux_dev_station_service_user_enabled | bool
|
||||||
|
- linux_dev_station_tool_packages | selectattr('name', 'equalto', 'opencode') | list | length > 0
|
||||||
|
|
||||||
|
- name: Download sectool zip
|
||||||
|
become: true
|
||||||
|
ansible.builtin.get_url:
|
||||||
|
url: "https://github.com/a13labs/sectool/releases/download/v{{ tool.version }}/sectool-v{{ tool.version }}-linux-x64.zip"
|
||||||
|
dest: "{{ linux_dev_station_service_home }}/.cache/sectool-{{ tool.version }}.zip"
|
||||||
|
owner: "{{ linux_dev_station_service_user }}"
|
||||||
|
group: "{{ linux_dev_station_service_user }}"
|
||||||
|
mode: "0644"
|
||||||
|
vars:
|
||||||
|
tool: "{{ linux_dev_station_tool_packages | selectattr('name', 'equalto', 'sectool') | first }}"
|
||||||
|
when:
|
||||||
|
- linux_dev_station_service_user_enabled | bool
|
||||||
|
- linux_dev_station_tool_packages | selectattr('name', 'equalto', 'sectool') | list | length > 0
|
||||||
|
|
||||||
|
- name: Create sectool installation directories
|
||||||
|
become: true
|
||||||
|
become_user: "{{ linux_dev_station_service_user }}"
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ item }}"
|
||||||
|
state: directory
|
||||||
|
mode: "0755"
|
||||||
|
loop:
|
||||||
|
- "{{ linux_dev_station_service_home }}/.local/lib/sectool-{{ tool.version }}"
|
||||||
|
- "{{ linux_dev_station_service_home }}/.local/bin"
|
||||||
|
vars:
|
||||||
|
tool: "{{ linux_dev_station_tool_packages | selectattr('name', 'equalto', 'sectool') | first }}"
|
||||||
|
when:
|
||||||
|
- linux_dev_station_service_user_enabled | bool
|
||||||
|
- linux_dev_station_tool_packages | selectattr('name', 'equalto', 'sectool') | list | length > 0
|
||||||
|
|
||||||
|
- name: Extract sectool zip to versioned directory
|
||||||
|
become: true
|
||||||
|
become_user: "{{ linux_dev_station_service_user }}"
|
||||||
|
ansible.builtin.unarchive:
|
||||||
|
src: "{{ linux_dev_station_service_home }}/.cache/sectool-{{ tool.version }}.zip"
|
||||||
|
dest: "{{ linux_dev_station_service_home }}/.local/lib/sectool-{{ tool.version }}"
|
||||||
|
remote_src: true
|
||||||
|
vars:
|
||||||
|
tool: "{{ linux_dev_station_tool_packages | selectattr('name', 'equalto', 'sectool') | first }}"
|
||||||
|
when:
|
||||||
|
- linux_dev_station_service_user_enabled | bool
|
||||||
|
- linux_dev_station_tool_packages | selectattr('name', 'equalto', 'sectool') | list | length > 0
|
||||||
|
|
||||||
|
- name: Ensure sectool binary is executable
|
||||||
|
become: true
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ linux_dev_station_service_home }}/.local/lib/sectool-{{ tool.version }}/sectool"
|
||||||
|
mode: "0755"
|
||||||
|
owner: "{{ linux_dev_station_service_user }}"
|
||||||
|
group: "{{ linux_dev_station_service_user }}"
|
||||||
|
vars:
|
||||||
|
tool: "{{ linux_dev_station_tool_packages | selectattr('name', 'equalto', 'sectool') | first }}"
|
||||||
|
when:
|
||||||
|
- linux_dev_station_service_user_enabled | bool
|
||||||
|
- linux_dev_station_tool_packages | selectattr('name', 'equalto', 'sectool') | list | length > 0
|
||||||
|
|
||||||
|
- name: Create symlink for sectool binary
|
||||||
|
become: true
|
||||||
|
become_user: "{{ linux_dev_station_service_user }}"
|
||||||
|
ansible.builtin.file:
|
||||||
|
src: "{{ linux_dev_station_service_home }}/.local/lib/sectool-{{ tool.version }}/sectool"
|
||||||
|
dest: "{{ linux_dev_station_service_home }}/.local/bin/sectool"
|
||||||
|
state: link
|
||||||
|
vars:
|
||||||
|
tool: "{{ linux_dev_station_tool_packages | selectattr('name', 'equalto', 'sectool') | first }}"
|
||||||
|
when:
|
||||||
|
- linux_dev_station_service_user_enabled | bool
|
||||||
|
- linux_dev_station_tool_packages | selectattr('name', 'equalto', 'sectool') | list | length > 0
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=code-server - VS Code on remote Linux
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User={{ linux_dev_station_service_user }}
|
||||||
|
Group={{ linux_dev_station_service_user }}
|
||||||
|
WorkingDirectory={{ linux_dev_station_workspace_dir }}
|
||||||
|
ExecStart=/bin/zsh -i {{ linux_dev_station_scripts_dir }}/start-ide.sh
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
StandardOutput=append:{{ linux_dev_station_logs_dir }}/code-server.log
|
||||||
|
StandardError=append:{{ linux_dev_station_logs_dir }}/code-server.err.log
|
||||||
|
|
||||||
|
Environment="HOME={{ linux_dev_station_service_home }}"
|
||||||
|
Environment="PATH={{ linux_dev_station_path }}"
|
||||||
|
Environment="TMPDIR={{ linux_dev_station_service_home }}/.cache/tmp"
|
||||||
|
{% if linux_dev_station_ssh_agent_enabled | bool %}
|
||||||
|
Environment="SSH_AUTH_SOCK={{ linux_dev_station_ssh_agent_socket }}"
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{{ linux_dev_station_opencode_config | to_nice_json }}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=OpenCode - AI-powered terminal IDE
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User={{ linux_dev_station_service_user }}
|
||||||
|
Group={{ linux_dev_station_service_user }}
|
||||||
|
WorkingDirectory={{ linux_dev_station_workspace_dir }}
|
||||||
|
ExecStart=/bin/zsh -i {{ linux_dev_station_scripts_dir }}/start-opencode.sh
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
StandardOutput=append:{{ linux_dev_station_logs_dir }}/opencode.log
|
||||||
|
StandardError=append:{{ linux_dev_station_logs_dir }}/opencode.err.log
|
||||||
|
|
||||||
|
Environment="HOME={{ linux_dev_station_service_home }}"
|
||||||
|
Environment="PATH={{ linux_dev_station_path }}"
|
||||||
|
Environment="TMPDIR={{ linux_dev_station_service_home }}/.cache/tmp"
|
||||||
|
{% for env_key, env_value in linux_dev_station_opencode_extra_environment.items() %}
|
||||||
|
Environment="{{ env_key }}={{ env_value }}"
|
||||||
|
{% endfor %}
|
||||||
|
{% if linux_dev_station_ssh_agent_enabled | bool %}
|
||||||
|
Environment="SSH_AUTH_SOCK={{ linux_dev_station_ssh_agent_socket }}"
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# SSH agent starter — idempotent, safe to source from concurrent sessions.
|
||||||
|
# Starts ssh-agent if not already running, then adds keys.
|
||||||
|
|
||||||
|
export SSH_AUTH_SOCK="{{ linux_dev_station_ssh_agent_socket }}"
|
||||||
|
|
||||||
|
# Start ssh-agent if socket doesn't exist
|
||||||
|
if [ ! -S "$SSH_AUTH_SOCK" ]; then
|
||||||
|
eval "$(/usr/bin/ssh-agent -a "{{ linux_dev_station_ssh_agent_socket }}" -s)" > /dev/null 2>&1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Wait for the agent socket to appear
|
||||||
|
__wait=0
|
||||||
|
while [ ! -S "$SSH_AUTH_SOCK" ] && [ "$__wait" -lt 15 ]; do
|
||||||
|
sleep 1
|
||||||
|
__wait=$(( __wait + 1 ))
|
||||||
|
done
|
||||||
|
|
||||||
|
# Add keys if the agent is running but has none loaded
|
||||||
|
if [ -S "$SSH_AUTH_SOCK" ]; then
|
||||||
|
if ssh-add -l 2>/dev/null | grep -q "The agent has no identities"; then
|
||||||
|
for _key in {{ linux_dev_station_ssh_agent_keys | map('quote') | join(' ') }}; do
|
||||||
|
if [ -f "$_key" ]; then
|
||||||
|
if [ -n "{{ linux_dev_station_ssh_agent_passphrase }}" ]; then
|
||||||
|
printf '%s\n' "{{ linux_dev_station_ssh_agent_passphrase }}" | ssh-add "$_key" 2>/dev/null || true
|
||||||
|
else
|
||||||
|
ssh-add "$_key" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
fi
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=SSH Agent for devstation
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User={{ linux_dev_station_service_user }}
|
||||||
|
Group={{ linux_dev_station_service_user }}
|
||||||
|
ExecStart=/usr/bin/ssh-agent -a {{ linux_dev_station_ssh_agent_socket }}
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
StandardOutput=append:{{ linux_dev_station_logs_dir }}/ssh-agent.log
|
||||||
|
StandardError=append:{{ linux_dev_station_logs_dir }}/ssh-agent.err.log
|
||||||
|
|
||||||
|
Environment="HOME={{ linux_dev_station_service_home }}"
|
||||||
|
Environment="PATH={{ linux_dev_station_path }}"
|
||||||
|
Environment="SSH_AUTH_SOCK={{ linux_dev_station_ssh_agent_socket }}"
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env zsh
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Suppress ZLE warnings from Oh My Zsh (no TTY in systemd)
|
||||||
|
setopt NOZLE 2>/dev/null || true
|
||||||
|
|
||||||
|
# Override TMPDIR — /tmp is noexec on Fedora, Bun needs an executable temp
|
||||||
|
export TMPDIR="{{ linux_dev_station_service_home }}/.cache/tmp"
|
||||||
|
mkdir -p "$TMPDIR"
|
||||||
|
# Clean stale files older than 7 days
|
||||||
|
find "$TMPDIR" -mindepth 1 -atime +7 -delete 2>/dev/null || true
|
||||||
|
|
||||||
|
{% if linux_dev_station_ssh_agent_enabled | bool %}
|
||||||
|
source "{{ linux_dev_station_scripts_dir }}/ssh-agent-start.sh"
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
cd {{ linux_dev_station_workspace_dir }}
|
||||||
|
|
||||||
|
exec code-server \
|
||||||
|
--bind-addr {{ linux_dev_station_code_server_host }}:{{ linux_dev_station_code_server_port }} \
|
||||||
|
--auth {{ linux_dev_station_code_server_auth }} \
|
||||||
|
--disable-telemetry \
|
||||||
|
{{ linux_dev_station_code_server_workspace }}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env zsh
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Suppress ZLE warnings from Oh My Zsh (no TTY in systemd)
|
||||||
|
setopt NOZLE 2>/dev/null || true
|
||||||
|
|
||||||
|
# Override TMPDIR — /tmp is noexec on Fedora, Bun needs an executable temp
|
||||||
|
export TMPDIR="{{ linux_dev_station_service_home }}/.cache/tmp"
|
||||||
|
mkdir -p "$TMPDIR"
|
||||||
|
# Clean stale files older than 7 days
|
||||||
|
find "$TMPDIR" -mindepth 1 -atime +7 -delete 2>/dev/null || true
|
||||||
|
|
||||||
|
{% if linux_dev_station_ssh_agent_enabled | bool %}
|
||||||
|
source "{{ linux_dev_station_scripts_dir }}/ssh-agent-start.sh"
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
cd {{ linux_dev_station_workspace_dir }}
|
||||||
|
|
||||||
|
exec {{ linux_dev_station_opencode_command }}
|
||||||
@@ -10,6 +10,7 @@ llama_server_extra_groups: "users,video"
|
|||||||
llama_server_devices: []
|
llama_server_devices: []
|
||||||
llama_server_env: {}
|
llama_server_env: {}
|
||||||
llama_server_port: 11434
|
llama_server_port: 11434
|
||||||
|
llama_server_bind: "0.0.0.0"
|
||||||
llama_server_preset_global: {}
|
llama_server_preset_global: {}
|
||||||
llama_server_host: "127.0.0.1"
|
llama_server_host: "127.0.0.1"
|
||||||
llama_server_argv_extra: []
|
llama_server_argv_extra: []
|
||||||
|
|||||||
@@ -120,6 +120,8 @@ def _normalize_preset_options(options: dict[str, Any], scope: str) -> dict[str,
|
|||||||
normalized: dict[str, str] = {}
|
normalized: dict[str, str] = {}
|
||||||
for key, value in options.items():
|
for key, value in options.items():
|
||||||
key_str = str(key).strip()
|
key_str = str(key).strip()
|
||||||
|
# convert _ to - for compatibility with llama.cpp router preset format
|
||||||
|
key_str = key_str.replace("_", "-")
|
||||||
if not key_str:
|
if not key_str:
|
||||||
raise RuntimeError(f"{scope} preset option keys must be non-empty")
|
raise RuntimeError(f"{scope} preset option keys must be non-empty")
|
||||||
if value is None:
|
if value is None:
|
||||||
|
|||||||
@@ -135,7 +135,7 @@
|
|||||||
state: started
|
state: started
|
||||||
device: "{{ llama_server_devices | default(omit) }}"
|
device: "{{ llama_server_devices | default(omit) }}"
|
||||||
env: "{{ llama_server_env | default(omit) }}"
|
env: "{{ llama_server_env | default(omit) }}"
|
||||||
ports: "0.0.0.0:{{ llama_server_port }}:8080/tcp"
|
ports: "{{ llama_server_bind }}:{{ llama_server_port }}:8080/tcp"
|
||||||
volumes:
|
volumes:
|
||||||
- "{{ llama_server_home }}/models:/models:Z"
|
- "{{ llama_server_home }}/models:/models:Z"
|
||||||
- "{{ llama_server_home }}/.cache:/app/.cache:Z"
|
- "{{ llama_server_home }}/.cache:/app/.cache:Z"
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
---
|
---
|
||||||
login_users: []
|
login_users: []
|
||||||
|
login_disable_history: true
|
||||||
@@ -61,3 +61,4 @@
|
|||||||
owner: root
|
owner: root
|
||||||
group: root
|
group: root
|
||||||
mode: "0755"
|
mode: "0755"
|
||||||
|
when: login_disable_history
|
||||||
|
|||||||
@@ -31,8 +31,33 @@ macos_dev_station_code_server_extensions:
|
|||||||
- redhat.ansible
|
- redhat.ansible
|
||||||
- eamodio.gitlens
|
- eamodio.gitlens
|
||||||
|
|
||||||
|
macos_dev_station_copilot_enabled: false
|
||||||
|
macos_dev_station_copilot_extensions:
|
||||||
|
- GitHub.copilot
|
||||||
|
- GitHub.copilot-chat
|
||||||
|
macos_dev_station_copilot_fail_on_error: false
|
||||||
|
|
||||||
macos_dev_station_oh_my_zsh_enabled: true
|
macos_dev_station_oh_my_zsh_enabled: true
|
||||||
macos_dev_station_oh_my_zsh_install_url: "https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh"
|
macos_dev_station_oh_my_zsh_install_url: "https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh"
|
||||||
|
macos_dev_station_oh_my_zsh_theme: "agnoster"
|
||||||
|
macos_dev_station_oh_my_zsh_plugins:
|
||||||
|
- git
|
||||||
|
- fzf
|
||||||
|
- common-aliases
|
||||||
|
- sudo
|
||||||
|
- tmux
|
||||||
|
- extract
|
||||||
|
- colored-man-pages
|
||||||
|
- history-substring-search
|
||||||
|
- kubectl
|
||||||
|
- docker
|
||||||
|
- docker-compose
|
||||||
|
- ansible
|
||||||
|
- aws
|
||||||
|
- golang
|
||||||
|
- brew
|
||||||
|
- bgnotify
|
||||||
|
macos_dev_station_zshrc_extra_env: {}
|
||||||
|
|
||||||
macos_dev_station_colima_enabled: true
|
macos_dev_station_colima_enabled: true
|
||||||
macos_dev_station_colima_cpu: 4
|
macos_dev_station_colima_cpu: 4
|
||||||
|
|||||||
+164
@@ -0,0 +1,164 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ansible: macos_dev_station install-copilot.sh
|
||||||
|
# Installs GitHub Copilot and Copilot Chat extensions into code-server
|
||||||
|
# with engine-compatible versions resolved from the VS Marketplace API.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
get_engine_version() {
|
||||||
|
local version_output
|
||||||
|
version_output=$(code-server --version | head -n1)
|
||||||
|
if echo "$version_output" | grep -q "with Code"; then
|
||||||
|
echo "$version_output" | sed -n 's/.*with Code \([0-9.]*\).*/\1/p'
|
||||||
|
else
|
||||||
|
echo "$version_output" | awk '{print $1}'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
get_user_data_dir() {
|
||||||
|
local process_info
|
||||||
|
process_info=$(ps aux 2>/dev/null | grep -v grep | grep "code-server" | head -n 1) ||
|
||||||
|
process_info=$(ps -ef 2>/dev/null | grep -v grep | grep "code-server" | head -n 1)
|
||||||
|
if [ -n "$process_info" ]; then
|
||||||
|
local dir
|
||||||
|
dir=$(echo "$process_info" | grep -o -- '--user-data-dir=[^ ]*' | sed 's/--user-data-dir=//')
|
||||||
|
if [ -n "$dir" ]; then
|
||||||
|
echo "$dir"; return
|
||||||
|
fi
|
||||||
|
dir=$(echo "$process_info" | grep -o -- '--user-data-dir [^ ]*' | sed 's/--user-data-dir //')
|
||||||
|
if [ -n "$dir" ]; then
|
||||||
|
echo "$dir"; return
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
find_compatible_version() {
|
||||||
|
local extension_id="$1"
|
||||||
|
local engine_version="$2"
|
||||||
|
local response
|
||||||
|
response=$(curl -s -X POST "https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Accept: application/json;api-version=3.0-preview.1" \
|
||||||
|
-d "{
|
||||||
|
\"filters\": [{
|
||||||
|
\"criteria\": [
|
||||||
|
{\"filterType\": 7, \"value\": \"$extension_id\"},
|
||||||
|
{\"filterType\": 12, \"value\": \"4096\"}
|
||||||
|
],
|
||||||
|
\"pageSize\": 50
|
||||||
|
}],
|
||||||
|
\"flags\": 4112
|
||||||
|
}")
|
||||||
|
echo "$response" | jq -r --arg engine_version "$engine_version" '
|
||||||
|
.results[0].extensions[0].versions[] |
|
||||||
|
select(.version | test("^[0-9]+\\.[0-9]+\\.[0-9]*$")) |
|
||||||
|
select(all(.properties[]; .key != "Microsoft.VisualStudio.Code.PreRelease" or .value != "true")) |
|
||||||
|
{
|
||||||
|
version: .version,
|
||||||
|
engine: (.properties[] | select(.key == "Microsoft.VisualStudio.Code.Engine") | .value)
|
||||||
|
} |
|
||||||
|
select(.engine | ltrimstr("^") | split(".") |
|
||||||
|
map(split("-")[0] | tonumber? // 0) as $engine_parts |
|
||||||
|
($engine_version | split(".") | map(split("-")[0] | tonumber? // 0)) as $server_parts |
|
||||||
|
(
|
||||||
|
($engine_parts[0] // 0) < $server_parts[0] or
|
||||||
|
(($engine_parts[0] // 0) == $server_parts[0] and ($engine_parts[1] // 0) < $server_parts[1]) or
|
||||||
|
(($engine_parts[0] // 0) == $server_parts[0] and ($engine_parts[1] // 0) == $server_parts[1] and ($engine_parts[2] // 0) <= $server_parts[2])
|
||||||
|
)
|
||||||
|
) |
|
||||||
|
.version' | head -n 1
|
||||||
|
}
|
||||||
|
|
||||||
|
install_extension() {
|
||||||
|
local extension_id="$1"
|
||||||
|
local version="$2"
|
||||||
|
local user_data_dir="$3"
|
||||||
|
local extension_name
|
||||||
|
extension_name=$(echo "$extension_id" | cut -d'.' -f2)
|
||||||
|
local temp_dir="/tmp/code-extensions"
|
||||||
|
echo "Installing $extension_id v$version..."
|
||||||
|
mkdir -p "$temp_dir"
|
||||||
|
echo " Downloading..."
|
||||||
|
curl -L "https://marketplace.visualstudio.com/_apis/public/gallery/publishers/GitHub/vsextensions/$extension_name/$version/vspackage" \
|
||||||
|
-o "$temp_dir/$extension_name.vsix.gz"
|
||||||
|
if [ ! -f "$temp_dir/$extension_name.vsix.gz" ]; then
|
||||||
|
echo " [SKIP] Download failed for $extension_id"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if command -v gunzip >/dev/null 2>&1; then
|
||||||
|
gunzip -f "$temp_dir/$extension_name.vsix.gz"
|
||||||
|
else
|
||||||
|
gzip -df "$temp_dir/$extension_name.vsix.gz"
|
||||||
|
fi
|
||||||
|
if [ -n "$user_data_dir" ]; then
|
||||||
|
code-server --user-data-dir="$user_data_dir" --force --install-extension "$temp_dir/$extension_name.vsix"
|
||||||
|
else
|
||||||
|
code-server --force --install-extension "$temp_dir/$extension_name.vsix"
|
||||||
|
fi
|
||||||
|
rm -f "$temp_dir/$extension_name.vsix"
|
||||||
|
echo " [OK] $extension_id installed successfully!"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
check_dependencies() {
|
||||||
|
local missing_deps=()
|
||||||
|
for cmd in curl jq code-server; do
|
||||||
|
if ! command -v "$cmd" >/dev/null 2>&1; then
|
||||||
|
missing_deps+=("$cmd")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if ! command -v gunzip >/dev/null 2>&1 && ! command -v gzip >/dev/null 2>&1; then
|
||||||
|
missing_deps+=("gunzip/gzip")
|
||||||
|
fi
|
||||||
|
if [ "${#missing_deps[@]}" -gt 0 ]; then
|
||||||
|
echo "Error: Missing required dependencies: ${missing_deps[*]}"
|
||||||
|
echo "Please install the missing dependencies and try again."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "========================================"
|
||||||
|
echo " GitHub Copilot Extensions Installer"
|
||||||
|
echo "========================================"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
check_dependencies
|
||||||
|
|
||||||
|
ENGINE_VERSION="$(get_engine_version)"
|
||||||
|
if [ -z "$ENGINE_VERSION" ]; then
|
||||||
|
echo "Error: Could not extract engine version from code-server"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Detected code-server engine version: $ENGINE_VERSION"
|
||||||
|
|
||||||
|
USER_DATA_DIR="$(get_user_data_dir)"
|
||||||
|
if [ -n "$USER_DATA_DIR" ]; then
|
||||||
|
echo "Detected user-data-dir: $USER_DATA_DIR"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
EXTENSIONS="GitHub.copilot GitHub.copilot-chat"
|
||||||
|
FAILED=0
|
||||||
|
|
||||||
|
for ext in $EXTENSIONS; do
|
||||||
|
echo "Processing $ext..."
|
||||||
|
version="$(find_compatible_version "$ext" "$ENGINE_VERSION")"
|
||||||
|
if [ -z "$version" ]; then
|
||||||
|
echo " [SKIP] No compatible version found for $ext"
|
||||||
|
FAILED="$((FAILED + 1))"
|
||||||
|
else
|
||||||
|
echo " Found compatible version: $version"
|
||||||
|
if ! install_extension "$ext" "$version" "$USER_DATA_DIR"; then
|
||||||
|
FAILED="$((FAILED + 1))"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "========================================"
|
||||||
|
if [ $FAILED -eq 0 ]; then
|
||||||
|
echo "[OK] All extensions installed successfully!"
|
||||||
|
rm -rf /tmp/code-extensions
|
||||||
|
else
|
||||||
|
echo "[WARN] Completed with $FAILED error(s)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -165,6 +165,43 @@
|
|||||||
- macos_dev_station_service_user_enabled | bool
|
- macos_dev_station_service_user_enabled | bool
|
||||||
- macos_dev_station_oh_my_zsh_enabled | bool
|
- macos_dev_station_oh_my_zsh_enabled | bool
|
||||||
|
|
||||||
|
- name: Ensure .zshrc sources Oh My Zsh
|
||||||
|
become: true
|
||||||
|
ansible.builtin.blockinfile:
|
||||||
|
path: "{{ macos_dev_station_service_home }}/.zshrc"
|
||||||
|
block: |
|
||||||
|
# ansible: macos_dev_station oh-my-zsh
|
||||||
|
export ZSH="$HOME/.oh-my-zsh"
|
||||||
|
ZSH_THEME="{{ macos_dev_station_oh_my_zsh_theme }}"
|
||||||
|
plugins=({{ macos_dev_station_oh_my_zsh_plugins | join(' ') }})
|
||||||
|
source "$ZSH/oh-my-zsh.sh"
|
||||||
|
marker: "# {mark} ansible: macos_dev_station oh-my-zsh"
|
||||||
|
create: true
|
||||||
|
mode: "0644"
|
||||||
|
owner: "{{ macos_dev_station_service_user }}"
|
||||||
|
group: "{{ macos_dev_station_service_group }}"
|
||||||
|
when:
|
||||||
|
- macos_dev_station_service_user_enabled | bool
|
||||||
|
- macos_dev_station_oh_my_zsh_enabled | bool
|
||||||
|
|
||||||
|
- name: Ensure .zshrc has extra environment variables
|
||||||
|
become: true
|
||||||
|
ansible.builtin.blockinfile:
|
||||||
|
path: "{{ macos_dev_station_service_home }}/.zshrc"
|
||||||
|
block: |
|
||||||
|
# ansible: macos_dev_station extra env vars
|
||||||
|
{% for env_key, env_value in macos_dev_station_zshrc_extra_env.items() %}
|
||||||
|
export {{ env_key }}="{{ env_value }}"
|
||||||
|
{% endfor %}
|
||||||
|
marker: "# {mark} ansible: macos_dev_station extra env vars"
|
||||||
|
create: true
|
||||||
|
mode: "0644"
|
||||||
|
owner: "{{ macos_dev_station_service_user }}"
|
||||||
|
group: "{{ macos_dev_station_service_group }}"
|
||||||
|
when:
|
||||||
|
- macos_dev_station_service_user_enabled | bool
|
||||||
|
- macos_dev_station_zshrc_extra_env | length > 0
|
||||||
|
|
||||||
- name: Ensure .zshrc has ssh-agent startup for devstation
|
- name: Ensure .zshrc has ssh-agent startup for devstation
|
||||||
become: true
|
become: true
|
||||||
ansible.builtin.blockinfile:
|
ansible.builtin.blockinfile:
|
||||||
@@ -270,6 +307,35 @@
|
|||||||
- item.rc is defined
|
- item.rc is defined
|
||||||
- item.rc != 0
|
- item.rc != 0
|
||||||
|
|
||||||
|
- name: Copy Copilot installer script
|
||||||
|
become: true
|
||||||
|
ansible.builtin.copy:
|
||||||
|
src: install-copilot.sh
|
||||||
|
dest: "{{ macos_dev_station_service_home }}/.cache/install-copilot.sh"
|
||||||
|
owner: "{{ macos_dev_station_service_user }}"
|
||||||
|
group: "{{ macos_dev_station_service_group }}"
|
||||||
|
mode: "0755"
|
||||||
|
when:
|
||||||
|
- macos_dev_station_service_user_enabled | bool
|
||||||
|
- macos_dev_station_copilot_enabled | bool
|
||||||
|
|
||||||
|
- name: Install GitHub Copilot extensions via compatible VSIX
|
||||||
|
become: true
|
||||||
|
become_user: "{{ macos_dev_station_service_user }}"
|
||||||
|
ansible.builtin.command: "{{ macos_dev_station_service_home }}/.cache/install-copilot.sh"
|
||||||
|
register: __copilot_install_result
|
||||||
|
changed_when: __copilot_install_result.rc == 0
|
||||||
|
failed_when: >-
|
||||||
|
(macos_dev_station_copilot_fail_on_error | bool)
|
||||||
|
and (__copilot_install_result.rc != 0)
|
||||||
|
environment:
|
||||||
|
HOME: "{{ macos_dev_station_service_home }}"
|
||||||
|
USER: "{{ macos_dev_station_service_user }}"
|
||||||
|
PATH: "{{ macos_dev_station_path }}"
|
||||||
|
when:
|
||||||
|
- macos_dev_station_service_user_enabled | bool
|
||||||
|
- macos_dev_station_copilot_enabled | bool
|
||||||
|
|
||||||
- name: Ensure devstation SSH directory exists when SSH access is enabled
|
- name: Ensure devstation SSH directory exists when SSH access is enabled
|
||||||
become: true
|
become: true
|
||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
nginx_proxy_certbot_enabled: true
|
||||||
|
nginx_proxy_certbot_email: ""
|
||||||
|
nginx_proxy_certbot_cron_minute: "0"
|
||||||
|
nginx_proxy_certbot_cron_hour: "3"
|
||||||
|
nginx_proxy_certbot_credential_file: "/etc/letsencrypt/cloudns.ini"
|
||||||
|
nginx_proxy_certbot_credential_mode: "0600"
|
||||||
|
|
||||||
|
nginx_proxy_nginx_enabled: true
|
||||||
|
nginx_proxy_nginx_bind: "0.0.0.0"
|
||||||
|
nginx_proxy_nginx_port: 443
|
||||||
|
nginx_proxy_nginx_worker_connections: 1024
|
||||||
|
nginx_proxy_nginx_access_log: "/var/log/nginx/access.log"
|
||||||
|
nginx_proxy_nginx_error_log: "/var/log/nginx/error.log warn"
|
||||||
|
nginx_proxy_nginx_ssl_protocols: "TLSv1.2 TLSv1.3"
|
||||||
|
nginx_proxy_nginx_ssl_ciphers: ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384
|
||||||
|
nginx_proxy_nginx_ssl_prefer_server_ciphers: "on"
|
||||||
|
nginx_proxy_nginx_hsts_max_age: "31536000"
|
||||||
|
nginx_proxy_nginx_hsts_include_subdomains: true
|
||||||
|
|
||||||
|
nginx_proxy_oauth2_proxy_enabled: true
|
||||||
|
nginx_proxy_oauth2_proxy_bind: "127.0.0.1"
|
||||||
|
nginx_proxy_oauth2_proxy_port: 4180
|
||||||
|
nginx_proxy_oauth2_proxy_version: "7.15.3"
|
||||||
|
nginx_proxy_oauth2_proxy_cookie_domain: ""
|
||||||
|
nginx_proxy_oauth2_proxy_cookie_name: "_oauth2_proxy"
|
||||||
|
nginx_proxy_oauth2_proxy_cookie_secret: "{{ lookup('env', 'OAUTH2_PROXY_COOKIE_SECRET', default='') }}"
|
||||||
|
nginx_proxy_oauth2_proxy_client_id: "{{ lookup('env', 'OAUTH2_PROXY_CLIENT_ID', default='') }}"
|
||||||
|
nginx_proxy_oauth2_proxy_client_secret: "{{ lookup('env', 'OAUTH2_PROXY_CLIENT_SECRET', default='') }}"
|
||||||
|
nginx_proxy_oauth2_proxy_redirect_urls: []
|
||||||
|
nginx_proxy_oauth2_proxy_provider: ""
|
||||||
|
nginx_proxy_oauth2_proxy_provider_url: ""
|
||||||
|
nginx_proxy_oauth2_proxy_scopes: "openid profile email"
|
||||||
|
nginx_proxy_oauth2_proxy_email_domains: []
|
||||||
|
nginx_proxy_oauth2_proxy_upstream: "static://200"
|
||||||
|
nginx_proxy_oauth2_proxy_skip_jwt: false
|
||||||
|
nginx_proxy_oauth2_proxy_set_basic_auth: false
|
||||||
|
nginx_proxy_oauth2_proxy_set_xauthrequest: true
|
||||||
|
nginx_proxy_oauth2_proxy_ssl_insecure_skip_verify: false
|
||||||
|
nginx_proxy_oauth2_proxy_proxy_prefix: "/oauth2"
|
||||||
|
nginx_proxy_oauth2_proxy_silent_login: false
|
||||||
|
nginx_proxy_oauth2_proxy_skip_provider_whoami: false
|
||||||
|
nginx_proxy_oauth2_proxy_cookie_refresh: "0s"
|
||||||
|
nginx_proxy_oauth2_proxy_cookie_expire: "168h"
|
||||||
|
nginx_proxy_oauth2_proxy_session_storage_type: "memory"
|
||||||
|
nginx_proxy_oauth2_proxy_request_logging: true
|
||||||
|
nginx_proxy_oauth2_proxy_log_file: "/var/log/oauth2-proxy.log"
|
||||||
|
nginx_proxy_oauth2_proxy_log_level: "info"
|
||||||
|
|
||||||
|
nginx_proxy_sites: []
|
||||||
|
|
||||||
|
nginx_proxy_cloudns_auth_id: "{{ lookup('env', 'CLOUDNS_AUTH_ID') }}"
|
||||||
|
nginx_proxy_cloudns_auth_password: "{{ lookup('env', 'CLOUDNS_PASSWORD') }}"
|
||||||
|
nginx_proxy_cloudns_nameserver: "109.201.133.111"
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
- name: Reload nginx
|
||||||
|
become: true
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: nginx
|
||||||
|
state: reloaded
|
||||||
|
listen: Reload nginx
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
---
|
||||||
|
- name: Remove certbot installed via package manager
|
||||||
|
become: true
|
||||||
|
ansible.builtin.dnf:
|
||||||
|
name: certbot
|
||||||
|
state: absent
|
||||||
|
|
||||||
|
- name: Install pip
|
||||||
|
become: true
|
||||||
|
ansible.builtin.dnf:
|
||||||
|
name: python3-pip
|
||||||
|
state: present
|
||||||
|
|
||||||
|
- name: Install certbot and DNS plugin via pip
|
||||||
|
become: true
|
||||||
|
ansible.builtin.pip:
|
||||||
|
name:
|
||||||
|
- certbot
|
||||||
|
- certbot-dns-cloudns
|
||||||
|
state: present
|
||||||
|
|
||||||
|
- name: Ensure CloudDNS credentials directory exists
|
||||||
|
become: true
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: /etc/letsencrypt
|
||||||
|
state: directory
|
||||||
|
mode: '0700'
|
||||||
|
|
||||||
|
- name: Create CloudDNS credentials file
|
||||||
|
become: true
|
||||||
|
ansible.builtin.copy:
|
||||||
|
content: |
|
||||||
|
dns_cloudns_auth_id={{ nginx_proxy_cloudns_auth_id }}
|
||||||
|
dns_cloudns_auth_password={{ nginx_proxy_cloudns_auth_password }}
|
||||||
|
dest: "{{ nginx_proxy_certbot_credential_file }}"
|
||||||
|
mode: "{{ nginx_proxy_certbot_credential_mode }}"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
|
||||||
|
- name: Obtain TLS certificate via DNS-01 challenge
|
||||||
|
become: true
|
||||||
|
ansible.builtin.command: >-
|
||||||
|
certbot certonly
|
||||||
|
--authenticator dns-cloudns
|
||||||
|
--dns-cloudns-credentials {{ nginx_proxy_certbot_credential_file }}
|
||||||
|
--dns-cloudns-nameserver {{ nginx_proxy_cloudns_nameserver }}
|
||||||
|
-d {{ __nginx_proxy_certbot_domains | join(' -d ') }}
|
||||||
|
--non-interactive
|
||||||
|
--agree-tos
|
||||||
|
-m {{ nginx_proxy_certbot_email }}
|
||||||
|
args:
|
||||||
|
creates: "/etc/letsencrypt/live/{{ __nginx_proxy_certbot_domains[0] }}/fullchain.pem"
|
||||||
|
|
||||||
|
- name: Create certbot renewal cron job
|
||||||
|
become: true
|
||||||
|
ansible.builtin.cron:
|
||||||
|
name: "certbot renewal"
|
||||||
|
minute: "{{ nginx_proxy_certbot_cron_minute }}"
|
||||||
|
hour: "{{ nginx_proxy_certbot_cron_hour }}"
|
||||||
|
job: "certbot renew --deploy-hook 'systemctl reload nginx'"
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
---
|
||||||
|
- name: Validate nginx_proxy_sites is defined
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- nginx_proxy_sites is defined
|
||||||
|
- nginx_proxy_sites | length > 0
|
||||||
|
fail_msg: "nginx_proxy_sites must be defined with at least one site"
|
||||||
|
|
||||||
|
- name: Validate each site has required fields
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- item.server_name is defined
|
||||||
|
- item.upstream is defined
|
||||||
|
- item.upstream.host is defined
|
||||||
|
- item.upstream.port is defined
|
||||||
|
fail_msg: "Each site in nginx_proxy_sites must have server_name, upstream.host, and upstream.port"
|
||||||
|
loop: "{{ nginx_proxy_sites }}"
|
||||||
|
|
||||||
|
- name: Calculate certbot domains from sites
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
__nginx_proxy_certbot_domains: "{{ nginx_proxy_sites | map(attribute='server_name') | list }}"
|
||||||
|
when: nginx_proxy_certbot_domains is not defined
|
||||||
|
|
||||||
|
- name: Use explicit certbot domains when defined
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
__nginx_proxy_certbot_domains: "{{ nginx_proxy_certbot_domains }}"
|
||||||
|
when: nginx_proxy_certbot_domains is defined
|
||||||
|
|
||||||
|
- name: Install certbot
|
||||||
|
ansible.builtin.include_tasks: certbot.yml
|
||||||
|
when: nginx_proxy_certbot_enabled | bool
|
||||||
|
|
||||||
|
- name: Install nginx
|
||||||
|
ansible.builtin.include_tasks: nginx.yml
|
||||||
|
when: nginx_proxy_nginx_enabled | bool
|
||||||
|
|
||||||
|
- name: Install oauth2-proxy
|
||||||
|
ansible.builtin.include_tasks: oauth2_proxy.yml
|
||||||
|
when: nginx_proxy_oauth2_proxy_enabled | bool
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
---
|
||||||
|
- name: Install nginx
|
||||||
|
become: true
|
||||||
|
ansible.builtin.dnf:
|
||||||
|
name: nginx
|
||||||
|
state: present
|
||||||
|
|
||||||
|
- name: Deploy nginx.conf
|
||||||
|
become: true
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: nginx.conf.j2
|
||||||
|
dest: /etc/nginx/nginx.conf
|
||||||
|
mode: "0644"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
notify: Reload nginx
|
||||||
|
|
||||||
|
- name: Deploy server blocks configuration
|
||||||
|
become: true
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: nginx-server.conf.j2
|
||||||
|
dest: /etc/nginx/conf.d/nginx-proxy.conf
|
||||||
|
mode: "0644"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
notify: Reload nginx
|
||||||
|
|
||||||
|
- name: Enable and start nginx
|
||||||
|
become: true
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: nginx
|
||||||
|
state: started
|
||||||
|
enabled: true
|
||||||
|
daemon_reload: true
|
||||||
|
|
||||||
|
- name: Set SELinux context for Let's Encrypt certificates
|
||||||
|
become: true
|
||||||
|
ansible.builtin.command: >-
|
||||||
|
semanage fcontext -a -t httpd_cert_t "/etc/letsencrypt(/.*)?"
|
||||||
|
failed_when: false
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Restore SELinux context for Let's Encrypt directory
|
||||||
|
become: true
|
||||||
|
ansible.builtin.command: restorecon -Rv /etc/letsencrypt
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Set SELinux boolean to allow nginx to connect to any port
|
||||||
|
become: true
|
||||||
|
ansible.builtin.command: semanage boolean -m --on nginx_connect_any
|
||||||
|
failed_when: false
|
||||||
|
changed_when: false
|
||||||
|
when: ansible_selinux.status == "enabled"
|
||||||
|
|
||||||
|
- name: Add oauth2-proxy port to SELinux http_port_t
|
||||||
|
become: true
|
||||||
|
ansible.builtin.command: >-
|
||||||
|
semanage port -a -t http_port_t -p tcp {{ nginx_proxy_oauth2_proxy_port }}
|
||||||
|
failed_when: false
|
||||||
|
changed_when: false
|
||||||
|
when: ansible_selinux.status == "enabled"
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
---
|
||||||
|
- name: Download oauth2-proxy
|
||||||
|
become: true
|
||||||
|
ansible.builtin.get_url:
|
||||||
|
url: "https://github.com/oauth2-proxy/oauth2-proxy/releases/download/v{{ nginx_proxy_oauth2_proxy_version }}/oauth2-proxy-v{{ nginx_proxy_oauth2_proxy_version }}.linux-amd64.tar.gz"
|
||||||
|
dest: /tmp/oauth2-proxy.tar.gz
|
||||||
|
mode: "0644"
|
||||||
|
|
||||||
|
- name: Extract oauth2-proxy binary
|
||||||
|
become: true
|
||||||
|
ansible.builtin.unarchive:
|
||||||
|
src: /tmp/oauth2-proxy.tar.gz
|
||||||
|
dest: /tmp/
|
||||||
|
remote_src: true
|
||||||
|
|
||||||
|
- name: Install oauth2-proxy binary
|
||||||
|
become: true
|
||||||
|
ansible.builtin.copy:
|
||||||
|
src: >-
|
||||||
|
/tmp/oauth2-proxy-v{{ nginx_proxy_oauth2_proxy_version }}.linux-amd64/oauth2-proxy
|
||||||
|
dest: /usr/local/bin/oauth2-proxy
|
||||||
|
mode: "0755"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
remote_src: true
|
||||||
|
|
||||||
|
- name: Create oauth2-proxy config directory
|
||||||
|
become: true
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: /etc/oauth2-proxy
|
||||||
|
state: directory
|
||||||
|
mode: "0755"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
|
||||||
|
- name: Deploy oauth2-proxy configuration
|
||||||
|
become: true
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: oauth2-proxy.cfg.j2
|
||||||
|
dest: /etc/oauth2-proxy/oauth2-proxy.cfg
|
||||||
|
mode: "0640"
|
||||||
|
owner: root
|
||||||
|
group: nginx
|
||||||
|
|
||||||
|
- name: Create oauth2-proxy log file
|
||||||
|
become: true
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ nginx_proxy_oauth2_proxy_log_file }}"
|
||||||
|
state: touch
|
||||||
|
mode: "0644"
|
||||||
|
owner: nginx
|
||||||
|
group: nginx
|
||||||
|
|
||||||
|
- name: Deploy oauth2-proxy systemd service
|
||||||
|
become: true
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: oauth2-proxy.service.j2
|
||||||
|
dest: /etc/systemd/system/oauth2-proxy.service
|
||||||
|
mode: "0644"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
|
||||||
|
- name: Enable and start oauth2-proxy
|
||||||
|
become: true
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: oauth2-proxy
|
||||||
|
state: started
|
||||||
|
enabled: true
|
||||||
|
daemon_reload: true
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
{% set cert_prefix = nginx_proxy_sites[0].server_name %}
|
||||||
|
{% for item in nginx_proxy_sites %}
|
||||||
|
server {
|
||||||
|
listen {{ nginx_proxy_nginx_port }} ssl;
|
||||||
|
http2 on;
|
||||||
|
server_name {{ item.server_name }};
|
||||||
|
|
||||||
|
ssl_certificate {{ item.ssl_certificate | default('/etc/letsencrypt/live/' + cert_prefix + '/fullchain.pem') }};
|
||||||
|
ssl_certificate_key {{ item.ssl_certificate_key | default('/etc/letsencrypt/live/' + cert_prefix + '/privkey.pem') }};
|
||||||
|
ssl_protocols {{ nginx_proxy_nginx_ssl_protocols }};
|
||||||
|
ssl_ciphers {{ nginx_proxy_nginx_ssl_ciphers }};
|
||||||
|
ssl_prefer_server_ciphers {{ nginx_proxy_nginx_ssl_prefer_server_ciphers }};
|
||||||
|
|
||||||
|
add_header Strict-Transport-Security "max-age={{ nginx_proxy_nginx_hsts_max_age }}; {{ nginx_proxy_nginx_hsts_include_subdomains | ternary('includeSubDomains', '') }}" always;
|
||||||
|
|
||||||
|
{% if not (item.no_auth | default(false) | bool) %}
|
||||||
|
# Redirect unauthenticated requests to OAuth2 sign-in
|
||||||
|
error_page 401 = /oauth2/sign_in;
|
||||||
|
|
||||||
|
# OAuth2 auth_request
|
||||||
|
location = /oauth2/auth {
|
||||||
|
proxy_pass http://{{ nginx_proxy_oauth2_proxy_bind }}:{{ nginx_proxy_oauth2_proxy_port }}/oauth2/auth;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Scheme $scheme;
|
||||||
|
proxy_set_header X-Auth-Request-Redirect $request_uri;
|
||||||
|
proxy_set_header Content-Length "";
|
||||||
|
proxy_pass_request_body off;
|
||||||
|
proxy_method GET;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /oauth2/callback {
|
||||||
|
proxy_pass http://{{ nginx_proxy_oauth2_proxy_bind }}:{{ nginx_proxy_oauth2_proxy_port }}/oauth2/callback;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Scheme $scheme;
|
||||||
|
proxy_set_header X-Auth-Request-Redirect $request_uri;
|
||||||
|
proxy_buffer_size 16k;
|
||||||
|
proxy_buffers 4 32k;
|
||||||
|
proxy_busy_buffers_size 64k;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /oauth2/sign_in {
|
||||||
|
proxy_pass http://{{ nginx_proxy_oauth2_proxy_bind }}:{{ nginx_proxy_oauth2_proxy_port }}/oauth2/sign_in;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Scheme $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /oauth2/ {
|
||||||
|
proxy_pass http://{{ nginx_proxy_oauth2_proxy_bind }}:{{ nginx_proxy_oauth2_proxy_port }};
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Scheme $scheme;
|
||||||
|
}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
{% if not (item.no_auth | default(false) | bool) %}
|
||||||
|
auth_request /oauth2/auth;
|
||||||
|
auth_request_set $auth_status $upstream_status;
|
||||||
|
auth_request_set $auth_cookie $upstream_http_set_cookie;
|
||||||
|
|
||||||
|
add_header Set-Cookie $auth_cookie;
|
||||||
|
{% endif %}
|
||||||
|
add_header Strict-Transport-Security "max-age={{ nginx_proxy_nginx_hsts_max_age }}; {{ nginx_proxy_nginx_hsts_include_subdomains | ternary('includeSubDomains', '') }}" always;
|
||||||
|
|
||||||
|
proxy_pass http://{{ item.upstream.host }}:{{ item.upstream.port }};
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Port $server_port;
|
||||||
|
|
||||||
|
# WebSocket support
|
||||||
|
{% if item.websocket | default(true) | bool %}
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
# Timeouts
|
||||||
|
proxy_read_timeout {{ item.proxy_read_timeout | default('3600s') }};
|
||||||
|
proxy_send_timeout {{ item.proxy_send_timeout | default('3600s') }};
|
||||||
|
}
|
||||||
|
|
||||||
|
# Extra location blocks
|
||||||
|
{% if item.extra_locations | default([]) %}
|
||||||
|
{% for location in item.extra_locations %}
|
||||||
|
location {{ location.path }} {
|
||||||
|
{% for key, value in location.config.items() %}
|
||||||
|
{{ key }} {{ value }};
|
||||||
|
{% endfor %}
|
||||||
|
}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
}
|
||||||
|
|
||||||
|
{% endfor %}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
user nginx;
|
||||||
|
worker_processes auto;
|
||||||
|
error_log {{ nginx_proxy_nginx_error_log }};
|
||||||
|
pid /run/nginx.pid;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections {{ nginx_proxy_nginx_worker_connections }};
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||||
|
'$status $body_bytes_sent "$http_referer" '
|
||||||
|
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||||
|
|
||||||
|
access_log {{ nginx_proxy_nginx_access_log }} main;
|
||||||
|
|
||||||
|
sendfile on;
|
||||||
|
tcp_nopush on;
|
||||||
|
tcp_nodelay on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
types_hash_bucket_size 64;
|
||||||
|
types_hash_max_size 2048;
|
||||||
|
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
map $http_upgrade $connection_upgrade {
|
||||||
|
default upgrade;
|
||||||
|
'' close;
|
||||||
|
}
|
||||||
|
|
||||||
|
include /etc/nginx/conf.d/*.conf;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
provider = "{{ nginx_proxy_oauth2_proxy_provider }}"
|
||||||
|
oidc_issuer_url = "{{ nginx_proxy_oauth2_proxy_provider_url }}"
|
||||||
|
client_id = "{{ nginx_proxy_oauth2_proxy_client_id }}"
|
||||||
|
client_secret = "{{ nginx_proxy_oauth2_proxy_client_secret }}"
|
||||||
|
cookie_domains = [ "{{ nginx_proxy_oauth2_proxy_cookie_domain }}" ]
|
||||||
|
cookie_name = "{{ nginx_proxy_oauth2_proxy_cookie_name }}"
|
||||||
|
cookie_secret = "{{ nginx_proxy_oauth2_proxy_cookie_secret }}"
|
||||||
|
cookie_expire = "{{ nginx_proxy_oauth2_proxy_cookie_expire }}"
|
||||||
|
cookie_refresh = "{{ nginx_proxy_oauth2_proxy_cookie_refresh }}"
|
||||||
|
redirect_url = "{{ nginx_proxy_oauth2_proxy_redirect_urls[0] }}"
|
||||||
|
scope = "{{ nginx_proxy_oauth2_proxy_scopes }}"
|
||||||
|
email_domains = {{ nginx_proxy_oauth2_proxy_email_domains | to_json }}
|
||||||
|
upstreams = [ "static://200" ]
|
||||||
|
skip_jwt_bearer_tokens = {{ nginx_proxy_oauth2_proxy_skip_jwt | lower }}
|
||||||
|
set_basic_auth = {{ nginx_proxy_oauth2_proxy_set_basic_auth | lower }}
|
||||||
|
set_xauthrequest = {{ nginx_proxy_oauth2_proxy_set_xauthrequest | lower }}
|
||||||
|
ssl_insecure_skip_verify = {{ nginx_proxy_oauth2_proxy_ssl_insecure_skip_verify | lower }}
|
||||||
|
proxy_prefix = "{{ nginx_proxy_oauth2_proxy_proxy_prefix }}"
|
||||||
|
http_address = "{{ nginx_proxy_oauth2_proxy_bind }}:{{ nginx_proxy_oauth2_proxy_port }}"
|
||||||
|
{% if nginx_proxy_oauth2_proxy_request_logging | default(true) %}
|
||||||
|
logging_filename = "{{ nginx_proxy_oauth2_proxy_log_file | default('/var/log/oauth2-proxy.log') }}"
|
||||||
|
{% endif %}
|
||||||
|
cookie_secure = false
|
||||||
|
reverse_proxy = true
|
||||||
|
trusted_proxy_ips = [ "127.0.0.1" ]
|
||||||
|
proxy_websockets = true
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=oauth2-proxy
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=nginx
|
||||||
|
ExecStart=/usr/local/bin/oauth2-proxy --config /etc/oauth2-proxy/oauth2-proxy.cfg
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -180,3 +180,90 @@
|
|||||||
{{ windows_gitea_runner_podman_runner.name | default(windows_gitea_runner_podman_runner.profile) }}
|
{{ windows_gitea_runner_podman_runner.name | default(windows_gitea_runner_podman_runner.profile) }}
|
||||||
tags:
|
tags:
|
||||||
- roles::windows_gitea_runner_podman::linux
|
- roles::windows_gitea_runner_podman::linux
|
||||||
|
|
||||||
|
- name: Collect running Gitea runner container names
|
||||||
|
ansible.windows.win_shell: |
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$podman = "{{ windows_gitea_runner_podman_install_dir }}\podman.exe"
|
||||||
|
|
||||||
|
$raw = & $podman ps -a --filter label=io.a13labs.gitea_runner.config_hash --format json 2>$null
|
||||||
|
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($raw)) {
|
||||||
|
Write-Output "[]"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
$containers = ($raw | ConvertFrom-Json)
|
||||||
|
$names = @()
|
||||||
|
foreach ($c in $containers) {
|
||||||
|
$names += $c.Names
|
||||||
|
}
|
||||||
|
($names | ConvertTo-Json -Compress)
|
||||||
|
become: true
|
||||||
|
become_method: ansible.builtin.runas
|
||||||
|
become_user: "{{ windows_gitea_runner_podman_service_user }}"
|
||||||
|
register: __windows_gitea_runner_podman_existing_containers
|
||||||
|
changed_when: false
|
||||||
|
tags:
|
||||||
|
- roles::windows_gitea_runner_podman::linux
|
||||||
|
|
||||||
|
- name: Resolve defined container names with defaults
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
__windows_gitea_runner_podman_defined_containers: >-
|
||||||
|
{{
|
||||||
|
__windows_gitea_runner_podman_defined_containers | default([]) + [
|
||||||
|
__windows_gitea_runner_podman_item.container_name
|
||||||
|
| default(
|
||||||
|
'gitea-runner-' ~ (
|
||||||
|
__windows_gitea_runner_podman_item.profile
|
||||||
|
| default(__windows_gitea_runner_podman_item.name | default('runner'))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
loop: "{{ windows_gitea_runner_podman_runners }}"
|
||||||
|
loop_control:
|
||||||
|
loop_var: __windows_gitea_runner_podman_item
|
||||||
|
tags:
|
||||||
|
- roles::windows_gitea_runner_podman::linux
|
||||||
|
|
||||||
|
- name: Identify stale Gitea runner containers
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
__windows_gitea_runner_podman_stale_containers: >-
|
||||||
|
{{
|
||||||
|
(__windows_gitea_runner_podman_existing_containers.stdout | from_json)
|
||||||
|
| list
|
||||||
|
| reject('in', __windows_gitea_runner_podman_defined_containers)
|
||||||
|
| list
|
||||||
|
}}
|
||||||
|
tags:
|
||||||
|
- roles::windows_gitea_runner_podman::linux
|
||||||
|
|
||||||
|
- name: Remove stale Gitea runner containers
|
||||||
|
ansible.windows.win_shell: |
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$podman = "{{ windows_gitea_runner_podman_install_dir }}\podman.exe"
|
||||||
|
$name = "{{ item }}"
|
||||||
|
& $podman rm -f $name 2>$null
|
||||||
|
if ($LASTEXITCODE -eq 0) {
|
||||||
|
Write-Output "__REMOVED__:$name"
|
||||||
|
}
|
||||||
|
become: true
|
||||||
|
become_method: ansible.builtin.runas
|
||||||
|
become_user: "{{ windows_gitea_runner_podman_service_user }}"
|
||||||
|
loop: "{{ __windows_gitea_runner_podman_stale_containers }}"
|
||||||
|
loop_control:
|
||||||
|
label: "{{ item }}"
|
||||||
|
register: __windows_gitea_runner_podman_stale_removal
|
||||||
|
changed_when: true
|
||||||
|
failed_when: false
|
||||||
|
when: __windows_gitea_runner_podman_stale_containers | length > 0
|
||||||
|
tags:
|
||||||
|
- roles::windows_gitea_runner_podman::linux
|
||||||
|
|
||||||
|
- name: Report stale container cleanup
|
||||||
|
ansible.builtin.debug:
|
||||||
|
msg: >-
|
||||||
|
Stale containers removed: {{ __windows_gitea_runner_podman_stale_containers | join(', ') }}
|
||||||
|
when: __windows_gitea_runner_podman_stale_containers | length > 0
|
||||||
|
tags:
|
||||||
|
- roles::windows_gitea_runner_podman::linux
|
||||||
|
|||||||
@@ -361,6 +361,7 @@
|
|||||||
-e "GITEA_RUNNER_REGISTRATION_TOKEN={{ windows_gitea_runner_podman_registration_token }}" `
|
-e "GITEA_RUNNER_REGISTRATION_TOKEN={{ windows_gitea_runner_podman_registration_token }}" `
|
||||||
-e "GITEA_RUNNER_NAME={{ __windows_gitea_runner_podman_name }}" `
|
-e "GITEA_RUNNER_NAME={{ __windows_gitea_runner_podman_name }}" `
|
||||||
-e "GITEA_RUNNER_LABELS={{ __windows_gitea_runner_podman_labels }}" `
|
-e "GITEA_RUNNER_LABELS={{ __windows_gitea_runner_podman_labels }}" `
|
||||||
|
-e "GITEA_RUNNER_USER=runner" `
|
||||||
{% if __windows_gitea_runner_podman_enable_nested_build %}
|
{% if __windows_gitea_runner_podman_enable_nested_build %}
|
||||||
-v "{{ __windows_gitea_runner_podman_storage_dir }}:/var/lib/containers" `
|
-v "{{ __windows_gitea_runner_podman_storage_dir }}:/var/lib/containers" `
|
||||||
-e "BUILDAH_ISOLATION=chroot" `
|
-e "BUILDAH_ISOLATION=chroot" `
|
||||||
|
|||||||
@@ -21,3 +21,8 @@ OPENCODE_SERVER_USERNAME=$OPENCODE_SERVER_USERNAME
|
|||||||
OPENCODE_SERVER_PASSWORD=$OPENCODE_SERVER_PASSWORD
|
OPENCODE_SERVER_PASSWORD=$OPENCODE_SERVER_PASSWORD
|
||||||
CODE_SERVER_PASSWORD=$CODE_SERVER_PASSWORD
|
CODE_SERVER_PASSWORD=$CODE_SERVER_PASSWORD
|
||||||
OPEN_WEBUI_API_KEY=$OPEN_WEBUI_API_KEY
|
OPEN_WEBUI_API_KEY=$OPEN_WEBUI_API_KEY
|
||||||
|
OAUTH2_PROXY_COOKIE_SECRET=$OAUTH2_PROXY_COOKIE_SECRET
|
||||||
|
OAUTH2_PROXY_CLIENT_ID=$OAUTH2_PROXY_CLIENT_ID
|
||||||
|
OAUTH2_PROXY_CLIENT_SECRET=$OAUTH2_PROXY_CLIENT_SECRET
|
||||||
|
SCW_DEFAULT_ORGANIZATION_ID=$SCALEWAY_ORGANIZATION_ID
|
||||||
|
SCW_INFERENCE_API_KEY=$SCW_INFERENCE_API_KEY
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# Linux Dev Station - dev-02.lab.alexpires.me
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Provisioned `dev-02.lab.alexpires.me` (Fedora, `10.19.4.210`) as a remote dev station mirroring the macOS setup. Includes code-server, opencode, and dev toolchain management. Exposed via nginx reverse proxy with TLS termination and OAuth2/OIDC SSO (Gitea as IdP).
|
||||||
|
|
||||||
|
## Role: `linux_dev_station`
|
||||||
|
- **Location:** `ansible/roles/linux_dev_station/`
|
||||||
|
- **Scope:** Fedora only (`ansible_facts['distribution'] == "Fedora"`)
|
||||||
|
- **Package manager:** dnf for system packages; tool-managed (uv, rustup, fnm) for dev toolchains
|
||||||
|
- **Excludes:** podman/container runtime management
|
||||||
|
|
||||||
|
### Components
|
||||||
|
- **SSH hardening:** Public key auth, password auth disabled, root login disabled
|
||||||
|
- **Users:** `devstation` user with sudo, zsh shell, and required groups
|
||||||
|
- **Zsh:** Oh My Zsh with agnoster theme, plugins, custom env vars, and zshrc management
|
||||||
|
- **Tooling:**
|
||||||
|
- `uv` - Python package manager
|
||||||
|
- `rustup` - Rust toolchain
|
||||||
|
- `fnm` + `node` - Node.js version manager
|
||||||
|
- `code-server` - VS Code remote on `127.0.0.1:3000`
|
||||||
|
- `opencode` - AI coding assistant (HTTP server on `127.0.0.1:4096`)
|
||||||
|
- **Firewall:** firewalld with `fw_allowed_ports` (22, 443)
|
||||||
|
- **Systemd services:** code-server, opencode
|
||||||
|
|
||||||
|
### Opencode Details
|
||||||
|
- **Repo:** `anomalyco/opencode` (not `opencode-ai`)
|
||||||
|
- **Version:** `1.17.18`
|
||||||
|
- **Binary:** `opencode-linux-x64.tar.gz`
|
||||||
|
- **Mode:** HTTP server (`serve`), binds to `127.0.0.1`
|
||||||
|
- **Install strategy:** Versioned directories (`~/.local/lib/tool-vX.Y.Z`) + symlinks for idempotent upgrades
|
||||||
|
|
||||||
|
### Oh My Zsh Plugins (Linux)
|
||||||
|
git, fzf, common-aliases, sudo, tmux, extract, colored-man-pages, history-substring-search, kubectl, microk8s, docker, docker-compose, ansible, aws, golang, dnf, bgnotify
|
||||||
|
|
||||||
|
## macOS Role Updates
|
||||||
|
- **Location:** `ansible/roles/macos_dev_station/`
|
||||||
|
- Synced Oh My Zsh fixes: sourcing, theme, plugins, extra env vars
|
||||||
|
- **Oh My Zsh Plugins (macOS):** git, fzf, common-aliases, sudo, tmux, extract, colored-man-pages, history-substring-search, kubectl, docker, docker-compose, ansible, aws, golang, brew, bgnotify
|
||||||
|
|
||||||
|
## TLS + OAuth2/OIDC SSO (`nginx_proxy` role)
|
||||||
|
- **Location:** `ansible/roles/nginx_proxy/` — standalone, generic role not tied to `linux_dev_station`
|
||||||
|
- **Playbook:** `ansible/playbook_nginx_proxy.yml` targets `[nginx]` group
|
||||||
|
- **Architecture:** nginx (:443, TLS) → oauth2-proxy (:4180, session mgmt) → backends (127.0.0.1 only)
|
||||||
|
- **Certificate:** SAN cert for `ide.lab.alexpires.me` + `agent.lab.alexpires.me` via certbot DNS-01 (cloudns plugin), auto-renewal cron
|
||||||
|
- **SSO cookie:** Scoped to `.lab.alexpires.me`
|
||||||
|
- **IdP:** Gitea (`code.alexpires.me`), OAuth2 app `lab-services` (confidential client, scopes: `openid`, `profile`, `email`)
|
||||||
|
- **oauth2-proxy:** v7.15.3, config format: `oidc_issuer_url`, `http_address`, `upstreams` array, `cookie_domains` array, `proxy_websockets = true`, `reverse_proxy = true`
|
||||||
|
- **Services exposed:**
|
||||||
|
- `https://ide.lab.alexpires.me` → code-server (127.0.0.1:3000)
|
||||||
|
- `https://agent.lab.alexpires.me` → opencode (127.0.0.1:4096)
|
||||||
|
- **Nginx fixes applied:** `types_hash_bucket_size 64;`, ssl_ciphers single line, http2 separate directive, SAN cert shared via first site directory, POST auth_request body fix (`proxy_set_header Content-Length "";`), proxy_buffer_size for oauth2 callback
|
||||||
|
|
||||||
|
### Defaults
|
||||||
|
- `nginx_proxy_oauth2_proxy_version: "7.15.3"`
|
||||||
|
- `nginx_proxy_cloudns_nameserver: "109.201.133.111"`
|
||||||
|
- `nginx_proxy_sites` — list of site configs with server_name, upstream, websocket toggle
|
||||||
|
|
||||||
|
### Secrets
|
||||||
|
- `OAUTH2_PROXY_COOKIE_SECRET` — 32-byte random, stored in vault
|
||||||
|
- `OAUTH2_PROXY_CLIENT_ID` / `OAUTH2_PROXY_CLIENT_SECRET` — from Gitea OAuth2 app
|
||||||
|
- `CLOUDNS_AUTH_ID` / `CLOUDNS_PASSWORD` — DNS credentials for certbot
|
||||||
|
|
||||||
|
## Infrastructure Changes
|
||||||
|
- **DNS:**
|
||||||
|
- Added A record for `dev-02.lab.alexpires.me` (`10.19.4.210`)
|
||||||
|
- Added A records for `ide` and `agent` → `10.19.4.210` (in `terraform/apps/dev-01/config.tf`)
|
||||||
|
- **Inventory:** Added `[nginx]` group with `dev-02.lab.alexpires.me`; dev-02 removed from `[certbot]` group
|
||||||
|
- **Host vars:** `ansible/host_vars/dev-02.lab.alexpires.me.yml` with `nginx_proxy_sites`, `bind_to_localhost: true`, firewall rules (22, 443), OAuth2 credentials via `lookup('env', ...)`
|
||||||
|
- **dev-01 cleanup:** Removed `opencode`/`vscode` CNAME records and `proxy_services` entries (no longer proxying to dev-02)
|
||||||
|
|
||||||
|
## Service Bindings
|
||||||
|
| Service | Binding |
|
||||||
|
|---------|---------|
|
||||||
|
| code-server | `127.0.0.1:3000` |
|
||||||
|
| opencode | `127.0.0.1:4096` |
|
||||||
|
| oauth2-proxy | `127.0.0.1:4180` |
|
||||||
|
| nginx | `0.0.0.0:443` |
|
||||||
|
|
||||||
|
## Firewall
|
||||||
|
| Port | Access |
|
||||||
|
|------|--------|
|
||||||
|
| 22 | `10.19.4.0/24` + VPN (`10.5.5.5/32`) |
|
||||||
|
| 443 | `10.19.4.0/24` + VPN (`10.5.5.5/32`) |
|
||||||
|
| 3000/4096 | Closed (nginx handles all external access) |
|
||||||
|
|
||||||
|
## SELinux
|
||||||
|
- http_port_t extended to include 4180 (oauth2-proxy)
|
||||||
|
- httpd_can_network_connect enabled
|
||||||
|
- `/etc/letsencrypt` context set to `httpd_cert_t`
|
||||||
|
|
||||||
|
## Known Issues & Workarounds
|
||||||
|
- **ssh-agent systemd:** Removed due to SELinux blocking transitions; managed via `.zshrc` startup instead
|
||||||
|
- **ACL issue:** `allow_world_readable_tmpfiles = True` required in `ansible.cfg` for macOS→Fedora `become_user` compatibility
|
||||||
|
|
||||||
|
## Required Credentials
|
||||||
|
- `GITEA_APP_TOKEN`
|
||||||
|
- `OPEN_WEBUI_API_KEY`
|
||||||
|
- `CLOUDNS_AUTH_ID` / `CLOUDNS_PASSWORD` (for certbot DNS-01)
|
||||||
|
- `OAUTH2_PROXY_COOKIE_SECRET` / `CLIENT_ID` / `CLIENT_SECRET` (for nginx_proxy role)
|
||||||
|
|
||||||
|
## Ansible.cfg Fix
|
||||||
|
Added `allow_world_readable_tmpfiles = True` under `[defaults]` to fix `become_user` failures on Fedora when running from macOS.
|
||||||
+12
-3
@@ -3,12 +3,14 @@ prod-01.alexpires.me
|
|||||||
dev-01.lab.alexpires.me
|
dev-01.lab.alexpires.me
|
||||||
vh-01.lab.alexpires.me
|
vh-01.lab.alexpires.me
|
||||||
gpu-01.lab.alexpires.me
|
gpu-01.lab.alexpires.me
|
||||||
|
dev-02.lab.alexpires.me
|
||||||
|
|
||||||
[hardening]
|
[hardening]
|
||||||
prod-01.alexpires.me
|
prod-01.alexpires.me
|
||||||
dev-01.lab.alexpires.me
|
dev-01.lab.alexpires.me
|
||||||
vh-01.lab.alexpires.me
|
vh-01.lab.alexpires.me
|
||||||
gpu-01.lab.alexpires.me
|
gpu-01.lab.alexpires.me
|
||||||
|
dev-02.lab.alexpires.me
|
||||||
|
|
||||||
[ubuntu]
|
[ubuntu]
|
||||||
prod-01.alexpires.me
|
prod-01.alexpires.me
|
||||||
@@ -17,11 +19,16 @@ dev-01.lab.alexpires.me
|
|||||||
[fedora]
|
[fedora]
|
||||||
vh-01.lab.alexpires.me
|
vh-01.lab.alexpires.me
|
||||||
gpu-01.lab.alexpires.me
|
gpu-01.lab.alexpires.me
|
||||||
|
dev-02.lab.alexpires.me
|
||||||
|
|
||||||
[certbot]
|
[certbot]
|
||||||
vh-01.lab.alexpires.me
|
vh-01.lab.alexpires.me
|
||||||
gpu-01.lab.alexpires.me
|
gpu-01.lab.alexpires.me
|
||||||
|
|
||||||
|
[nginx]
|
||||||
|
dev-02.lab.alexpires.me
|
||||||
|
gpu-01.lab.alexpires.me
|
||||||
|
|
||||||
[public]
|
[public]
|
||||||
prod-01.alexpires.me
|
prod-01.alexpires.me
|
||||||
|
|
||||||
@@ -29,6 +36,7 @@ prod-01.alexpires.me
|
|||||||
dev-01.lab.alexpires.me
|
dev-01.lab.alexpires.me
|
||||||
vh-01.lab.alexpires.me
|
vh-01.lab.alexpires.me
|
||||||
gpu-01.lab.alexpires.me
|
gpu-01.lab.alexpires.me
|
||||||
|
dev-02.lab.alexpires.me
|
||||||
|
|
||||||
[gitea]
|
[gitea]
|
||||||
prod-01.alexpires.me
|
prod-01.alexpires.me
|
||||||
@@ -85,9 +93,6 @@ gpu-01.lab.alexpires.me
|
|||||||
[comfyui]
|
[comfyui]
|
||||||
gpu-01.lab.alexpires.me
|
gpu-01.lab.alexpires.me
|
||||||
|
|
||||||
[openwebui]
|
|
||||||
dev-01.lab.alexpires.me
|
|
||||||
|
|
||||||
[mcp]
|
[mcp]
|
||||||
dev-01.lab.alexpires.me
|
dev-01.lab.alexpires.me
|
||||||
|
|
||||||
@@ -141,3 +146,7 @@ ci-01.lab.alexpires.me
|
|||||||
[darwin_dev]
|
[darwin_dev]
|
||||||
mac-01.lab.alexpires.me
|
mac-01.lab.alexpires.me
|
||||||
|
|
||||||
|
[linux_dev]
|
||||||
|
dev-02.lab.alexpires.me
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,3 @@
|
|||||||
module "open-webui" {
|
|
||||||
source = "../../modules/apps/open-webui"
|
|
||||||
|
|
||||||
persistent_folder = local.persistent_folder
|
|
||||||
fqdn = local.open_webui_fqdn
|
|
||||||
llm_proxy = local.llm_proxy
|
|
||||||
secret_key = var.webui_secret_key
|
|
||||||
tag = "v0.9.2-slim"
|
|
||||||
log_level = local.open_webui_log_level
|
|
||||||
}
|
|
||||||
|
|
||||||
module "samba" {
|
module "samba" {
|
||||||
source = "../../modules/apps/samba"
|
source = "../../modules/apps/samba"
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,6 @@ locals {
|
|||||||
lab_domain = "lab.alexpires.me"
|
lab_domain = "lab.alexpires.me"
|
||||||
internal_issuer = "internal-ca"
|
internal_issuer = "internal-ca"
|
||||||
|
|
||||||
# open-webui
|
|
||||||
open_webui_fqdn = "open-webui.${local.lab_domain}"
|
|
||||||
open_webui_log_level = "DEBUG"
|
|
||||||
|
|
||||||
# samba
|
# samba
|
||||||
samba_shares = [
|
samba_shares = [
|
||||||
{
|
{
|
||||||
@@ -61,11 +57,6 @@ locals {
|
|||||||
type = "CNAME"
|
type = "CNAME"
|
||||||
content = "dev-01.${local.lab_domain}."
|
content = "dev-01.${local.lab_domain}."
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name = "open-webui"
|
|
||||||
type = "CNAME"
|
|
||||||
content = "dev-01.${local.lab_domain}."
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name = "smb"
|
name = "smb"
|
||||||
type = "CNAME"
|
type = "CNAME"
|
||||||
@@ -111,6 +102,26 @@ locals {
|
|||||||
type = "A"
|
type = "A"
|
||||||
content = "10.19.4.207"
|
content = "10.19.4.207"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name = "dev-02"
|
||||||
|
type = "A"
|
||||||
|
content = "10.19.4.210"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name = "llm"
|
||||||
|
type = "A"
|
||||||
|
content = "10.19.4.106"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name = "ide"
|
||||||
|
type = "A"
|
||||||
|
content = "10.19.4.210"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name = "agent"
|
||||||
|
type = "A"
|
||||||
|
content = "10.19.4.210"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name = "vaultwarden"
|
name = "vaultwarden"
|
||||||
type = "CNAME"
|
type = "CNAME"
|
||||||
@@ -121,16 +132,6 @@ locals {
|
|||||||
type = "CNAME"
|
type = "CNAME"
|
||||||
content = "dev-01.${local.lab_domain}."
|
content = "dev-01.${local.lab_domain}."
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name = "opencode"
|
|
||||||
type = "CNAME"
|
|
||||||
content = "dev-01.${local.lab_domain}."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "vscode"
|
|
||||||
type = "CNAME"
|
|
||||||
content = "dev-01.${local.lab_domain}."
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -152,38 +153,6 @@ locals {
|
|||||||
# },
|
# },
|
||||||
# ]
|
# ]
|
||||||
# }
|
# }
|
||||||
"opencode" = {
|
|
||||||
http_port = 4096
|
|
||||||
tls = true
|
|
||||||
fqdn = ["opencode.lab.alexpires.me"]
|
|
||||||
upstream = "http://10.19.4.203:4096"
|
|
||||||
read_timeout = "3600s"
|
|
||||||
|
|
||||||
locations = [
|
|
||||||
{
|
|
||||||
path = "/"
|
|
||||||
headers = {
|
|
||||||
"Upgrade" = "$http_upgrade"
|
|
||||||
"Connection" = "upgrade"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"code-server" = {
|
|
||||||
http_port = 3000
|
|
||||||
tls = true
|
|
||||||
fqdn = ["vscode.lab.alexpires.me"]
|
|
||||||
upstream = "http://10.19.4.203:3000"
|
|
||||||
locations = [
|
|
||||||
{
|
|
||||||
path = "/"
|
|
||||||
headers = {
|
|
||||||
"Upgrade" = "$http_upgrade"
|
|
||||||
"Connection" = "upgrade"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
streams = {
|
streams = {
|
||||||
@@ -207,14 +176,6 @@ locals {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
llm_proxy = {
|
|
||||||
enabled = true
|
|
||||||
mac = "04:7c:16:d6:63:66"
|
|
||||||
ip = "10.19.4.106"
|
|
||||||
port = 11434
|
|
||||||
scheme = "http"
|
|
||||||
}
|
|
||||||
|
|
||||||
llm_proxy_1 = {
|
llm_proxy_1 = {
|
||||||
enabled = true
|
enabled = true
|
||||||
mac = "52:54:00:CD:DD:2E"
|
mac = "52:54:00:CD:DD:2E"
|
||||||
|
|||||||
@@ -22,12 +22,6 @@ variable "telegram_bot_token" {
|
|||||||
sensitive = true
|
sensitive = true
|
||||||
}
|
}
|
||||||
|
|
||||||
variable "webui_secret_key" {
|
|
||||||
description = "The secret key for WEBUI"
|
|
||||||
type = string
|
|
||||||
sensitive = true
|
|
||||||
}
|
|
||||||
|
|
||||||
variable "searxng_secret_key" {
|
variable "searxng_secret_key" {
|
||||||
description = "The secret key for SearXNG"
|
description = "The secret key for SearXNG"
|
||||||
type = string
|
type = string
|
||||||
|
|||||||
@@ -147,28 +147,6 @@ locals {
|
|||||||
}
|
}
|
||||||
|
|
||||||
proxy_services = {
|
proxy_services = {
|
||||||
"open-webui" = {
|
|
||||||
http_port = 8080
|
|
||||||
https_port = 8443
|
|
||||||
tls = true
|
|
||||||
resolver = "10.19.4.136"
|
|
||||||
fqdn = ["ai.alexpires.me"]
|
|
||||||
read_timeout = "1800"
|
|
||||||
send_timeout = "1800"
|
|
||||||
upstream = "https://open-webui.lab.alexpires.me"
|
|
||||||
locations = [
|
|
||||||
{
|
|
||||||
path = "/ws/socket.io/"
|
|
||||||
headers = {
|
|
||||||
"Upgrade" = "$http_upgrade"
|
|
||||||
"Connection" = "upgrade"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path = "/"
|
|
||||||
},
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nextcloud" = {
|
"nextcloud" = {
|
||||||
http_port = 8081
|
http_port = 8081
|
||||||
https_port = 8444
|
https_port = 8444
|
||||||
|
|||||||
@@ -170,16 +170,6 @@ locals {
|
|||||||
type = "TXT"
|
type = "TXT"
|
||||||
value = "v=DKIM1; h=sha256; k=rsa; p=${var.alexpires_me_scaleway_dkim_value}"
|
value = "v=DKIM1; h=sha256; k=rsa; p=${var.alexpires_me_scaleway_dkim_value}"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name = "webdav"
|
|
||||||
type = "CNAME"
|
|
||||||
value = "${local.vps_1.name}.alexpires.me"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "ai"
|
|
||||||
type = "CNAME"
|
|
||||||
value = "${local.vps_1.name}.alexpires.me"
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"provider": "bitwarden",
|
||||||
|
"bitwarden": {
|
||||||
|
"api_url": "https://api.bitwarden.eu",
|
||||||
|
"identity_url": "https://identity.bitwarden.eu",
|
||||||
|
"project": "c6bd56a4-9fec-4388-b74a-b2a100c0ae07"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user