fix(approach-c): full E2E success on marcelle - byte-identical templates + core-only recreate

This session completed Approach C end-to-end on marcelle (status=COMPLETED,
mkdocs untouched, idempotent on re-run). Four fixes landed:

1. template-engine.ts: dropped nginx/conf.d/*.hbs (default, api, services)
   from renderAllTemplates AND renderAllTemplatesInMemory. The new
   prod-style docker-compose.yml.hbs does NOT mount conf.d/ into the
   nginx container ("Note: conf.d is NOT mounted (configs are generated
   at startup from templates)" — nginx confs are baked into the nginx
   Docker image). Writing them was a no-op orphan that showed up as 3
   "modified" lines in preview unnecessarily.
   Same reason removed nginx/nginx.conf from staticFiles.

2. templates/configs/{pangolin/resources.yml,prometheus/prometheus.yml,
   grafana/datasources/datasources.yml}.hbs: synced byte-identical to
   canonical changemaker.lite/configs/*. These ARE mounted into pangolin
   tunnel + prometheus + grafana respectively. Preview now reports
   "unchanged" for them on install.sh tenants.

3. templates/docker-compose.yml.hbs: dropped the CCP-tenant header
   comment, making the template now BYTE-IDENTICAL (58907 bytes) to
   canonical changemaker.lite/docker-compose.prod.yml. Even a 1-byte
   comment difference caused docker compose to compute new config hashes
   for every service, triggering full-stack recreates (including
   ccp-agent — the Phase 6 self-destruct trap from upgrade.sh).

4. upgrade.service.ts:runReleaseUpgrade — composeUp now restricted to
   core app services [api, admin, media-api, nginx] (same set as
   image-upgrade.sh). Unscoped composeUp would recreate ccp-agent
   mid-apply and orphan the runner. Until Approach C inherits the
   deferred-ccp-agent-restart pattern from upgrade.sh, this restriction
   keeps the apply path safe. Limitation: brand-new services in a
   release won't auto-deploy via Approach C alone — operator must
   follow with Approach A (full upgrade.sh) to pick them up.

E2E verification on marcelle:
  - Apply: status=COMPLETED, duration<10s.
  - mkdocs.yml md5 unchanged (38810d9df8b4258ad46a6739232cf88a).
  - mkdocs/docs file count unchanged (242).
  - docker-compose.yml now byte-identical to canonical (58907 bytes).
  - app + api public sites: 200 both.
  - Re-preview: ALL 10 files show "unchanged" — true idempotency.

Phase 6 acceptance gate met. Approach C now fully operational on the
install.sh fleet.

Bunker Admin
This commit is contained in:
bunker-admin 2026-05-23 11:00:38 -06:00
parent 8af11af720
commit 5331cdcc67
6 changed files with 198 additions and 163 deletions

View File

@ -243,12 +243,15 @@ export async function renderAllTemplates(context: TemplateContext, outputDir: st
const templatesDir = path.resolve(__dirname, '../..', 'templates'); const templatesDir = path.resolve(__dirname, '../..', 'templates');
// Templates that produce on-disk files actually consumed by tenant containers.
// nginx/conf.d/* templates removed 2026-05-23: the new (prod-style)
// docker-compose.yml does NOT mount conf.d/ into the nginx container
// ("Note: conf.d is NOT mounted (configs are generated at startup from
// templates)" — nginx confs are baked into the nginx Docker image).
// Writing them was a no-op orphan.
const templateFiles = [ const templateFiles = [
{ template: 'docker-compose.yml.hbs', output: 'docker-compose.yml' }, { template: 'docker-compose.yml.hbs', output: 'docker-compose.yml' },
{ template: 'env.hbs', output: '.env' }, { template: 'env.hbs', output: '.env' },
{ template: 'nginx/conf.d/default.conf.hbs', output: 'nginx/conf.d/default.conf' },
{ template: 'nginx/conf.d/api.conf.hbs', output: 'nginx/conf.d/api.conf' },
{ template: 'nginx/conf.d/services.conf.hbs', output: 'nginx/conf.d/services.conf' },
{ template: 'configs/pangolin/resources.yml.hbs', output: 'configs/pangolin/resources.yml' }, { template: 'configs/pangolin/resources.yml.hbs', output: 'configs/pangolin/resources.yml' },
{ template: 'configs/prometheus/prometheus.yml.hbs', output: 'configs/prometheus/prometheus.yml' }, { template: 'configs/prometheus/prometheus.yml.hbs', output: 'configs/prometheus/prometheus.yml' },
{ template: 'configs/grafana/datasources/datasources.yml.hbs', output: 'configs/grafana/datasources/datasources.yml' }, { template: 'configs/grafana/datasources/datasources.yml.hbs', output: 'configs/grafana/datasources/datasources.yml' },
@ -272,9 +275,10 @@ export async function renderAllTemplates(context: TemplateContext, outputDir: st
logger.debug(`Rendered ${template}${outputPath}`); logger.debug(`Rendered ${template}${outputPath}`);
} }
// Copy static files (no templating needed) // Copy static files (no templating needed). nginx/nginx.conf removed
// 2026-05-23 — same reason as nginx/conf.d/* in templateFiles above
// (nginx image bakes its own; on-disk file is an orphan).
const staticFiles = [ const staticFiles = [
'nginx/nginx.conf',
'configs/prometheus/alerts.yml', 'configs/prometheus/alerts.yml',
'configs/alertmanager/alertmanager.yml', 'configs/alertmanager/alertmanager.yml',
'configs/grafana/dashboards/dashboards.yml', 'configs/grafana/dashboards/dashboards.yml',
@ -311,12 +315,15 @@ export async function renderAllTemplatesInMemory(
const templatesDir = path.resolve(__dirname, '../..', 'templates'); const templatesDir = path.resolve(__dirname, '../..', 'templates');
const result: Array<{ relativePath: string; content: string }> = []; const result: Array<{ relativePath: string; content: string }> = [];
// Templates that produce on-disk files actually consumed by tenant containers.
// nginx/conf.d/* templates removed 2026-05-23: the new (prod-style)
// docker-compose.yml does NOT mount conf.d/ into the nginx container
// ("Note: conf.d is NOT mounted (configs are generated at startup from
// templates)" — nginx confs are baked into the nginx Docker image).
// Writing them was a no-op orphan.
const templateFiles = [ const templateFiles = [
{ template: 'docker-compose.yml.hbs', output: 'docker-compose.yml' }, { template: 'docker-compose.yml.hbs', output: 'docker-compose.yml' },
{ template: 'env.hbs', output: '.env' }, { template: 'env.hbs', output: '.env' },
{ template: 'nginx/conf.d/default.conf.hbs', output: 'nginx/conf.d/default.conf' },
{ template: 'nginx/conf.d/api.conf.hbs', output: 'nginx/conf.d/api.conf' },
{ template: 'nginx/conf.d/services.conf.hbs', output: 'nginx/conf.d/services.conf' },
{ template: 'configs/pangolin/resources.yml.hbs', output: 'configs/pangolin/resources.yml' }, { template: 'configs/pangolin/resources.yml.hbs', output: 'configs/pangolin/resources.yml' },
{ template: 'configs/prometheus/prometheus.yml.hbs', output: 'configs/prometheus/prometheus.yml' }, { template: 'configs/prometheus/prometheus.yml.hbs', output: 'configs/prometheus/prometheus.yml' },
{ template: 'configs/grafana/datasources/datasources.yml.hbs', output: 'configs/grafana/datasources/datasources.yml' }, { template: 'configs/grafana/datasources/datasources.yml.hbs', output: 'configs/grafana/datasources/datasources.yml' },
@ -334,9 +341,9 @@ export async function renderAllTemplatesInMemory(
result.push({ relativePath: output, content: rendered }); result.push({ relativePath: output, content: rendered });
} }
// Read static files into memory // Read static files into memory. nginx/nginx.conf removed 2026-05-23 —
// same orphan reason as in renderAllTemplates above.
const staticFiles = [ const staticFiles = [
'nginx/nginx.conf',
'configs/prometheus/alerts.yml', 'configs/prometheus/alerts.yml',
'configs/alertmanager/alertmanager.yml', 'configs/alertmanager/alertmanager.yml',
'configs/grafana/dashboards/dashboards.yml', 'configs/grafana/dashboards/dashboards.yml',

View File

@ -651,14 +651,24 @@ async function runReleaseUpgrade(
}); });
await driver.composePull(instance.basePath, instance.composeProject); await driver.composePull(instance.basePath, instance.composeProject);
// Phase 4: recreate services // Phase 4: recreate services. Restricted to core app services (api,
// admin, media-api, nginx) — same set as scripts/image-upgrade.sh.
// Calling unscoped composeUp would recreate ccp-agent too, severing the
// CCP↔agent connection mid-apply and orphaning the script (same trap as
// upgrade.sh Phase 6 self-destruct fixed in v2.10.2). Until we mirror
// upgrade.sh's deferred-ccp-agent-restart pattern here, stick to the
// explicit service list. Limitation: Approach C will not pick up
// brand-new services in a release until either (a) operator runs full
// upgrade.sh (Approach A) afterwards, or (b) this is upgraded to the
// deferred-restart pattern.
const coreServices = ['api', 'admin', 'media-api', 'nginx'];
await updateStatus({ await updateStatus({
currentPhase: 4, currentPhase: 4,
phaseName: 'Recreate Services', phaseName: 'Recreate Services',
percentage: 80, percentage: 80,
progressMessage: 'Recreating services with new orchestration...', progressMessage: `Recreating core services (${coreServices.join(', ')})...`,
}); });
await driver.composeUp(instance.basePath, instance.composeProject); await driver.composeUp(instance.basePath, instance.composeProject, coreServices);
// Phase 5: verify (best-effort; soft warnings only) // Phase 5: verify (best-effort; soft warnings only)
await updateStatus({ await updateStatus({

View File

@ -4,7 +4,7 @@ datasources:
- name: Prometheus - name: Prometheus
type: prometheus type: prometheus
access: proxy access: proxy
url: http://{{containerPrefix}}-prometheus:9090 url: http://prometheus-changemaker:9090
isDefault: true isDefault: true
editable: true editable: true
jsonData: jsonData:

View File

@ -1,116 +1,155 @@
# Pangolin Resources — Instance: {{name}} # Pangolin Resource Definitions
# All resources route through nginx for consistent subdomain handling # All resources route through Nginx (port 80) by default
# Newt tunnel → Nginx (port 80) → Backend containers (various ports)
#
# target_ip: the hostname/IP that Newt sends traffic to (default: nginx)
# target_port: the port on the target host (default: 80)
resources: resources:
# ─── Always-On Resources ────────────────────────────────── # Required services (fail if down)
- subdomain: app
name: Admin GUI
container: changemaker-v2-admin
port: 3000
target_ip: nginx
target_port: 80
required: true
- name: app - subdomain: api
subdomain: app name: API Server
target: http://{{containerPrefix}}-nginx:80 container: changemaker-v2-api
isBaseDomain: false port: 4000
target_ip: nginx
target_port: 80
required: true
- name: api - subdomain: "" # Root domain
subdomain: api name: Public Site
target: http://{{containerPrefix}}-nginx:80 container: mkdocs-site-server-changemaker
isBaseDomain: false port: 80
target_ip: nginx
target_port: 80
required: true
- name: root # Optional services (warn and skip if down)
subdomain: "" - subdomain: db
target: http://{{containerPrefix}}-nginx:80 name: NocoDB
isBaseDomain: true container: changemaker-v2-nocodb
port: 8080
target_ip: nginx
target_port: 80
required: false
- name: docs - subdomain: docs
subdomain: docs name: Documentation
target: http://{{containerPrefix}}-nginx:80 container: mkdocs-changemaker
isBaseDomain: false port: 8000
target_ip: nginx
target_port: 80
required: false
- name: db - subdomain: code
subdomain: db name: Code Server
target: http://{{containerPrefix}}-nginx:80 container: code-server-changemaker
isBaseDomain: false port: 8080
target_ip: nginx
target_port: 80
required: false
- name: mail - subdomain: n8n
subdomain: mail name: Workflows
target: http://{{containerPrefix}}-nginx:80 container: n8n-changemaker
isBaseDomain: false port: 5678
target_ip: nginx
target_port: 80
required: false
- name: qr - subdomain: git
subdomain: qr name: Gitea
target: http://{{containerPrefix}}-nginx:80 container: gitea-changemaker
isBaseDomain: false port: 3000
target_ip: nginx
target_port: 80
required: false
# ─── Conditional Resources ──────────────────────────────── - subdomain: home
name: Homepage
container: homepage-changemaker
port: 3000
target_ip: nginx
target_port: 80
required: false
{{#if enableMedia}} - subdomain: listmonk
- name: media name: Newsletter
subdomain: media container: listmonk-app
target: http://{{containerPrefix}}-nginx:80 port: 9000
isBaseDomain: false target_ip: nginx
{{/if}} target_port: 80
required: false
{{#if enableListmonk}} - subdomain: qr
- name: listmonk name: Mini QR
subdomain: listmonk container: mini-qr
target: http://{{containerPrefix}}-nginx:80 port: 8080
isBaseDomain: false target_ip: nginx
{{/if}} target_port: 80
required: false
{{#if enableGancio}} - subdomain: draw
- name: events name: Excalidraw
subdomain: events container: excalidraw-changemaker
target: http://{{containerPrefix}}-nginx:80 port: 80
isBaseDomain: false target_ip: nginx
{{/if}} target_port: 80
required: false
{{#if enableChat}} - subdomain: vault
- name: chat name: Vaultwarden
subdomain: chat container: vaultwarden-changemaker
target: http://{{containerPrefix}}-nginx:80 port: 80
isBaseDomain: false target_ip: nginx
{{/if}} target_port: 80
required: false
{{#if enableMeet}} - subdomain: mail
- name: meet name: MailHog
subdomain: meet container: mailhog-changemaker
target: http://{{containerPrefix}}-nginx:80 port: 8025
isBaseDomain: false target_ip: nginx
{{/if}} target_port: 80
required: false
{{#if enableMonitoring}} - subdomain: chat
- name: grafana name: Rocket.Chat
subdomain: grafana container: rocketchat-changemaker
target: http://{{containerPrefix}}-nginx:80 port: 3000
isBaseDomain: false target_ip: nginx
{{/if}} target_port: 80
required: false
{{#if enableDevTools}} - subdomain: events
- name: code name: Gancio Events
subdomain: code container: gancio-changemaker
target: http://{{containerPrefix}}-nginx:80 port: 13120
isBaseDomain: false target_ip: nginx
target_port: 80
required: false
- name: git - subdomain: meet
subdomain: git name: Jitsi Meet
target: http://{{containerPrefix}}-nginx:80 container: jitsi-web-changemaker
isBaseDomain: false port: 80
target_ip: nginx
target_port: 80
required: false
- name: n8n # Monitoring services (auto-detect profile)
subdomain: n8n - subdomain: grafana
target: http://{{containerPrefix}}-nginx:80 name: Grafana
isBaseDomain: false container: grafana-changemaker
port: 3000
- name: home target_ip: nginx
subdomain: home target_port: 80
target: http://{{containerPrefix}}-nginx:80 required: false
isBaseDomain: false profile: monitoring # Auto-detect if monitoring profile active
- name: vault
subdomain: vault
target: http://{{containerPrefix}}-nginx:80
isBaseDomain: false
- name: draw
subdomain: draw
target: http://{{containerPrefix}}-nginx:80
isBaseDomain: false
{{/if}}

View File

@ -1,66 +1,61 @@
# Prometheus — Instance: {{name}}
global: global:
scrape_interval: 15s scrape_interval: 15s
evaluation_interval: 15s evaluation_interval: 15s
external_labels: external_labels:
monitor: '{{composeProject}}' monitor: 'changemaker-lite'
{{#if enableMonitoring}} # Alertmanager configuration
alerting: alerting:
alertmanagers: alertmanagers:
- static_configs: - static_configs:
- targets: ['{{containerPrefix}}-alertmanager:9093'] - targets: ['alertmanager:9093']
# Load rules once and periodically evaluate them
rule_files: rule_files:
- "alerts.yml" - "alerts.yml"
{{/if}}
# Scrape configurations
scrape_configs: scrape_configs:
- job_name: '{{composeProject}}-api' # V2 Unified API Metrics
- job_name: 'changemaker-v2-api'
static_configs: static_configs:
- targets: ['{{containerPrefix}}-api:4000'] - targets: ['changemaker-v2-api:4000']
metrics_path: '/api/metrics' metrics_path: '/api/metrics/internal'
scrape_interval: 10s scrape_interval: 10s
scrape_timeout: 5s scrape_timeout: 5s
{{#if enableMedia}} # N8N Metrics (if available)
- job_name: '{{composeProject}}-media-api' - job_name: 'n8n'
static_configs: static_configs:
- targets: ['{{containerPrefix}}-media-api:4100'] - targets: ['n8n-changemaker:5678']
metrics_path: '/api/metrics' metrics_path: '/metrics'
{{/if}} scrape_interval: 30s
- job_name: '{{composeProject}}-redis' # Redis Metrics
- job_name: 'redis'
static_configs: static_configs:
- targets: ['{{containerPrefix}}-redis-exporter:9121'] - targets: ['redis-exporter:9121']
scrape_interval: 15s scrape_interval: 15s
{{#if enableMonitoring}} # cAdvisor - Docker container metrics
- job_name: '{{composeProject}}-cadvisor' - job_name: 'cadvisor'
static_configs: static_configs:
- targets: ['{{containerPrefix}}-cadvisor:8080'] - targets: ['cadvisor:8080']
scrape_interval: 15s scrape_interval: 15s
- job_name: '{{composeProject}}-node' # Node Exporter - System metrics
- job_name: 'node'
static_configs: static_configs:
- targets: ['{{containerPrefix}}-node-exporter:9100'] - targets: ['node-exporter:9100']
scrape_interval: 15s scrape_interval: 15s
- job_name: '{{composeProject}}-prometheus' # Prometheus self-monitoring
- job_name: 'prometheus'
static_configs: static_configs:
- targets: ['localhost:9090'] - targets: ['localhost:9090']
- job_name: '{{composeProject}}-alertmanager' # Alertmanager monitoring
- job_name: 'alertmanager'
static_configs: static_configs:
- targets: ['{{containerPrefix}}-alertmanager:9093'] - targets: ['alertmanager:9093']
scrape_interval: 30s scrape_interval: 30s
{{/if}}
{{#if enableDevTools}}
- job_name: '{{composeProject}}-n8n'
static_configs:
- targets: ['{{containerPrefix}}-n8n:5678']
metrics_path: '/metrics'
scrape_interval: 30s
{{/if}}

View File

@ -1,24 +1,8 @@
############################################################################### ###############################################################################
############################################################################### ###############################################################################
# Changemaker Lite v2 — Tenant compose (CCP template) # Changemaker Lite v2 — Production Docker Compose
# Instance: {{name}} ({{slug}}) # Pre-built images only. No source code mounts, no build blocks.
# Compose project: {{composeProject}} # Generated from docker-compose.yml by build-release.sh
#
# This template is a byte-mirror of changemaker.lite/docker-compose.prod.yml
# (modulo this header comment). Approach C (CCP-driven release upgrade)
# renders this against the tenant's context and writes the result to the
# tenant's filesystem.
#
# All per-instance variation flows through env-var substitution from the
# tenant's .env file (rendered by env.hbs for CCP-provisioned tenants;
# kept as-is for install.sh-registered tenants). The CCP controls image
# tag selection by writing IMAGE_TAG to the tenant's .env, which the
# compose's ${IMAGE_TAG:-latest} substitution then picks up at compose-up.
#
# To keep this template in sync with canonical docker-compose.prod.yml:
# - When a new service is added to changemaker.lite/docker-compose.prod.yml,
# copy the same block here verbatim. Handlebars is NOT used in the
# compose template itself — all variation is env-var driven.
############################################################################### ###############################################################################
############################################################################### ###############################################################################