feat(demo): Tier 1 zero-deps demo + Tier 2 docker-compose scaffold
Tier 1 (cre demo): in-memory SQLite + tempfile .env rotator. Walks through inventory -> before -> 4-step rotation -> after -> audit chain verify. Narrates every step event live with green/red glyphs. Runs in under 1 second on commodity hardware, no external deps. Tier 2 (docker/docker-compose.yml): postgres:16, localstack (secretsmanager), hashicorp/vault dev mode, plus a fake-GitHub Flask app that mocks /user, POST/DELETE /user/personal-access-tokens. config/demo-full.cr.example documents env vars for the full stack. Also fixed env_file rotator newline preservation (lines(chomp: true) + explicit \n join) so the rotated file is properly delimited.
This commit is contained in:
parent
7ce0cb48bd
commit
a19e8669d2
|
|
@ -0,0 +1,23 @@
|
|||
# ©AngelaMos | 2026
|
||||
# demo-full.cr.example
|
||||
|
||||
# Tier 2 demo config: docker-compose stack on localhost.
|
||||
# Copy to demo-full.cr and load via `cre run --config=...` (full config loader
|
||||
# wired in a future iteration).
|
||||
|
||||
# Database:
|
||||
# DATABASE_URL=postgres://cre:cre@localhost:5432/cre
|
||||
|
||||
# AWS Secrets Manager (LocalStack):
|
||||
# AWS_ACCESS_KEY_ID=test
|
||||
# AWS_SECRET_ACCESS_KEY=test
|
||||
# AWS_REGION=us-east-1
|
||||
# AWS_ENDPOINT=http://localhost:4566
|
||||
|
||||
# Vault:
|
||||
# VAULT_ADDR=http://localhost:8200
|
||||
# VAULT_TOKEN=dev-root-token
|
||||
|
||||
# GitHub (fake API):
|
||||
# GITHUB_API_BASE=http://localhost:7000
|
||||
# GITHUB_TOKEN=ghp_admin_fake
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
# ©AngelaMos | 2026
|
||||
# docker-compose.yml
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
environment:
|
||||
POSTGRES_USER: cre
|
||||
POSTGRES_PASSWORD: cre
|
||||
POSTGRES_DB: cre
|
||||
ports: ["5432:5432"]
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U cre"]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
localstack:
|
||||
image: localstack/localstack:latest
|
||||
environment:
|
||||
SERVICES: secretsmanager
|
||||
DEBUG: 0
|
||||
ports: ["4566:4566"]
|
||||
|
||||
vault:
|
||||
image: hashicorp/vault:latest
|
||||
cap_add: [IPC_LOCK]
|
||||
environment:
|
||||
VAULT_DEV_ROOT_TOKEN_ID: dev-root-token
|
||||
VAULT_DEV_LISTEN_ADDRESS: 0.0.0.0:8200
|
||||
ports: ["8200:8200"]
|
||||
command: vault server -dev
|
||||
|
||||
fake-github:
|
||||
image: python:3.13-alpine
|
||||
working_dir: /app
|
||||
volumes: ["./fake-github:/app:ro"]
|
||||
command: ["sh", "-c", "pip install --quiet flask && python /app/app.py"]
|
||||
ports: ["7000:7000"]
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
"""
|
||||
©AngelaMos | 2026
|
||||
app.py
|
||||
"""
|
||||
|
||||
from flask import Flask, jsonify, request
|
||||
|
||||
app = Flask(__name__)
|
||||
TOKENS: dict[int, dict] = {}
|
||||
NEXT_ID = 100000
|
||||
|
||||
|
||||
@app.route("/user", methods=["GET"])
|
||||
def user():
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if not auth.startswith("Bearer "):
|
||||
return jsonify({"message": "Bad credentials"}), 401
|
||||
return jsonify({"login": "fake-bot", "id": 1})
|
||||
|
||||
|
||||
@app.route("/user/personal-access-tokens", methods=["POST"])
|
||||
def create_pat():
|
||||
global NEXT_ID
|
||||
body = request.get_json() or {}
|
||||
pat_id = NEXT_ID
|
||||
NEXT_ID += 1
|
||||
TOKENS[pat_id] = body
|
||||
return jsonify({
|
||||
"id": pat_id,
|
||||
"token": f"ghp_fake_{pat_id}",
|
||||
"expires_at": "2026-12-31T00:00:00Z",
|
||||
})
|
||||
|
||||
|
||||
@app.route("/user/personal-access-tokens/<int:pat_id>", methods=["DELETE"])
|
||||
def delete_pat(pat_id):
|
||||
TOKENS.pop(pat_id, None)
|
||||
return "", 200
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=7000)
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
# ===================
|
||||
# ©AngelaMos | 2026
|
||||
# tier_1_spec.cr
|
||||
# ===================
|
||||
|
||||
require "../../spec_helper"
|
||||
require "../../../src/cre/demo/tier_1"
|
||||
|
||||
describe CRE::Demo::Tier1 do
|
||||
it "runs end-to-end and reports rotation success" do
|
||||
io = IO::Memory.new
|
||||
code = CRE::Demo::Tier1.run(io)
|
||||
code.should eq 0
|
||||
|
||||
out = CRE::Tui::Ansi.strip(io.to_s)
|
||||
out.should contain "Tier 1 demo"
|
||||
out.should contain "BEFORE"
|
||||
out.should contain "AFTER"
|
||||
out.should contain "rotation completed"
|
||||
out.should contain "audit events, hash chain valid"
|
||||
end
|
||||
end
|
||||
|
|
@ -3,14 +3,115 @@
|
|||
# tier_1.cr
|
||||
# ===================
|
||||
|
||||
require "../engine/event_bus"
|
||||
require "../engine/rotation_orchestrator"
|
||||
require "../persistence/sqlite/sqlite_persistence"
|
||||
require "../rotators/env_file"
|
||||
require "../audit/audit_log"
|
||||
require "../tui/ansi"
|
||||
|
||||
module CRE::Demo
|
||||
# Tier 1 demo is implemented in Phase 15. This stub keeps `cre demo`
|
||||
# wired and compilable until then.
|
||||
module Tier1
|
||||
def self.run(io : IO) : Int32
|
||||
io.puts "Tier 1 demo not yet implemented (Phase 15 of build)."
|
||||
io.puts "Try: cre run --db=sqlite:cre.db"
|
||||
0
|
||||
tmp_env = File.tempname("cre-demo-", ".env")
|
||||
File.write(tmp_env, "API_KEY=oldvalue-aaa\nOTHER=keep\n")
|
||||
|
||||
io.puts CRE::Tui::Ansi.cyan("Credential Rotation Enforcer - Tier 1 demo")
|
||||
io.puts " (in-memory SQLite + ephemeral .env file rotator, zero external deps)"
|
||||
io.puts ""
|
||||
|
||||
persist = CRE::Persistence::Sqlite::SqlitePersistence.new(":memory:")
|
||||
persist.migrate!
|
||||
log = CRE::Audit::AuditLog.new(persist, Bytes.new(32, 0_u8), 1, 1024)
|
||||
|
||||
cred_id = UUID.random
|
||||
cred = CRE::Domain::Credential.new(
|
||||
id: cred_id,
|
||||
external_id: "demo-#{tmp_env}",
|
||||
kind: CRE::Domain::CredentialKind::EnvFile,
|
||||
name: "API_KEY",
|
||||
tags: {
|
||||
"path" => tmp_env,
|
||||
"key" => "API_KEY",
|
||||
"bytes" => "16",
|
||||
} of String => String,
|
||||
updated_at: Time.utc - 60.days,
|
||||
)
|
||||
persist.credentials.insert(cred)
|
||||
|
||||
io.puts CRE::Tui::Ansi.bold("Step 1 - Inventory:")
|
||||
io.puts " - #{cred.kind} '#{cred.name}' (id=#{short(cred.id)})"
|
||||
io.puts " last updated #{cred.updated_at.to_rfc3339} (60 days ago - overdue)"
|
||||
io.puts ""
|
||||
|
||||
io.puts CRE::Tui::Ansi.bold("Step 2 - File contents BEFORE:")
|
||||
File.read(tmp_env).each_line { |line| io.puts " #{line}" }
|
||||
io.puts ""
|
||||
|
||||
io.puts CRE::Tui::Ansi.bold("Step 3 - Rotating (4-step contract):")
|
||||
bus = CRE::Engine::EventBus.new
|
||||
ch = bus.subscribe(buffer: 256)
|
||||
bus.run
|
||||
|
||||
rotator = CRE::Rotators::EnvFileRotator.new
|
||||
orchestrator = CRE::Engine::RotationOrchestrator.new(bus, persist)
|
||||
state = orchestrator.run(cred, rotator)
|
||||
|
||||
sleep 0.05.seconds
|
||||
drain_steps(ch, io)
|
||||
|
||||
bus.stop
|
||||
|
||||
io.puts ""
|
||||
io.puts CRE::Tui::Ansi.bold("Step 4 - File contents AFTER:")
|
||||
File.read(tmp_env).each_line { |line| io.puts " #{line}" }
|
||||
io.puts ""
|
||||
|
||||
io.puts CRE::Tui::Ansi.bold("Step 5 - Audit chain verification:")
|
||||
ok = log.verify_chain
|
||||
latest_seq = persist.audit.latest_seq
|
||||
if ok
|
||||
io.puts " #{CRE::Tui::Ansi.green("✓")} #{latest_seq} audit events, hash chain valid"
|
||||
else
|
||||
io.puts " #{CRE::Tui::Ansi.red("✗")} audit chain BROKEN"
|
||||
end
|
||||
|
||||
persist.close
|
||||
File.delete(tmp_env) if File.exists?(tmp_env)
|
||||
|
||||
io.puts ""
|
||||
io.puts CRE::Tui::Ansi.dim("Demo complete. State #{state}. Try 'cre run --db=sqlite:cre.db' for the daemon.")
|
||||
state == CRE::Persistence::RotationState::Completed ? 0 : 1
|
||||
end
|
||||
|
||||
private def self.drain_steps(ch : ::Channel(CRE::Events::Event), io : IO) : Nil
|
||||
loop do
|
||||
select
|
||||
when ev = ch.receive
|
||||
narrate(ev, io)
|
||||
else
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private def self.narrate(ev : CRE::Events::Event, io : IO) : Nil
|
||||
case ev
|
||||
when CRE::Events::RotationStepStarted
|
||||
io.puts " - step started: #{ev.step}"
|
||||
when CRE::Events::RotationStepCompleted
|
||||
io.puts " #{CRE::Tui::Ansi.green("✓")} step completed: #{ev.step}"
|
||||
when CRE::Events::RotationStepFailed
|
||||
io.puts " #{CRE::Tui::Ansi.red("✗")} step failed: #{ev.step} (#{ev.error})"
|
||||
when CRE::Events::RotationCompleted
|
||||
io.puts " #{CRE::Tui::Ansi.green("✓")} rotation completed"
|
||||
when CRE::Events::RotationFailed
|
||||
io.puts " #{CRE::Tui::Ansi.red("✗")} rotation failed: #{ev.reason}"
|
||||
end
|
||||
end
|
||||
|
||||
private def self.short(uuid : UUID) : String
|
||||
uuid.to_s[0, 8]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -44,11 +44,11 @@ module CRE::Rotators
|
|||
pending_path = "#{path}.pending"
|
||||
|
||||
existing = File.exists?(path) ? File.read(path) : ""
|
||||
lines = existing.lines.reject { |line| line.strip.starts_with?("#{key}=") }
|
||||
lines = existing.lines(chomp: true).reject { |line| line.strip.starts_with?("#{key}=") }
|
||||
new_value = String.new(s.ciphertext)
|
||||
lines << "#{key}=#{new_value}\n"
|
||||
lines << "#{key}=#{new_value}"
|
||||
|
||||
File.write(pending_path, lines.join, perm: 0o600)
|
||||
File.write(pending_path, lines.join('\n') + "\n", perm: 0o600)
|
||||
end
|
||||
|
||||
def verify(c : Domain::Credential, s : Domain::NewSecret) : Bool
|
||||
|
|
|
|||
Loading…
Reference in New Issue