Compare commits
2 Commits
fix/gitea-
...
chore/cano
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e88a89f34d | ||
|
|
373e4558a3 |
@@ -62,9 +62,8 @@ Jarvis (v0.2.0) is a self-hosted AI assistant with a Python FastAPI backend and
|
|||||||
19. `@mosaicstack/prdy` — PRD wizard
|
19. `@mosaicstack/prdy` — PRD wizard
|
||||||
20. `@mosaicstack/quality-rails` — code quality scaffolder
|
20. `@mosaicstack/quality-rails` — code quality scaffolder
|
||||||
21. `@mosaicstack/cli` — unified `mosaic` CLI
|
21. `@mosaicstack/cli` — unified `mosaic` CLI
|
||||||
22. Mosaic framework git wrappers — provider-aware issue/PR/CI shell wrappers for GitHub and self-hosted Gitea hosts used by Mosaic/USC repositories
|
22. Docker Compose deployment + bare-metal capability
|
||||||
23. Docker Compose deployment + bare-metal capability
|
23. Agent log service — ingest, parse, tier, summarize agent interaction logs
|
||||||
24. Agent log service — ingest, parse, tier, summarize agent interaction logs
|
|
||||||
|
|
||||||
### Out of Scope (v0.1.0)
|
### Out of Scope (v0.1.0)
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ These are MVP-level checks that don't belong to any single workstream. Updated b
|
|||||||
| MVP-T04 | not-started | Sync `.mosaic/orchestrator/mission.json` MVP slot with this manifest (milestone enumeration, etc.) | Coord state file; consider whether to repopulate via `mosaic coord` or accept hand-edit |
|
| MVP-T04 | not-started | Sync `.mosaic/orchestrator/mission.json` MVP slot with this manifest (milestone enumeration, etc.) | Coord state file; consider whether to repopulate via `mosaic coord` or accept hand-edit |
|
||||||
| MVP-T05 | in-progress | Kick off W1 / FED-M1 — federated tier infrastructure | Session 16 (2026-04-19): FED-M1-01 in-progress on `feat/federation-m1-tier-config` |
|
| MVP-T05 | in-progress | Kick off W1 / FED-M1 — federated tier infrastructure | Session 16 (2026-04-19): FED-M1-01 in-progress on `feat/federation-m1-tier-config` |
|
||||||
| MVP-T06 | not-started | Declare additional workstreams (web dashboard, TUI/CLI parity, remote control, etc.) as scope solidifies | Track each new workstream by adding a row to the Workstream Rollup |
|
| MVP-T06 | not-started | Declare additional workstreams (web dashboard, TUI/CLI parity, remote control, etc.) as scope solidifies | Track each new workstream by adding a row to the Workstream Rollup |
|
||||||
| MVP-T07 | in-progress | Harden Mosaic framework Gitea PR metadata and merge preflight wrappers | Internal ref `t_a292e96f`; source branch `fix/gitea-pr-metadata-login-t-a292e96f` |
|
|
||||||
|
|
||||||
## Pointer to Active Workstream
|
## Pointer to Active Workstream
|
||||||
|
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
# t_a292e96f — Gitea PR metadata and merge wrapper fix
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Fix Mosaic git wrappers so Gitea repositories on `git.uscllc.com` resolve PR metadata and merge preflight through the correct host credentials, without selecting the stale `mosaicstack` Tea login.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- `pr-metadata.sh` returns `baseRefName=main` for U-Connect PR #1905 and PR #1908.
|
|
||||||
- `pr-metadata.sh` returns source-branch-style `headRefName`; for Gitea `refs/pull/<n>/head` responses, normalize to `head.label`.
|
|
||||||
- `pr-merge.sh` preserves Mosaic squash-only and base-branch policy, then uses host-matched Gitea API credentials for Gitea merges instead of a hard-coded Tea login.
|
|
||||||
- Add regression coverage/harness for Gitea metadata normalization and merge preflight.
|
|
||||||
- Do not print, log, or commit tokens.
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
1. Reproduce current live metadata/login context with sanitized output.
|
|
||||||
2. Patch repo-source shell wrappers under `packages/mosaic/framework/tools/git/`.
|
|
||||||
3. Add a hermetic shell regression harness with fake `git`, `curl`, and `tea`.
|
|
||||||
4. Validate with `bash -n`, shellcheck if available, regression harness, and live sanitized U-Connect wrapper calls.
|
|
||||||
5. Apply the same script changes to the installed Mosaic wrapper location only after source changes validate, so active U-Connect merge wrappers are unblocked while the PR is reviewed.
|
|
||||||
6. Commit, push through queue guard, open PR, and hand off to Ultron review task `t_848435ab`; do not merge.
|
|
||||||
|
|
||||||
## Progress
|
|
||||||
|
|
||||||
- Live sanitized metadata check before source patch:
|
|
||||||
- PR #1905: `baseRefName=main`, `headRefName=edith/t_39ce717c-authentik-smoke-gate`.
|
|
||||||
- PR #1908: `baseRefName=main`, `headRefName=refs/pull/1908/head`; raw Gitea `head.label` is `fix/t_23fa9e1d-portal-health-backend`.
|
|
||||||
- `tea login list` contains only `git.mosaicstack.dev`, so the prior `--login mosaicstack` default cannot work for `git.uscllc.com`.
|
|
||||||
|
|
||||||
## Verification log
|
|
||||||
|
|
||||||
- `bash -n packages/mosaic/framework/tools/git/detect-platform.sh packages/mosaic/framework/tools/git/pr-metadata.sh packages/mosaic/framework/tools/git/pr-merge.sh packages/mosaic/framework/tools/git/tests/pr-gitea-wrapper-regression.sh` — pass.
|
|
||||||
- `shellcheck packages/mosaic/framework/tools/git/detect-platform.sh packages/mosaic/framework/tools/git/pr-metadata.sh packages/mosaic/framework/tools/git/pr-merge.sh packages/mosaic/framework/tools/git/tests/pr-gitea-wrapper-regression.sh` — pass when available in the Kanban runtime.
|
|
||||||
- `TMPDIR="$PWD/.agent-tmp" bash packages/mosaic/framework/tools/git/tests/pr-gitea-wrapper-regression.sh` — pass; proves host-matched Gitea credential selection, metadata normalization, and merge dry-run preflight without invoking `tea`.
|
|
||||||
- Live sanitized U-Connect metadata using the patched wrapper from `/src/uconnect`:
|
|
||||||
- PR #1905: `number=1905`, `baseRefName=main`, `headRefName=edith/t_39ce717c-authentik-smoke-gate`, `state=open`.
|
|
||||||
- PR #1908: `number=1908`, `baseRefName=main`, `headRefName=fix/t_23fa9e1d-portal-health-backend`, `state=closed`.
|
|
||||||
- Live sanitized U-Connect merge preflight using `pr-merge.sh --skip-queue-guard --dry-run`:
|
|
||||||
- PR #1905: `Dry run: Gitea merge preflight OK for USC/uconnect#1905 targeting main via git.uscllc.com API`.
|
|
||||||
- PR #1908: `Dry run: Gitea merge preflight OK for USC/uconnect#1908 targeting main via git.uscllc.com API`.
|
|
||||||
- Installed wrapper parity: `/home/hermes/.config/mosaic/tools/git/{detect-platform.sh,pr-metadata.sh,pr-merge.sh}` byte-match the PR source copies after validation, so active U-Connect wrapper invocations use the same fix while source PR review runs.
|
|
||||||
|
|
||||||
## Risks / notes
|
|
||||||
|
|
||||||
- `--dry-run` was added to `pr-merge.sh` to validate metadata/auth/preflight without merging a live PR.
|
|
||||||
- Gitea branch deletion after merge remains a documented warning, matching prior behavior, and is not expanded in this fix.
|
|
||||||
- Duplicate recovery PR #517 was closed after wrapper-first `pr-close.sh -n 517` failed headlessly with `/dev/tty`; PR #518 is the review target.
|
|
||||||
@@ -453,6 +453,26 @@ Initialize standard labels and the first pre-MVP milestone:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Secrets Bootstrap (Required for Every New App)
|
||||||
|
|
||||||
|
Every new application MUST complete the following secrets bootstrap before deploying to any non-local environment. This is a hard gate — deployment without completed secrets bootstrap is forbidden.
|
||||||
|
|
||||||
|
### Secrets bootstrap checklist
|
||||||
|
|
||||||
|
- [ ] Vault path created: `vault kv put secret/k3s/<app>/ ...` with all required secret fields
|
||||||
|
- [ ] Required secrets listed in project README under a "Secrets architecture" section, including:
|
||||||
|
- Vault path(s) used
|
||||||
|
- All required secret keys and their purpose
|
||||||
|
- Whether the app uses ESO bridge (default) or Direct-Vault (opt-in, with justification)
|
||||||
|
- [ ] `external-secret.yaml` manifest committed to repo's `deploy/` or `k8s/` directory
|
||||||
|
- [ ] Deployment YAML references the synced k8s Secret via `secretKeyRef` (not raw env vars or `.env` files)
|
||||||
|
- [ ] App startup has schema-based validation for all required env vars (zod / pydantic / envconfig equivalent) that exits non-zero on missing required values
|
||||||
|
- [ ] Direct-Vault opt-in (if applicable): justification documented in README + AppRole provisioned + bootstrap credentials stored in Vault and synced via a separate `ExternalSecret`
|
||||||
|
|
||||||
|
See `~/.config/mosaic/guides/VAULT-SECRETS.md` for full worked examples of the ESO bridge pattern, the Direct-Vault opt-in pattern, and the forbidden antipatterns.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Checklist
|
## Checklist
|
||||||
|
|
||||||
After bootstrapping, verify:
|
After bootstrapping, verify:
|
||||||
|
|||||||
@@ -203,3 +203,374 @@ Error: token expired
|
|||||||
3. **Audit logging** - All access is logged; act accordingly
|
3. **Audit logging** - All access is logged; act accordingly
|
||||||
4. **No local copies** - Don't store secrets in files or env vars long-term
|
4. **No local copies** - Don't store secrets in files or env vars long-term
|
||||||
5. **Rotate on compromise** - Immediately rotate any exposed secrets
|
5. **Rotate on compromise** - Immediately rotate any exposed secrets
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Secrets Architecture Decision Matrix
|
||||||
|
|
||||||
|
Use this table to choose between the ESO bridge (default) and Direct-Vault (opt-in) patterns for every new app or integration.
|
||||||
|
|
||||||
|
| Factor | ESO Bridge (default) | Direct-Vault (opt-in) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Use-case** | All static secrets (DB creds, API keys, signing keys, OAuth secrets) | Dynamic creds with short TTLs (DB rotation, AWS STS, PKI), per-request audit trails, or lease renewal mid-pod-lifecycle |
|
||||||
|
| **App code change** | None — reads standard env vars via `secretKeyRef` | Requires Vault client (`hvac`, `node-vault`, `vault/api`) in application code |
|
||||||
|
| **Secret rotation** | ESO re-syncs on Vault write; pod restart or secret refresh picks up new value | App manages lease renewal or re-auth within the running process |
|
||||||
|
| **Audit granularity** | Access logged at Vault when ESO syncs; no per-request app audit | Every app request to Vault is a separate audit log entry |
|
||||||
|
| **Operational burden** | Low — ESO handles polling, sync, and k8s Secret lifecycle | Higher — app must handle auth, lease renewal, error paths, and token rotation |
|
||||||
|
| **Justification required?** | No — this is the default | Yes — document in project README under "Secrets architecture" |
|
||||||
|
| **Example use cases** | Web app DB password, OAuth client secret, JWT signing key, API token | HashiCorp DB secrets engine with 15-min TTL leases, AWS STS assume-role, Vault PKI short-lived certs |
|
||||||
|
|
||||||
|
**Decision rule:** If you are unsure, use ESO. Only justify Direct-Vault when the secret cannot be safely stored in a k8s Secret (too short-lived, per-request TTL required, or mid-lifecycle renewal needed).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ESO Bridge Pattern (Default)
|
||||||
|
|
||||||
|
This is the required default for all k8s workloads. Follow this exact pattern unless a documented dynamic-secrets requirement justifies Direct-Vault.
|
||||||
|
|
||||||
|
### 1. Provision Vault path
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Write the secrets for the app (run once; use IaC/Terraform for repeatable provisioning)
|
||||||
|
vault kv put secret/k3s/<app> \
|
||||||
|
db_password="..." \
|
||||||
|
api_key="..." \
|
||||||
|
jwt_secret="..."
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the canonical path structure: `secret/k3s/<app>` for k3s cluster workloads.
|
||||||
|
|
||||||
|
### 2. ExternalSecret manifest
|
||||||
|
|
||||||
|
Commit this to the repo's `deploy/` or `k8s/` directory:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# deploy/external-secret.yaml
|
||||||
|
apiVersion: external-secrets.io/v1beta1
|
||||||
|
kind: ExternalSecret
|
||||||
|
metadata:
|
||||||
|
name: <app>-secrets
|
||||||
|
namespace: <namespace>
|
||||||
|
spec:
|
||||||
|
refreshInterval: 1h
|
||||||
|
secretStoreRef:
|
||||||
|
name: vault-backend # ClusterSecretStore name — verify with cluster admin
|
||||||
|
kind: ClusterSecretStore
|
||||||
|
target:
|
||||||
|
name: <app>-secrets # k8s Secret name that will be created
|
||||||
|
creationPolicy: Owner
|
||||||
|
data:
|
||||||
|
- secretKey: DB_PASSWORD # key in the k8s Secret
|
||||||
|
remoteRef:
|
||||||
|
key: secret/k3s/<app> # Vault path
|
||||||
|
property: db_password # field within the Vault secret
|
||||||
|
- secretKey: API_KEY
|
||||||
|
remoteRef:
|
||||||
|
key: secret/k3s/<app>
|
||||||
|
property: api_key
|
||||||
|
- secretKey: JWT_SECRET
|
||||||
|
remoteRef:
|
||||||
|
key: secret/k3s/<app>
|
||||||
|
property: jwt_secret
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Deployment manifest — reference synced k8s Secret
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# deploy/deployment.yaml (env section)
|
||||||
|
env:
|
||||||
|
- name: DB_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: <app>-secrets # matches ExternalSecret target.name
|
||||||
|
key: DB_PASSWORD
|
||||||
|
- name: API_KEY
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: <app>-secrets
|
||||||
|
key: API_KEY
|
||||||
|
- name: JWT_SECRET
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: <app>-secrets
|
||||||
|
key: JWT_SECRET
|
||||||
|
- name: PORT
|
||||||
|
value: "3000" # safe-default: non-secret, no Vault needed
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. App-side schema validation — TypeScript (zod)
|
||||||
|
|
||||||
|
Validate all required env vars at startup. Exit non-zero on missing values.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/env.ts
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const envSchema = z.object({
|
||||||
|
DB_PASSWORD: z.string().min(1, 'DB_PASSWORD is required'),
|
||||||
|
API_KEY: z.string().min(1, 'API_KEY is required'),
|
||||||
|
JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 chars'),
|
||||||
|
PORT: z.coerce.number().default(3000),
|
||||||
|
NODE_ENV: z.enum(['development', 'production', 'test']).default('production'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = envSchema.safeParse(process.env);
|
||||||
|
if (!result.success) {
|
||||||
|
console.error('Missing or invalid environment variables:');
|
||||||
|
console.error(result.error.flatten().fieldErrors);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const env = result.data;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4b. App-side schema validation — Python (pydantic)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# src/config.py
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
db_password: str
|
||||||
|
api_key: str
|
||||||
|
jwt_secret: str
|
||||||
|
port: int = 3000
|
||||||
|
node_env: str = "production"
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(env_file=None) # no .env in prod
|
||||||
|
|
||||||
|
try:
|
||||||
|
settings = Settings()
|
||||||
|
except Exception as e:
|
||||||
|
import sys
|
||||||
|
print(f"Missing or invalid environment variables: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4c. App-side schema validation — Go (envconfig)
|
||||||
|
|
||||||
|
```go
|
||||||
|
// config/config.go
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/kelseyhightower/envconfig"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
DBPassword string `envconfig:"DB_PASSWORD" required:"true"`
|
||||||
|
APIKey string `envconfig:"API_KEY" required:"true"`
|
||||||
|
JWTSecret string `envconfig:"JWT_SECRET" required:"true"`
|
||||||
|
Port int `envconfig:"PORT" default:"3000"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() (*Config, error) {
|
||||||
|
var cfg Config
|
||||||
|
if err := envconfig.Process("", &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid environment: %w", err)
|
||||||
|
}
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In your `main.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Direct-Vault Opt-In Pattern
|
||||||
|
|
||||||
|
Use this pattern ONLY when a documented dynamic-secrets requirement applies (DB rotation with short TTLs, AWS STS, PKI, per-request audit). Document the justification in the project README under "Secrets architecture" before implementing.
|
||||||
|
|
||||||
|
### When it is justified
|
||||||
|
|
||||||
|
- Vault DB secrets engine with lease TTLs shorter than a typical pod lifecycle (< 1 hour)
|
||||||
|
- AWS STS assume-role tokens generated per-request
|
||||||
|
- Vault PKI short-lived certificates (< 24 hours) that must be renewed within a running pod
|
||||||
|
- Per-request audit trail requirement (each app call must appear separately in Vault audit log)
|
||||||
|
|
||||||
|
### Provision an AppRole for the app
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Enable AppRole auth (if not already enabled)
|
||||||
|
vault auth enable approle
|
||||||
|
|
||||||
|
# Create a Vault policy for the app
|
||||||
|
# Note: KV v2 paths require both the exact path (for the top-level secret) and the
|
||||||
|
# wildcard (for sub-paths). Always include both to avoid permission denied errors.
|
||||||
|
vault policy write <app>-policy - <<EOF
|
||||||
|
path "secret/data/k3s/<app>" {
|
||||||
|
capabilities = ["read"]
|
||||||
|
}
|
||||||
|
path "secret/data/k3s/<app>/*" {
|
||||||
|
capabilities = ["read"]
|
||||||
|
}
|
||||||
|
path "database/creds/<app>-role" {
|
||||||
|
capabilities = ["read"]
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Create the AppRole
|
||||||
|
vault write auth/approle/role/<app>-role \
|
||||||
|
token_policies="<app>-policy" \
|
||||||
|
token_ttl=1h \
|
||||||
|
token_max_ttl=4h \
|
||||||
|
secret_id_ttl=0
|
||||||
|
|
||||||
|
# Retrieve role-id and secret-id
|
||||||
|
vault read auth/approle/role/<app>-role/role-id
|
||||||
|
vault write -f auth/approle/role/<app>-role/secret-id
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bootstrap AppRole credentials via ESO (solving the chicken-and-egg problem)
|
||||||
|
|
||||||
|
The AppRole `role-id` and `secret-id` are themselves secrets. Store them in Vault at a bootstrap path, then use ESO to sync them into a k8s Secret. The app reads that k8s Secret at startup to authenticate with Vault directly.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Store the bootstrap credentials in Vault
|
||||||
|
vault kv put secret/k3s/<app>-bootstrap \
|
||||||
|
role_id="<role-id>" \
|
||||||
|
secret_id="<secret-id>"
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# deploy/external-secret-bootstrap.yaml
|
||||||
|
apiVersion: external-secrets.io/v1beta1
|
||||||
|
kind: ExternalSecret
|
||||||
|
metadata:
|
||||||
|
name: <app>-vault-auth
|
||||||
|
namespace: <namespace>
|
||||||
|
spec:
|
||||||
|
refreshInterval: 24h
|
||||||
|
secretStoreRef:
|
||||||
|
name: vault-backend
|
||||||
|
kind: ClusterSecretStore
|
||||||
|
target:
|
||||||
|
name: <app>-vault-auth
|
||||||
|
creationPolicy: Owner
|
||||||
|
data:
|
||||||
|
- secretKey: VAULT_ROLE_ID
|
||||||
|
remoteRef:
|
||||||
|
key: secret/k3s/<app>-bootstrap
|
||||||
|
property: role_id
|
||||||
|
- secretKey: VAULT_SECRET_ID
|
||||||
|
remoteRef:
|
||||||
|
key: secret/k3s/<app>-bootstrap
|
||||||
|
property: secret_id
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# deploy/deployment.yaml (env section for Direct-Vault app)
|
||||||
|
env:
|
||||||
|
- name: VAULT_ADDR
|
||||||
|
value: "https://vault.example.com" # safe-default: non-secret cluster address
|
||||||
|
- name: VAULT_ROLE_ID
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: <app>-vault-auth
|
||||||
|
key: VAULT_ROLE_ID
|
||||||
|
- name: VAULT_SECRET_ID
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: <app>-vault-auth
|
||||||
|
key: VAULT_SECRET_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
### App-side Vault client pattern
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/vault-client.ts — only exists in Direct-Vault apps
|
||||||
|
import vault from 'node-vault';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const bootstrapSchema = z.object({
|
||||||
|
VAULT_ADDR: z.string().url(),
|
||||||
|
VAULT_ROLE_ID: z.string().min(1),
|
||||||
|
VAULT_SECRET_ID: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
const bootstrap = bootstrapSchema.parse(process.env);
|
||||||
|
|
||||||
|
const client = vault({ endpoint: bootstrap.VAULT_ADDR });
|
||||||
|
|
||||||
|
export async function getVaultClient() {
|
||||||
|
const { auth } = await client.approleLogin({
|
||||||
|
role_id: bootstrap.VAULT_ROLE_ID,
|
||||||
|
secret_id: bootstrap.VAULT_SECRET_ID,
|
||||||
|
});
|
||||||
|
client.token = auth.client_token;
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Document in README under "Secrets architecture": the Vault path, why Direct-Vault is required, and the lease/renewal strategy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Forbidden Patterns (CI Lint Targets)
|
||||||
|
|
||||||
|
The following patterns are forbidden in all Mosaic projects. CI lint SHOULD catch these automatically (implementation tracked separately). Agents MUST NOT introduce these patterns.
|
||||||
|
|
||||||
|
### 1. Untagged fallback defaults for required values
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# FORBIDDEN — required secret with silent fallback
|
||||||
|
environment:
|
||||||
|
- DB_PASSWORD=${DB_PASSWORD:-changeme}
|
||||||
|
- API_KEY=${API_KEY:-}
|
||||||
|
|
||||||
|
# REQUIRED — fast-fail on missing required values
|
||||||
|
environment:
|
||||||
|
- DB_PASSWORD=${DB_PASSWORD:?DB_PASSWORD is required}
|
||||||
|
- API_KEY=${API_KEY:?API_KEY is required}
|
||||||
|
|
||||||
|
# ALLOWED — true convenience default, tagged
|
||||||
|
environment:
|
||||||
|
- PORT=${PORT:-3000} # safe-default: non-secret, app works at any port
|
||||||
|
```
|
||||||
|
|
||||||
|
This applies to: `docker-compose.yml`, k8s manifests, Helm `values.yaml`, any env file committed to git.
|
||||||
|
|
||||||
|
### 2. Vault KV calls in application source code (ESO-default projects)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# FORBIDDEN in ESO-default apps — direct Vault client in app source
|
||||||
|
import hvac
|
||||||
|
client = hvac.Client(url=os.environ['VAULT_ADDR'])
|
||||||
|
secret = client.secrets.kv.v2.read_secret_version(path='myapp/db')
|
||||||
|
```
|
||||||
|
|
||||||
|
ESO-default apps read env vars only. Direct-Vault clients belong only in apps with a documented dynamic-secrets justification in README.
|
||||||
|
|
||||||
|
### 3. Hardcoded secrets or API keys in committed files
|
||||||
|
|
||||||
|
```python
|
||||||
|
# FORBIDDEN — hardcoded credential
|
||||||
|
DB_PASSWORD = "supersecret123"
|
||||||
|
API_KEY = "sk-live-abc123"
|
||||||
|
```
|
||||||
|
|
||||||
|
No exceptions. CI lint must flag any string matching common secret patterns (`password`, `secret`, `api_key`, `token` assigned a literal non-env-var value).
|
||||||
|
|
||||||
|
### 4. `.env` files in production deployment paths
|
||||||
|
|
||||||
|
```
|
||||||
|
# FORBIDDEN — .env file in a production deploy path
|
||||||
|
deploy/.env
|
||||||
|
k8s/.env
|
||||||
|
docker/.env
|
||||||
|
|
||||||
|
# ALLOWED — local dev only
|
||||||
|
.env.example # template only, no real values
|
||||||
|
.env # local dev, must be in .gitignore
|
||||||
|
```
|
||||||
|
|
||||||
|
`.env` files are acceptable in local-dev contexts only and MUST be in `.gitignore`. They are forbidden in any path that a CI pipeline or production deployment process reads directly.
|
||||||
|
|||||||
@@ -27,6 +27,16 @@ Master/slave model:
|
|||||||
- Do not perform destructive git/file actions without explicit instruction.
|
- Do not perform destructive git/file actions without explicit instruction.
|
||||||
- Browser automation (Playwright, Cypress, Puppeteer) MUST run in headless mode. Never launch a visible browser — it collides with the user's display and active session.
|
- Browser automation (Playwright, Cypress, Puppeteer) MUST run in headless mode. Never launch a visible browser — it collides with the user's display and active session.
|
||||||
|
|
||||||
|
### Secrets handling (HARD RULE)
|
||||||
|
|
||||||
|
- Vault is the canonical source-of-truth for every secret in every environment. No exceptions.
|
||||||
|
- For k8s workloads, the default read path is **External Secrets Operator → k8s Secret → env var** (`secretKeyRef`). The app reads standard env vars; no Vault client in app code.
|
||||||
|
- Direct-Vault clients in application code are **opt-in only**, justified per-app by a documented dynamic-secrets requirement (e.g., DB rotation, AWS STS). Default to ESO. Document the justification in the project's README under "Secrets architecture".
|
||||||
|
- `${VAR:-default}` fallback syntax in any deployment configuration (compose, k8s manifests, Helm values, env files committed to git) is **forbidden** for required values. Use `${VAR:?VAR is required}` to fast-fail. Defaults are allowed only for true conveniences (e.g. `${PORT:-3000}`) and MUST be tagged `# safe-default: <reason>` so a reviewer can confirm the intent.
|
||||||
|
- `.env` files in production deployment paths are **forbidden**. `.env.example` and `.env` in local-dev paths are fine.
|
||||||
|
- App startup MUST validate required secrets against a schema (zod / pydantic / equivalent) and exit non-zero on missing required values. Never run with defaulted weak fallbacks.
|
||||||
|
- New apps: bootstrap checklist (see `~/.config/mosaic/guides/BOOTSTRAP.md`) MUST include Vault path provisioning + `ExternalSecret` manifest + README declaring the Vault path and required keys.
|
||||||
|
|
||||||
## Session Lifecycle Contract
|
## Session Lifecycle Contract
|
||||||
|
|
||||||
- Start: `scripts/agent/session-start.sh`
|
- Start: `scripts/agent/session-start.sh`
|
||||||
|
|||||||
@@ -453,6 +453,26 @@ Initialize standard labels and the first pre-MVP milestone:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Secrets Bootstrap (Required for Every New App)
|
||||||
|
|
||||||
|
Every new application MUST complete the following secrets bootstrap before deploying to any non-local environment. This is a hard gate — deployment without completed secrets bootstrap is forbidden.
|
||||||
|
|
||||||
|
### Secrets bootstrap checklist
|
||||||
|
|
||||||
|
- [ ] Vault path created: `vault kv put secret/k3s/<app>/ ...` with all required secret fields
|
||||||
|
- [ ] Required secrets listed in project README under a "Secrets architecture" section, including:
|
||||||
|
- Vault path(s) used
|
||||||
|
- All required secret keys and their purpose
|
||||||
|
- Whether the app uses ESO bridge (default) or Direct-Vault (opt-in, with justification)
|
||||||
|
- [ ] `external-secret.yaml` manifest committed to repo's `deploy/` or `k8s/` directory
|
||||||
|
- [ ] Deployment YAML references the synced k8s Secret via `secretKeyRef` (not raw env vars or `.env` files)
|
||||||
|
- [ ] App startup has schema-based validation for all required env vars (zod / pydantic / envconfig equivalent) that exits non-zero on missing required values
|
||||||
|
- [ ] Direct-Vault opt-in (if applicable): justification documented in README + AppRole provisioned + bootstrap credentials stored in Vault and synced via a separate `ExternalSecret`
|
||||||
|
|
||||||
|
See `~/.config/mosaic/guides/VAULT-SECRETS.md` for full worked examples of the ESO bridge pattern, the Direct-Vault opt-in pattern, and the forbidden antipatterns.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Checklist
|
## Checklist
|
||||||
|
|
||||||
After bootstrapping, verify:
|
After bootstrapping, verify:
|
||||||
|
|||||||
@@ -203,3 +203,374 @@ Error: token expired
|
|||||||
3. **Audit logging** - All access is logged; act accordingly
|
3. **Audit logging** - All access is logged; act accordingly
|
||||||
4. **No local copies** - Don't store secrets in files or env vars long-term
|
4. **No local copies** - Don't store secrets in files or env vars long-term
|
||||||
5. **Rotate on compromise** - Immediately rotate any exposed secrets
|
5. **Rotate on compromise** - Immediately rotate any exposed secrets
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Secrets Architecture Decision Matrix
|
||||||
|
|
||||||
|
Use this table to choose between the ESO bridge (default) and Direct-Vault (opt-in) patterns for every new app or integration.
|
||||||
|
|
||||||
|
| Factor | ESO Bridge (default) | Direct-Vault (opt-in) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Use-case** | All static secrets (DB creds, API keys, signing keys, OAuth secrets) | Dynamic creds with short TTLs (DB rotation, AWS STS, PKI), per-request audit trails, or lease renewal mid-pod-lifecycle |
|
||||||
|
| **App code change** | None — reads standard env vars via `secretKeyRef` | Requires Vault client (`hvac`, `node-vault`, `vault/api`) in application code |
|
||||||
|
| **Secret rotation** | ESO re-syncs on Vault write; pod restart or secret refresh picks up new value | App manages lease renewal or re-auth within the running process |
|
||||||
|
| **Audit granularity** | Access logged at Vault when ESO syncs; no per-request app audit | Every app request to Vault is a separate audit log entry |
|
||||||
|
| **Operational burden** | Low — ESO handles polling, sync, and k8s Secret lifecycle | Higher — app must handle auth, lease renewal, error paths, and token rotation |
|
||||||
|
| **Justification required?** | No — this is the default | Yes — document in project README under "Secrets architecture" |
|
||||||
|
| **Example use cases** | Web app DB password, OAuth client secret, JWT signing key, API token | HashiCorp DB secrets engine with 15-min TTL leases, AWS STS assume-role, Vault PKI short-lived certs |
|
||||||
|
|
||||||
|
**Decision rule:** If you are unsure, use ESO. Only justify Direct-Vault when the secret cannot be safely stored in a k8s Secret (too short-lived, per-request TTL required, or mid-lifecycle renewal needed).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ESO Bridge Pattern (Default)
|
||||||
|
|
||||||
|
This is the required default for all k8s workloads. Follow this exact pattern unless a documented dynamic-secrets requirement justifies Direct-Vault.
|
||||||
|
|
||||||
|
### 1. Provision Vault path
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Write the secrets for the app (run once; use IaC/Terraform for repeatable provisioning)
|
||||||
|
vault kv put secret/k3s/<app> \
|
||||||
|
db_password="..." \
|
||||||
|
api_key="..." \
|
||||||
|
jwt_secret="..."
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the canonical path structure: `secret/k3s/<app>` for k3s cluster workloads.
|
||||||
|
|
||||||
|
### 2. ExternalSecret manifest
|
||||||
|
|
||||||
|
Commit this to the repo's `deploy/` or `k8s/` directory:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# deploy/external-secret.yaml
|
||||||
|
apiVersion: external-secrets.io/v1beta1
|
||||||
|
kind: ExternalSecret
|
||||||
|
metadata:
|
||||||
|
name: <app>-secrets
|
||||||
|
namespace: <namespace>
|
||||||
|
spec:
|
||||||
|
refreshInterval: 1h
|
||||||
|
secretStoreRef:
|
||||||
|
name: vault-backend # ClusterSecretStore name — verify with cluster admin
|
||||||
|
kind: ClusterSecretStore
|
||||||
|
target:
|
||||||
|
name: <app>-secrets # k8s Secret name that will be created
|
||||||
|
creationPolicy: Owner
|
||||||
|
data:
|
||||||
|
- secretKey: DB_PASSWORD # key in the k8s Secret
|
||||||
|
remoteRef:
|
||||||
|
key: secret/k3s/<app> # Vault path
|
||||||
|
property: db_password # field within the Vault secret
|
||||||
|
- secretKey: API_KEY
|
||||||
|
remoteRef:
|
||||||
|
key: secret/k3s/<app>
|
||||||
|
property: api_key
|
||||||
|
- secretKey: JWT_SECRET
|
||||||
|
remoteRef:
|
||||||
|
key: secret/k3s/<app>
|
||||||
|
property: jwt_secret
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Deployment manifest — reference synced k8s Secret
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# deploy/deployment.yaml (env section)
|
||||||
|
env:
|
||||||
|
- name: DB_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: <app>-secrets # matches ExternalSecret target.name
|
||||||
|
key: DB_PASSWORD
|
||||||
|
- name: API_KEY
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: <app>-secrets
|
||||||
|
key: API_KEY
|
||||||
|
- name: JWT_SECRET
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: <app>-secrets
|
||||||
|
key: JWT_SECRET
|
||||||
|
- name: PORT
|
||||||
|
value: "3000" # safe-default: non-secret, no Vault needed
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. App-side schema validation — TypeScript (zod)
|
||||||
|
|
||||||
|
Validate all required env vars at startup. Exit non-zero on missing values.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/env.ts
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const envSchema = z.object({
|
||||||
|
DB_PASSWORD: z.string().min(1, 'DB_PASSWORD is required'),
|
||||||
|
API_KEY: z.string().min(1, 'API_KEY is required'),
|
||||||
|
JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 chars'),
|
||||||
|
PORT: z.coerce.number().default(3000),
|
||||||
|
NODE_ENV: z.enum(['development', 'production', 'test']).default('production'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = envSchema.safeParse(process.env);
|
||||||
|
if (!result.success) {
|
||||||
|
console.error('Missing or invalid environment variables:');
|
||||||
|
console.error(result.error.flatten().fieldErrors);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const env = result.data;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4b. App-side schema validation — Python (pydantic)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# src/config.py
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
db_password: str
|
||||||
|
api_key: str
|
||||||
|
jwt_secret: str
|
||||||
|
port: int = 3000
|
||||||
|
node_env: str = "production"
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(env_file=None) # no .env in prod
|
||||||
|
|
||||||
|
try:
|
||||||
|
settings = Settings()
|
||||||
|
except Exception as e:
|
||||||
|
import sys
|
||||||
|
print(f"Missing or invalid environment variables: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4c. App-side schema validation — Go (envconfig)
|
||||||
|
|
||||||
|
```go
|
||||||
|
// config/config.go
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/kelseyhightower/envconfig"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
DBPassword string `envconfig:"DB_PASSWORD" required:"true"`
|
||||||
|
APIKey string `envconfig:"API_KEY" required:"true"`
|
||||||
|
JWTSecret string `envconfig:"JWT_SECRET" required:"true"`
|
||||||
|
Port int `envconfig:"PORT" default:"3000"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() (*Config, error) {
|
||||||
|
var cfg Config
|
||||||
|
if err := envconfig.Process("", &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid environment: %w", err)
|
||||||
|
}
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In your `main.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Direct-Vault Opt-In Pattern
|
||||||
|
|
||||||
|
Use this pattern ONLY when a documented dynamic-secrets requirement applies (DB rotation with short TTLs, AWS STS, PKI, per-request audit). Document the justification in the project README under "Secrets architecture" before implementing.
|
||||||
|
|
||||||
|
### When it is justified
|
||||||
|
|
||||||
|
- Vault DB secrets engine with lease TTLs shorter than a typical pod lifecycle (< 1 hour)
|
||||||
|
- AWS STS assume-role tokens generated per-request
|
||||||
|
- Vault PKI short-lived certificates (< 24 hours) that must be renewed within a running pod
|
||||||
|
- Per-request audit trail requirement (each app call must appear separately in Vault audit log)
|
||||||
|
|
||||||
|
### Provision an AppRole for the app
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Enable AppRole auth (if not already enabled)
|
||||||
|
vault auth enable approle
|
||||||
|
|
||||||
|
# Create a Vault policy for the app
|
||||||
|
# Note: KV v2 paths require both the exact path (for the top-level secret) and the
|
||||||
|
# wildcard (for sub-paths). Always include both to avoid permission denied errors.
|
||||||
|
vault policy write <app>-policy - <<EOF
|
||||||
|
path "secret/data/k3s/<app>" {
|
||||||
|
capabilities = ["read"]
|
||||||
|
}
|
||||||
|
path "secret/data/k3s/<app>/*" {
|
||||||
|
capabilities = ["read"]
|
||||||
|
}
|
||||||
|
path "database/creds/<app>-role" {
|
||||||
|
capabilities = ["read"]
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Create the AppRole
|
||||||
|
vault write auth/approle/role/<app>-role \
|
||||||
|
token_policies="<app>-policy" \
|
||||||
|
token_ttl=1h \
|
||||||
|
token_max_ttl=4h \
|
||||||
|
secret_id_ttl=0
|
||||||
|
|
||||||
|
# Retrieve role-id and secret-id
|
||||||
|
vault read auth/approle/role/<app>-role/role-id
|
||||||
|
vault write -f auth/approle/role/<app>-role/secret-id
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bootstrap AppRole credentials via ESO (solving the chicken-and-egg problem)
|
||||||
|
|
||||||
|
The AppRole `role-id` and `secret-id` are themselves secrets. Store them in Vault at a bootstrap path, then use ESO to sync them into a k8s Secret. The app reads that k8s Secret at startup to authenticate with Vault directly.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Store the bootstrap credentials in Vault
|
||||||
|
vault kv put secret/k3s/<app>-bootstrap \
|
||||||
|
role_id="<role-id>" \
|
||||||
|
secret_id="<secret-id>"
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# deploy/external-secret-bootstrap.yaml
|
||||||
|
apiVersion: external-secrets.io/v1beta1
|
||||||
|
kind: ExternalSecret
|
||||||
|
metadata:
|
||||||
|
name: <app>-vault-auth
|
||||||
|
namespace: <namespace>
|
||||||
|
spec:
|
||||||
|
refreshInterval: 24h
|
||||||
|
secretStoreRef:
|
||||||
|
name: vault-backend
|
||||||
|
kind: ClusterSecretStore
|
||||||
|
target:
|
||||||
|
name: <app>-vault-auth
|
||||||
|
creationPolicy: Owner
|
||||||
|
data:
|
||||||
|
- secretKey: VAULT_ROLE_ID
|
||||||
|
remoteRef:
|
||||||
|
key: secret/k3s/<app>-bootstrap
|
||||||
|
property: role_id
|
||||||
|
- secretKey: VAULT_SECRET_ID
|
||||||
|
remoteRef:
|
||||||
|
key: secret/k3s/<app>-bootstrap
|
||||||
|
property: secret_id
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# deploy/deployment.yaml (env section for Direct-Vault app)
|
||||||
|
env:
|
||||||
|
- name: VAULT_ADDR
|
||||||
|
value: "https://vault.example.com" # safe-default: non-secret cluster address
|
||||||
|
- name: VAULT_ROLE_ID
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: <app>-vault-auth
|
||||||
|
key: VAULT_ROLE_ID
|
||||||
|
- name: VAULT_SECRET_ID
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: <app>-vault-auth
|
||||||
|
key: VAULT_SECRET_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
### App-side Vault client pattern
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/vault-client.ts — only exists in Direct-Vault apps
|
||||||
|
import vault from 'node-vault';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const bootstrapSchema = z.object({
|
||||||
|
VAULT_ADDR: z.string().url(),
|
||||||
|
VAULT_ROLE_ID: z.string().min(1),
|
||||||
|
VAULT_SECRET_ID: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
const bootstrap = bootstrapSchema.parse(process.env);
|
||||||
|
|
||||||
|
const client = vault({ endpoint: bootstrap.VAULT_ADDR });
|
||||||
|
|
||||||
|
export async function getVaultClient() {
|
||||||
|
const { auth } = await client.approleLogin({
|
||||||
|
role_id: bootstrap.VAULT_ROLE_ID,
|
||||||
|
secret_id: bootstrap.VAULT_SECRET_ID,
|
||||||
|
});
|
||||||
|
client.token = auth.client_token;
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Document in README under "Secrets architecture": the Vault path, why Direct-Vault is required, and the lease/renewal strategy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Forbidden Patterns (CI Lint Targets)
|
||||||
|
|
||||||
|
The following patterns are forbidden in all Mosaic projects. CI lint SHOULD catch these automatically (implementation tracked separately). Agents MUST NOT introduce these patterns.
|
||||||
|
|
||||||
|
### 1. Untagged fallback defaults for required values
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# FORBIDDEN — required secret with silent fallback
|
||||||
|
environment:
|
||||||
|
- DB_PASSWORD=${DB_PASSWORD:-changeme}
|
||||||
|
- API_KEY=${API_KEY:-}
|
||||||
|
|
||||||
|
# REQUIRED — fast-fail on missing required values
|
||||||
|
environment:
|
||||||
|
- DB_PASSWORD=${DB_PASSWORD:?DB_PASSWORD is required}
|
||||||
|
- API_KEY=${API_KEY:?API_KEY is required}
|
||||||
|
|
||||||
|
# ALLOWED — true convenience default, tagged
|
||||||
|
environment:
|
||||||
|
- PORT=${PORT:-3000} # safe-default: non-secret, app works at any port
|
||||||
|
```
|
||||||
|
|
||||||
|
This applies to: `docker-compose.yml`, k8s manifests, Helm `values.yaml`, any env file committed to git.
|
||||||
|
|
||||||
|
### 2. Vault KV calls in application source code (ESO-default projects)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# FORBIDDEN in ESO-default apps — direct Vault client in app source
|
||||||
|
import hvac
|
||||||
|
client = hvac.Client(url=os.environ['VAULT_ADDR'])
|
||||||
|
secret = client.secrets.kv.v2.read_secret_version(path='myapp/db')
|
||||||
|
```
|
||||||
|
|
||||||
|
ESO-default apps read env vars only. Direct-Vault clients belong only in apps with a documented dynamic-secrets justification in README.
|
||||||
|
|
||||||
|
### 3. Hardcoded secrets or API keys in committed files
|
||||||
|
|
||||||
|
```python
|
||||||
|
# FORBIDDEN — hardcoded credential
|
||||||
|
DB_PASSWORD = "supersecret123"
|
||||||
|
API_KEY = "sk-live-abc123"
|
||||||
|
```
|
||||||
|
|
||||||
|
No exceptions. CI lint must flag any string matching common secret patterns (`password`, `secret`, `api_key`, `token` assigned a literal non-env-var value).
|
||||||
|
|
||||||
|
### 4. `.env` files in production deployment paths
|
||||||
|
|
||||||
|
```
|
||||||
|
# FORBIDDEN — .env file in a production deploy path
|
||||||
|
deploy/.env
|
||||||
|
k8s/.env
|
||||||
|
docker/.env
|
||||||
|
|
||||||
|
# ALLOWED — local dev only
|
||||||
|
.env.example # template only, no real values
|
||||||
|
.env # local dev, must be in .gitignore
|
||||||
|
```
|
||||||
|
|
||||||
|
`.env` files are acceptable in local-dev contexts only and MUST be in `.gitignore`. They are forbidden in any path that a CI pipeline or production deployment process reads directly.
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ get_remote_host() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Resolve a Gitea API token for the given host.
|
# Resolve a Gitea API token for the given host.
|
||||||
# Priority: Mosaic credential loader → host-matched GITEA_TOKEN env → ~/.git-credentials
|
# Priority: Mosaic credential loader → GITEA_TOKEN env → ~/.git-credentials
|
||||||
get_gitea_token() {
|
get_gitea_token() {
|
||||||
local host="$1"
|
local host="$1"
|
||||||
local script_dir
|
local script_dir
|
||||||
@@ -103,28 +103,16 @@ get_gitea_token() {
|
|||||||
if [[ -f "$cred_loader" ]]; then
|
if [[ -f "$cred_loader" ]]; then
|
||||||
local token
|
local token
|
||||||
token=$(
|
token=$(
|
||||||
# shellcheck source=/dev/null
|
|
||||||
source "$cred_loader"
|
source "$cred_loader"
|
||||||
# Host-specific wrapper resolution must not inherit a caller/global GITEA_TOKEN.
|
|
||||||
# load_credentials intentionally preserves existing env vars for interactive use,
|
|
||||||
# but merge/metadata wrappers need the token matching the remote host.
|
|
||||||
unset GITEA_TOKEN GITEA_URL
|
|
||||||
case "$host" in
|
case "$host" in
|
||||||
git.mosaicstack.dev) load_credentials gitea-mosaicstack 2>/dev/null ;;
|
git.mosaicstack.dev) load_credentials gitea-mosaicstack 2>/dev/null ;;
|
||||||
git.uscllc.com) load_credentials gitea-usc 2>/dev/null ;;
|
git.uscllc.com) load_credentials gitea-usc 2>/dev/null ;;
|
||||||
*)
|
*)
|
||||||
local matched=false
|
|
||||||
for svc in gitea-mosaicstack gitea-usc; do
|
for svc in gitea-mosaicstack gitea-usc; do
|
||||||
unset GITEA_TOKEN GITEA_URL
|
|
||||||
load_credentials "$svc" 2>/dev/null || continue
|
load_credentials "$svc" 2>/dev/null || continue
|
||||||
if [[ "${GITEA_URL:-}" == "https://$host" || "${GITEA_URL:-}" == "http://$host" || "${GITEA_URL:-}" == *"//$host" ]]; then
|
[[ "${GITEA_URL:-}" == *"$host"* ]] && break
|
||||||
matched=true
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
if [[ "$matched" != true ]]; then
|
|
||||||
unset GITEA_TOKEN GITEA_URL
|
unset GITEA_TOKEN GITEA_URL
|
||||||
fi
|
done
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
echo "${GITEA_TOKEN:-}"
|
echo "${GITEA_TOKEN:-}"
|
||||||
@@ -135,12 +123,10 @@ get_gitea_token() {
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 2. GITEA_TOKEN env var (only when GITEA_URL, if present, matches the remote host)
|
# 2. GITEA_TOKEN env var (may be set by caller)
|
||||||
if [[ -n "${GITEA_TOKEN:-}" ]]; then
|
if [[ -n "${GITEA_TOKEN:-}" ]]; then
|
||||||
if [[ -z "${GITEA_URL:-}" || "${GITEA_URL:-}" == "https://$host" || "${GITEA_URL:-}" == "http://$host" || "${GITEA_URL:-}" == *"//$host" ]]; then
|
echo "$GITEA_TOKEN"
|
||||||
echo "$GITEA_TOKEN"
|
return 0
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 3. ~/.git-credentials file
|
# 3. ~/.git-credentials file
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# pr-merge.sh - Merge pull requests on Gitea or GitHub
|
# pr-merge.sh - Merge pull requests on Gitea or GitHub
|
||||||
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--skip-queue-guard] [--dry-run]
|
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--skip-queue-guard]
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
# shellcheck disable=SC1091
|
|
||||||
source "$SCRIPT_DIR/detect-platform.sh"
|
source "$SCRIPT_DIR/detect-platform.sh"
|
||||||
|
|
||||||
# Default values
|
# Default values
|
||||||
@@ -13,7 +12,6 @@ PR_NUMBER=""
|
|||||||
MERGE_METHOD="squash"
|
MERGE_METHOD="squash"
|
||||||
DELETE_BRANCH=false
|
DELETE_BRANCH=false
|
||||||
SKIP_QUEUE_GUARD=false
|
SKIP_QUEUE_GUARD=false
|
||||||
DRY_RUN=false
|
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
@@ -26,7 +24,6 @@ Options:
|
|||||||
-m, --method METHOD Merge method: squash only (default: squash)
|
-m, --method METHOD Merge method: squash only (default: squash)
|
||||||
-d, --delete-branch Delete the head branch after merge
|
-d, --delete-branch Delete the head branch after merge
|
||||||
--skip-queue-guard Skip CI queue guard wait before merge
|
--skip-queue-guard Skip CI queue guard wait before merge
|
||||||
--dry-run Validate metadata/auth/preflight without merging
|
|
||||||
-h, --help Show this help message
|
-h, --help Show this help message
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
@@ -57,10 +54,6 @@ while [[ $# -gt 0 ]]; do
|
|||||||
SKIP_QUEUE_GUARD=true
|
SKIP_QUEUE_GUARD=true
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
--dry-run)
|
|
||||||
DRY_RUN=true
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
-h|--help)
|
-h|--help)
|
||||||
usage
|
usage
|
||||||
;;
|
;;
|
||||||
@@ -81,8 +74,7 @@ if [[ "$MERGE_METHOD" != "squash" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
METADATA_JSON="$("$SCRIPT_DIR/pr-metadata.sh" -n "$PR_NUMBER")"
|
BASE_BRANCH="$("$SCRIPT_DIR/pr-metadata.sh" -n "$PR_NUMBER" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("baseRefName") or "").strip())')"
|
||||||
BASE_BRANCH="$(printf '%s' "$METADATA_JSON" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("baseRefName") or "").strip())')"
|
|
||||||
if [[ "$BASE_BRANCH" != "main" ]]; then
|
if [[ "$BASE_BRANCH" != "main" ]]; then
|
||||||
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' (found '$BASE_BRANCH')." >&2
|
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' (found '$BASE_BRANCH')." >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -102,55 +94,19 @@ REPO=$(get_repo_name)
|
|||||||
|
|
||||||
case "$PLATFORM" in
|
case "$PLATFORM" in
|
||||||
github)
|
github)
|
||||||
if [[ "$DRY_RUN" == true ]]; then
|
CMD="gh pr merge $PR_NUMBER --squash"
|
||||||
echo "Dry run: GitHub merge preflight OK for ${OWNER}/${REPO}#${PR_NUMBER} targeting ${BASE_BRANCH}"
|
[[ "$DELETE_BRANCH" == true ]] && CMD="$CMD --delete-branch"
|
||||||
exit 0
|
eval "$CMD"
|
||||||
fi
|
|
||||||
CMD=(gh pr merge "$PR_NUMBER" --squash)
|
|
||||||
[[ "$DELETE_BRANCH" == true ]] && CMD+=(--delete-branch)
|
|
||||||
"${CMD[@]}"
|
|
||||||
;;
|
;;
|
||||||
gitea)
|
gitea)
|
||||||
HOST=$(get_remote_host) || {
|
CMD="tea pr merge $PR_NUMBER --style squash --repo $OWNER/$REPO --login ${GITEA_LOGIN:-mosaicstack}"
|
||||||
echo "Error: Cannot determine host from remote URL" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
TOKEN=$(get_gitea_token "$HOST") || {
|
|
||||||
echo "Error: Could not resolve Gitea API token for ${HOST}" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
if [[ "$DRY_RUN" == true ]]; then
|
|
||||||
echo "Dry run: Gitea merge preflight OK for ${OWNER}/${REPO}#${PR_NUMBER} targeting ${BASE_BRANCH} via ${HOST} API"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
RESPONSE_FILE=$(mktemp)
|
|
||||||
trap 'rm -f "$RESPONSE_FILE"' EXIT
|
|
||||||
HTTP_CODE=$(curl -sS \
|
|
||||||
-X POST \
|
|
||||||
-H "Authorization: token $TOKEN" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"Do":"squash"}' \
|
|
||||||
-o "$RESPONSE_FILE" \
|
|
||||||
-w '%{http_code}' \
|
|
||||||
"https://${HOST}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge")
|
|
||||||
RESPONSE_BODY=$(cat "$RESPONSE_FILE")
|
|
||||||
rm -f "$RESPONSE_FILE"
|
|
||||||
trap - EXIT
|
|
||||||
|
|
||||||
if [[ ! "$HTTP_CODE" =~ ^2 ]]; then
|
|
||||||
echo "Error: Gitea PR merge failed for ${OWNER}/${REPO}#${PR_NUMBER} (HTTP ${HTTP_CODE})" >&2
|
|
||||||
if [[ -n "$RESPONSE_BODY" ]]; then
|
|
||||||
printf '%s\n' "$RESPONSE_BODY" >&2
|
|
||||||
fi
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Delete branch after merge if requested
|
# Delete branch after merge if requested
|
||||||
if [[ "$DELETE_BRANCH" == true ]]; then
|
if [[ "$DELETE_BRANCH" == true ]]; then
|
||||||
echo "Note: Branch deletion after merge may need to be done separately with the Gitea API" >&2
|
echo "Note: Branch deletion after merge may need to be done separately with tea" >&2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
eval "$CMD"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Error: Could not detect git platform" >&2
|
echo "Error: Could not detect git platform" >&2
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
set -e
|
set -e
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
# shellcheck disable=SC1091
|
|
||||||
source "$SCRIPT_DIR/detect-platform.sh"
|
source "$SCRIPT_DIR/detect-platform.sh"
|
||||||
|
|
||||||
# Parse arguments
|
# Parse arguments
|
||||||
@@ -56,51 +55,39 @@ if [[ "$PLATFORM" == "github" ]]; then
|
|||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
OWNER=$(get_repo_owner)
|
OWNER=$(get_repo_owner)
|
||||||
REPO=$(get_repo_name)
|
REPO=$(get_repo_name)
|
||||||
HOST=$(get_remote_host) || {
|
REMOTE_URL=$(git remote get-url origin 2>/dev/null)
|
||||||
|
|
||||||
|
# Extract host from remote URL
|
||||||
|
if [[ "$REMOTE_URL" == https://* ]]; then
|
||||||
|
HOST=$(echo "$REMOTE_URL" | sed -E 's|https://([^/]+)/.*|\1|')
|
||||||
|
elif [[ "$REMOTE_URL" == git@* ]]; then
|
||||||
|
HOST=$(echo "$REMOTE_URL" | sed -E 's|git@([^:]+):.*|\1|')
|
||||||
|
else
|
||||||
echo "Error: Cannot determine host from remote URL" >&2
|
echo "Error: Cannot determine host from remote URL" >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
fi
|
||||||
|
|
||||||
API_URL="https://${HOST}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}"
|
API_URL="https://${HOST}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}"
|
||||||
|
|
||||||
GITEA_API_TOKEN=$(get_gitea_token "$HOST" || true)
|
GITEA_API_TOKEN=$(get_gitea_token "$HOST" || true)
|
||||||
|
|
||||||
RESPONSE_FILE=$(mktemp)
|
|
||||||
trap 'rm -f "$RESPONSE_FILE"' EXIT
|
|
||||||
if [[ -n "$GITEA_API_TOKEN" ]]; then
|
if [[ -n "$GITEA_API_TOKEN" ]]; then
|
||||||
HTTP_CODE=$(curl -sS -H "Authorization: token $GITEA_API_TOKEN" -o "$RESPONSE_FILE" -w '%{http_code}' "$API_URL")
|
RAW=$(curl -sS -H "Authorization: token $GITEA_API_TOKEN" "$API_URL")
|
||||||
else
|
else
|
||||||
HTTP_CODE=$(curl -sS -o "$RESPONSE_FILE" -w '%{http_code}' "$API_URL")
|
RAW=$(curl -sS "$API_URL")
|
||||||
fi
|
|
||||||
RAW=$(cat "$RESPONSE_FILE")
|
|
||||||
rm -f "$RESPONSE_FILE"
|
|
||||||
trap - EXIT
|
|
||||||
|
|
||||||
if [[ ! "$HTTP_CODE" =~ ^2 ]]; then
|
|
||||||
echo "Error: Gitea PR metadata request failed for ${OWNER}/${REPO}#${PR_NUMBER} (HTTP ${HTTP_CODE})" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Normalize Gitea response to match our expected schema
|
# Normalize Gitea response to match our expected schema
|
||||||
METADATA=$(echo "$RAW" | python3 -c "
|
METADATA=$(echo "$RAW" | python3 -c "
|
||||||
import json, sys
|
import json, sys
|
||||||
data = json.load(sys.stdin)
|
data = json.load(sys.stdin)
|
||||||
if 'message' in data and not data.get('number'):
|
|
||||||
raise SystemExit('Error: Gitea PR metadata response did not contain PR data')
|
|
||||||
head = data.get('head') or {}
|
|
||||||
head_ref = head.get('ref') or ''
|
|
||||||
head_label = head.get('label') or ''
|
|
||||||
# Gitea can report closed/merged PR heads as refs/pull/<n>/head; callers need
|
|
||||||
# the source branch name equivalent to GitHub headRefName, so prefer label then.
|
|
||||||
if head_ref.startswith('refs/pull/') and head_label:
|
|
||||||
head_ref = head_label
|
|
||||||
normalized = {
|
normalized = {
|
||||||
'number': data.get('number'),
|
'number': data.get('number'),
|
||||||
'title': data.get('title'),
|
'title': data.get('title'),
|
||||||
'body': data.get('body', ''),
|
'body': data.get('body', ''),
|
||||||
'state': data.get('state'),
|
'state': data.get('state'),
|
||||||
'author': data.get('user', {}).get('login', ''),
|
'author': data.get('user', {}).get('login', ''),
|
||||||
'headRefName': head_ref,
|
'headRefName': data.get('head', {}).get('ref', ''),
|
||||||
'baseRefName': data.get('base', {}).get('ref', ''),
|
'baseRefName': data.get('base', {}).get('ref', ''),
|
||||||
'labels': [l.get('name', '') for l in data.get('labels', [])],
|
'labels': [l.get('name', '') for l in data.get('labels', [])],
|
||||||
'assignees': [a.get('login', '') for a in data.get('assignees', [])],
|
'assignees': [a.get('login', '') for a in data.get('assignees', [])],
|
||||||
|
|||||||
@@ -1,116 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Regression harness for Gitea PR metadata normalization and merge preflight.
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
GIT_TOOLS_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
||||||
TEST_ROOT="${TEST_ROOT:-$(pwd)/.test-output/pr-gitea-wrapper-regression}"
|
|
||||||
FAKE_BIN="$TEST_ROOT/bin"
|
|
||||||
FAKE_REPO="$TEST_ROOT/repo"
|
|
||||||
|
|
||||||
rm -rf "$TEST_ROOT"
|
|
||||||
mkdir -p "$FAKE_BIN" "$FAKE_REPO" "$TEST_ROOT/state"
|
|
||||||
|
|
||||||
cat > "$FAKE_BIN/git" <<'SH'
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
if [[ "$*" == "remote get-url origin" ]]; then
|
|
||||||
echo "https://git.uscllc.com/usc/uconnect.git"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "unexpected git invocation: $*" >&2
|
|
||||||
exit 2
|
|
||||||
SH
|
|
||||||
chmod +x "$FAKE_BIN/git"
|
|
||||||
|
|
||||||
cat > "$FAKE_BIN/curl" <<'SH'
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
method="GET"
|
|
||||||
out_file=""
|
|
||||||
write_format=""
|
|
||||||
url=""
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case "$1" in
|
|
||||||
-X)
|
|
||||||
method="$2"; shift 2 ;;
|
|
||||||
-o)
|
|
||||||
out_file="$2"; shift 2 ;;
|
|
||||||
-w)
|
|
||||||
write_format="$2"; shift 2 ;;
|
|
||||||
-H|-d)
|
|
||||||
shift 2 ;;
|
|
||||||
-s|-S|-f|-k|-sS|-fsS)
|
|
||||||
shift ;;
|
|
||||||
http*)
|
|
||||||
url="$1"; shift ;;
|
|
||||||
*)
|
|
||||||
shift ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
body='{}'
|
|
||||||
code="200"
|
|
||||||
if [[ "$method" == "GET" && "$url" == *"/api/v1/repos/usc/uconnect/pulls/1908" ]]; then
|
|
||||||
body='{"number":1908,"title":"Test PR","body":"","state":"open","user":{"login":"edith"},"head":{"label":"fix/t_23fa9e1d-portal-health-backend","ref":"refs/pull/1908/head","sha":"abc123"},"base":{"label":"main","ref":"main","sha":"def456"},"labels":[],"assignees":[],"created_at":"2026-05-22T00:00:00Z","updated_at":"2026-05-22T00:00:00Z","html_url":"https://git.uscllc.com/usc/uconnect/pulls/1908","draft":false,"mergeable":true,"diff_url":"https://git.uscllc.com/usc/uconnect/pulls/1908.diff"}'
|
|
||||||
elif [[ "$method" == "POST" && "$url" == *"/api/v1/repos/usc/uconnect/pulls/1908/merge" ]]; then
|
|
||||||
echo "$url" > "${TEST_ROOT:?}/state/merge-url"
|
|
||||||
body='{"merged":true}'
|
|
||||||
else
|
|
||||||
code="404"
|
|
||||||
body='{"message":"not found"}'
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "$out_file" ]]; then
|
|
||||||
printf '%s' "$body" > "$out_file"
|
|
||||||
else
|
|
||||||
printf '%s' "$body"
|
|
||||||
fi
|
|
||||||
if [[ -n "$write_format" ]]; then
|
|
||||||
printf '%s' "$code"
|
|
||||||
fi
|
|
||||||
SH
|
|
||||||
chmod +x "$FAKE_BIN/curl"
|
|
||||||
|
|
||||||
cat > "$FAKE_BIN/tea" <<'SH'
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
echo "tea must not be invoked by Gitea merge preflight" >&2
|
|
||||||
exit 99
|
|
||||||
SH
|
|
||||||
chmod +x "$FAKE_BIN/tea"
|
|
||||||
|
|
||||||
cat > "$TEST_ROOT/credentials.json" <<'JSON'
|
|
||||||
{
|
|
||||||
"gitea": {
|
|
||||||
"usc": {"url": "https://git.uscllc.com", "token": "fake-token-usc"},
|
|
||||||
"mosaicstack": {"url": "https://git.mosaicstack.dev", "token": "fake-token-mosaic"}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
JSON
|
|
||||||
|
|
||||||
export PATH="$FAKE_BIN:$PATH"
|
|
||||||
export TEST_ROOT
|
|
||||||
export MOSAIC_CREDENTIALS_FILE="$TEST_ROOT/credentials.json"
|
|
||||||
cd "$FAKE_REPO"
|
|
||||||
|
|
||||||
metadata="$("$GIT_TOOLS_DIR/pr-metadata.sh" -n 1908)"
|
|
||||||
python3 - "$metadata" <<'PY'
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
metadata = json.loads(sys.argv[1])
|
|
||||||
assert metadata["baseRefName"] == "main", metadata
|
|
||||||
assert metadata["headRefName"] == "fix/t_23fa9e1d-portal-health-backend", metadata
|
|
||||||
PY
|
|
||||||
|
|
||||||
merge_output="$("$GIT_TOOLS_DIR/pr-merge.sh" -n 1908 -m squash --skip-queue-guard --dry-run 2>&1)"
|
|
||||||
if grep -q "mosaicstack\|Login name\|tea must not" <<<"$merge_output"; then
|
|
||||||
echo "$merge_output" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if ! grep -q "Dry run: Gitea merge preflight OK" <<<"$merge_output"; then
|
|
||||||
echo "$merge_output" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
printf 'Gitea PR metadata and merge preflight regression passed\n'
|
|
||||||
Reference in New Issue
Block a user